Move marketplace plugin updates from hook to /update-agnes-plugins skill (#237)
* Move marketplace plugin updates from hook to /update-agnes-plugins skill The SessionStart hook used to run `agnes refresh-marketplace --quiet`, which performed a full fetch+reset+install cycle on every Claude Code session start. That work was invisible to the user, slowed session startup, and was unrecoverable interactively when something failed. Split the responsibility: - `agnes refresh-marketplace --check` is a new lightweight detector: `git fetch` only, compares local HEAD with remote FETCH_HEAD, emits a Claude Code hook JSON message pointing the user at `/update-agnes-plugins` when the marketplace has changes. No reset, no plugin install/update side effects. - `/update-agnes-plugins` is a new slash command (installed by `agnes init` into `<workspace>/.claude/commands/`) that runs `agnes refresh-marketplace` (default chatty path). Output streams into the Claude Code transcript so the user sees install/update progress and can react to errors interactively. - The SessionStart hook now runs `--check`. Existing workspaces auto-upgrade on next `agnes init` (substring marker matches both the old `--quiet` entry and the new `--check` one). BREAKING: `agnes refresh-marketplace --quiet` is removed. Old hooks calling it silent-noop after the CLI upgrade (the hook's `|| true` swallows the unknown-flag error) until re-init rewrites them. * Point marketplace 'Added to your stack' hint at /update-agnes-plugins The post-install green panel on plugin and skill/agent detail pages referenced the SessionStart auto-install path and a shell-prompt `agnes refresh-marketplace` invocation. With the hook now being detect-only, that copy was misleading — the actual install path is the new slash command. Condensed to a single instruction: "Open a new Claude Code session and run:" followed by `/update-agnes-plugins` in a copy-chip. JS clipboard string updated to match. --------- Co-authored-by: Minas Arustamyan <arustamyan.minas@gmail.com>
This commit is contained in:
parent
d269c69359
commit
d9405a6888
11 changed files with 647 additions and 283 deletions
47
CHANGELOG.md
47
CHANGELOG.md
|
|
@ -10,8 +10,55 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **`/update-agnes-plugins` slash command** — installed automatically by
|
||||
`agnes init` into `<workspace>/.claude/commands/`. Runs
|
||||
`agnes refresh-marketplace` (the chatty default mode) so the user sees
|
||||
install/update progress streamed into the Claude Code transcript and
|
||||
can react to errors interactively, instead of having a full reconcile
|
||||
happen silently behind a SessionStart hook.
|
||||
|
||||
- **`agnes refresh-marketplace --check`** — lightweight detector mode for
|
||||
the SessionStart hook. Runs `git fetch` only, compares local `HEAD`
|
||||
with remote `FETCH_HEAD`, and emits a Claude Code hook JSON message
|
||||
pointing the user at `/update-agnes-plugins` when there are remote
|
||||
changes. Silent when up to date. No `git reset`, no
|
||||
`claude plugin marketplace update`, no plugin install/update side
|
||||
effects.
|
||||
|
||||
### Changed
|
||||
|
||||
- **SessionStart marketplace hook is now read-only.** The hook installed
|
||||
by `agnes init` was previously `agnes refresh-marketplace --quiet`,
|
||||
which performed a full fetch+reset+install cycle on every session start
|
||||
(slow, invisible to the user, not interactively recoverable). It now
|
||||
runs `agnes refresh-marketplace --check` — detect-only — and surfaces a
|
||||
hint to run `/update-agnes-plugins` when updates are available.
|
||||
Existing workspaces auto-upgrade on next `agnes init` (the substring
|
||||
marker `agnes refresh-marketplace` matches both the old and new entry
|
||||
shapes, so the idempotent-replace path correctly rewrites them).
|
||||
|
||||
- **Marketplace "Added to your stack" hint points at `/update-agnes-plugins`.**
|
||||
The post-install green panel on plugin and skill/agent detail pages
|
||||
used to suggest `agnes refresh-marketplace` in a shell prompt and
|
||||
reference the SessionStart auto-install. With the hook now being
|
||||
detect-only, that text was outdated. The hint is condensed to a
|
||||
single instruction — open a new Claude Code session and run
|
||||
`/update-agnes-plugins` — with the slash command in a copy chip.
|
||||
Affects `marketplace_plugin_detail.html` and `marketplace_item_detail.html`.
|
||||
|
||||
### Removed
|
||||
|
||||
- **BREAKING: `agnes refresh-marketplace --quiet` flag.** Replaced by
|
||||
`--check` (detect-only) and the new `/update-agnes-plugins` slash
|
||||
command (interactive update). Existing SessionStart hooks calling
|
||||
`--quiet` will silent-noop after the CLI upgrade — the hook's
|
||||
`2>/dev/null || true` swallows the unknown-flag error — until the user
|
||||
re-runs `agnes init`, which rewrites the hook to use `--check` and
|
||||
installs the slash command. Dashboard `/setup` flow re-runs
|
||||
`agnes init` automatically on next paste.
|
||||
|
||||
- **BREAKING: legacy `git config --global http.<host>.sslVerify=false`
|
||||
downgrade in the install setup prompt.** The marketplace step (step 5)
|
||||
used to emit this line on `AGNES_DEBUG_AUTH=1` instances when no
|
||||
|
|
|
|||
|
|
@ -523,18 +523,12 @@
|
|||
<span class="title">✓ Added to your stack</span>
|
||||
<button class="dismiss" id="stack-hint-dismiss" type="button">Don’t show again</button>
|
||||
</div>
|
||||
<div>To use it in Claude Code:</div>
|
||||
<ol>
|
||||
<li><strong>Open a new Claude Code session</strong> — it auto-installs via the SessionStart hook.</li>
|
||||
<li>Or run now in your terminal:
|
||||
<div class="cmd-chip">
|
||||
<span class="prompt">$</span>
|
||||
<span class="cmd">agnes refresh-marketplace</span>
|
||||
<button class="btn-copy" id="stack-hint-copy" type="button">Copy</button>
|
||||
</div>
|
||||
Then in the running session: <code>/reload-plugins</code>
|
||||
</li>
|
||||
</ol>
|
||||
<div>Open a new Claude Code session and run:</div>
|
||||
<div class="cmd-chip">
|
||||
<span class="prompt">/</span>
|
||||
<span class="cmd">update-agnes-plugins</span>
|
||||
<button class="btn-copy" id="stack-hint-copy" type="button">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="panel details">
|
||||
|
|
@ -804,7 +798,7 @@
|
|||
copyBtn.addEventListener('click', async (ev) => {
|
||||
const b = ev.currentTarget;
|
||||
try {
|
||||
await navigator.clipboard.writeText('agnes refresh-marketplace');
|
||||
await navigator.clipboard.writeText('/update-agnes-plugins');
|
||||
const orig = b.textContent;
|
||||
b.classList.add('copied');
|
||||
b.textContent = 'Copied';
|
||||
|
|
|
|||
|
|
@ -512,18 +512,12 @@
|
|||
<span class="title">✓ Added to your stack</span>
|
||||
<button class="dismiss" id="stack-hint-dismiss" type="button">Don’t show again</button>
|
||||
</div>
|
||||
<div>To use it in Claude Code:</div>
|
||||
<ol>
|
||||
<li><strong>Open a new Claude Code session</strong> — it auto-installs via the SessionStart hook.</li>
|
||||
<li>Or run now in your terminal:
|
||||
<div class="cmd-chip">
|
||||
<span class="prompt">$</span>
|
||||
<span class="cmd">agnes refresh-marketplace</span>
|
||||
<button class="btn-copy" id="stack-hint-copy" type="button">Copy</button>
|
||||
</div>
|
||||
Then in the running session: <code>/reload-plugins</code>
|
||||
</li>
|
||||
</ol>
|
||||
<div>Open a new Claude Code session and run:</div>
|
||||
<div class="cmd-chip">
|
||||
<span class="prompt">/</span>
|
||||
<span class="cmd">update-agnes-plugins</span>
|
||||
<button class="btn-copy" id="stack-hint-copy" type="button">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel details" id="panel-details">
|
||||
|
|
@ -709,7 +703,7 @@
|
|||
document.getElementById('stack-hint-copy').addEventListener('click', async (ev) => {
|
||||
const copyBtn = ev.currentTarget;
|
||||
try {
|
||||
await navigator.clipboard.writeText('agnes refresh-marketplace');
|
||||
await navigator.clipboard.writeText('/update-agnes-plugins');
|
||||
const orig = copyBtn.textContent;
|
||||
copyBtn.classList.add('copied');
|
||||
copyBtn.textContent = 'Copied';
|
||||
|
|
|
|||
|
|
@ -16,7 +16,9 @@ Steps in order:
|
|||
RBAC-filtered, role-aware).
|
||||
5. Seed `.claude/settings.json` with default model + permissions, then call
|
||||
`cli.lib.hooks.install_claude_hooks` to merge in the SessionStart/End hook
|
||||
commands. Idempotent on re-run.
|
||||
commands. Then call `cli.lib.commands.install_claude_commands` to drop
|
||||
the Agnes-managed slash commands (today: `/update-agnes-plugins`) into
|
||||
`<workspace>/.claude/commands/`. Idempotent on re-run.
|
||||
6. Write the `.claude/CLAUDE.local.md` stub only when absent — `--force`
|
||||
regenerates CLAUDE.md but **never** clobbers the operator-edited
|
||||
CLAUDE.local.md.
|
||||
|
|
@ -44,6 +46,7 @@ import typer
|
|||
from cli.client import api_get
|
||||
from cli.config import save_config, save_token
|
||||
from cli.error_render import render_error
|
||||
from cli.lib.commands import install_claude_commands
|
||||
from cli.lib.hooks import install_claude_hooks
|
||||
from cli.lib.pull import PullResult, _override_server_env, run_pull
|
||||
|
||||
|
|
@ -190,6 +193,7 @@ def init(
|
|||
indent=2,
|
||||
), encoding="utf-8")
|
||||
install_claude_hooks(workspace)
|
||||
install_claude_commands(workspace)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step 6: CLAUDE.local.md stub — only when absent. `--force` does NOT
|
||||
|
|
|
|||
|
|
@ -5,15 +5,22 @@ Three call paths share the same code:
|
|||
- `agnes refresh-marketplace --bootstrap` — first-time setup; clones the
|
||||
per-user marketplace bare repo, registers it with Claude Code, then
|
||||
falls through to fetch+reset+reconcile so plugins land installed.
|
||||
- `agnes refresh-marketplace` — manual re-sync after a known stack change.
|
||||
- `agnes refresh-marketplace --quiet` — SessionStart hook context. Emits
|
||||
a Claude Code hook JSON object on stdout when something actually got
|
||||
installed/updated; silent otherwise.
|
||||
- `agnes refresh-marketplace` — manual re-sync after a known stack
|
||||
change. This is what the `/update-agnes-plugins` slash command runs
|
||||
inside Claude Code so the user sees install/update progress in the
|
||||
transcript.
|
||||
- `agnes refresh-marketplace --check` — SessionStart hook context.
|
||||
Lightweight detector: `git fetch` only (no reset, no plugin
|
||||
install/update side effects), compares local `HEAD` vs `FETCH_HEAD`,
|
||||
emits a Claude Code hook JSON message pointing the user at
|
||||
`/update-agnes-plugins` when there are remote changes. Silent
|
||||
otherwise.
|
||||
|
||||
Reconcile is version-aware (install missing / update on version diff /
|
||||
skip on match). Server-side stack composition lives in
|
||||
`src/marketplace_filter.py:resolve_user_marketplace`. Plugin installs use
|
||||
`--scope project` so they land in the workspace the hook fired in.
|
||||
Reconcile (default + --bootstrap paths) is version-aware (install
|
||||
missing / update on version diff / skip on match). Server-side stack
|
||||
composition lives in `src/marketplace_filter.py:resolve_user_marketplace`.
|
||||
Plugin installs use `--scope project` so they land in the workspace the
|
||||
caller invoked from.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -47,9 +54,17 @@ _CREDENTIAL_HELPER = '!f() { printf "username=x\\npassword=%s\\n" "$AGNES_TOKEN"
|
|||
|
||||
@refresh_marketplace_app.callback(invoke_without_command=True)
|
||||
def refresh_marketplace(
|
||||
quiet: bool = typer.Option(
|
||||
False, "--quiet",
|
||||
help="Suppress success stdout (errors and warnings still surface on stderr).",
|
||||
check: bool = typer.Option(
|
||||
False, "--check",
|
||||
help=(
|
||||
"Detect-only mode for the SessionStart hook. Runs `git fetch` "
|
||||
"and compares local HEAD with remote FETCH_HEAD. When they "
|
||||
"differ, emits a Claude Code hook JSON message hinting the "
|
||||
"user at `/update-agnes-plugins`. No `git reset`, no plugin "
|
||||
"install/update side effects — fast, invisible when nothing "
|
||||
"changed, fully recoverable interactively via the slash "
|
||||
"command."
|
||||
),
|
||||
),
|
||||
bootstrap: bool = typer.Option(
|
||||
False, "--bootstrap",
|
||||
|
|
@ -62,6 +77,13 @@ def refresh_marketplace(
|
|||
),
|
||||
):
|
||||
"""Sync the marketplace clone, re-register with Claude, install/update plugins."""
|
||||
if check and bootstrap:
|
||||
typer.echo(
|
||||
"error: --check and --bootstrap are mutually exclusive.",
|
||||
err=True,
|
||||
)
|
||||
raise typer.Exit(2)
|
||||
|
||||
clone_exists = (CLONE_DIR / ".git").is_dir()
|
||||
|
||||
# Hook contexts hit the no-clone path on every workspace that didn't
|
||||
|
|
@ -69,7 +91,7 @@ def refresh_marketplace(
|
|||
# workspaces with the hook installed but no agnes token configured
|
||||
# (fresh CI checkout, etc.) must silent-noop, not surface auth_failed.
|
||||
if not clone_exists and not bootstrap:
|
||||
if not quiet:
|
||||
if not check:
|
||||
typer.echo(
|
||||
f"No marketplace clone at {CLONE_DIR} — nothing to refresh. "
|
||||
"Re-run setup with `agnes refresh-marketplace --bootstrap` "
|
||||
|
|
@ -89,12 +111,22 @@ def refresh_marketplace(
|
|||
raise typer.Exit(1)
|
||||
|
||||
if not clone_exists:
|
||||
if not _bootstrap_clone(token, quiet=quiet):
|
||||
if not _bootstrap_clone(token):
|
||||
raise typer.Exit(1)
|
||||
|
||||
# --check: lightweight detector. Don't fetch+reset, don't reconcile
|
||||
# plugins — that's the slash command's job. Just check whether the
|
||||
# remote has new content and tell the user if so.
|
||||
if check:
|
||||
if not _git_fetch_only(token):
|
||||
raise typer.Exit(1)
|
||||
if _has_remote_changes():
|
||||
_emit_check_hook_message()
|
||||
raise typer.Exit(0)
|
||||
|
||||
events: dict[str, list[str]] = {"installed": [], "updated": []}
|
||||
|
||||
if not _git_fetch_and_reset(token, quiet=quiet):
|
||||
if not _git_fetch_and_reset(token):
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Snapshot installed versions BEFORE `claude plugin marketplace update`.
|
||||
|
|
@ -105,20 +137,18 @@ def refresh_marketplace(
|
|||
# would fire despite the plugin having actually changed.
|
||||
installed_pre = _list_installed_agnes_plugins_in_cwd()
|
||||
|
||||
_claude_marketplace_update(quiet=quiet)
|
||||
_claude_marketplace_update()
|
||||
|
||||
_reconcile_with_manifest(quiet=quiet, events=events, installed_pre=installed_pre)
|
||||
_reconcile_with_manifest(events=events, installed_pre=installed_pre)
|
||||
|
||||
if quiet and (events["installed"] or events["updated"]):
|
||||
_emit_hook_message(events)
|
||||
elif not quiet and (events["installed"] or events["updated"]):
|
||||
if events["installed"] or events["updated"]:
|
||||
typer.echo(
|
||||
"\nRun `/reload-plugins` in Claude Code to load the "
|
||||
"new/updated plugins into the running session — no restart needed."
|
||||
)
|
||||
|
||||
|
||||
def _bootstrap_clone(token: str, *, quiet: bool) -> bool:
|
||||
def _bootstrap_clone(token: str) -> bool:
|
||||
"""Initial clone of the per-user marketplace bare repo into ~/.agnes/marketplace.
|
||||
|
||||
Wrapping the destructive prep in the agnes binary lets the CLI's
|
||||
|
|
@ -156,8 +186,7 @@ def _bootstrap_clone(token: str, *, quiet: bool) -> bool:
|
|||
auth_url = f"{scheme}://x:{token}@{server_host}/marketplace.git/"
|
||||
clean_url = f"{scheme}://{server_host}/marketplace.git/"
|
||||
|
||||
if not quiet:
|
||||
typer.echo(f"Cloning marketplace from {clean_url} into {CLONE_DIR}...")
|
||||
typer.echo(f"Cloning marketplace from {clean_url} into {CLONE_DIR}...")
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
|
|
@ -206,20 +235,21 @@ def _bootstrap_clone(token: str, *, quiet: bool) -> bool:
|
|||
)
|
||||
if add.stderr:
|
||||
typer.echo(add.stderr.rstrip(), err=True)
|
||||
elif not quiet and add.stdout:
|
||||
elif add.stdout:
|
||||
typer.echo(add.stdout.rstrip())
|
||||
|
||||
if not quiet:
|
||||
typer.echo(f"Marketplace bootstrapped at {CLONE_DIR}.")
|
||||
typer.echo(f"Marketplace bootstrapped at {CLONE_DIR}.")
|
||||
return True
|
||||
|
||||
|
||||
def _git_fetch_and_reset(token: str, *, quiet: bool) -> bool:
|
||||
"""Fetch from origin then hard-reset to FETCH_HEAD.
|
||||
def _git_fetch_only(token: str) -> bool:
|
||||
"""Fetch from origin without resetting the working tree.
|
||||
|
||||
Not `pull --ff-only`: the marketplace bare repo on the server rebuilds
|
||||
as a fresh orphan commit on every content change, so two snapshots
|
||||
have unrelated histories and fast-forward is impossible.
|
||||
Used by `--check` to learn whether the remote has new content without
|
||||
actually applying it. The bare repo on the server rebuilds as a fresh
|
||||
orphan commit on every content change, so FETCH_HEAD is always the
|
||||
full new tree — comparing local HEAD to FETCH_HEAD is sufficient to
|
||||
detect remote-side changes.
|
||||
"""
|
||||
env = {**os.environ, "AGNES_TOKEN": token}
|
||||
fetch_cmd = [
|
||||
|
|
@ -234,7 +264,7 @@ def _git_fetch_and_reset(token: str, *, quiet: bool) -> bool:
|
|||
encoding="utf-8", errors="replace", check=False,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
typer.echo("error: `git` not found in PATH; cannot refresh marketplace.", err=True)
|
||||
typer.echo("error: `git` not found in PATH; cannot check marketplace.", err=True)
|
||||
return False
|
||||
if fetch.returncode != 0:
|
||||
if fetch.stdout:
|
||||
|
|
@ -242,6 +272,41 @@ def _git_fetch_and_reset(token: str, *, quiet: bool) -> bool:
|
|||
if fetch.stderr:
|
||||
typer.echo(fetch.stderr, err=True)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _has_remote_changes() -> bool:
|
||||
"""Return True iff local HEAD differs from remote FETCH_HEAD.
|
||||
|
||||
Caller must have already run `git fetch origin`. Any rev-parse failure
|
||||
(missing FETCH_HEAD, broken repo) is treated as "no detectable changes"
|
||||
so the hook stays quiet rather than surfacing a misleading hint.
|
||||
"""
|
||||
try:
|
||||
local = subprocess.run(
|
||||
["git", "-C", str(CLONE_DIR), "rev-parse", "HEAD"],
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace", check=False,
|
||||
)
|
||||
remote = subprocess.run(
|
||||
["git", "-C", str(CLONE_DIR), "rev-parse", "FETCH_HEAD"],
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace", check=False,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
if local.returncode != 0 or remote.returncode != 0:
|
||||
return False
|
||||
return local.stdout.strip() != remote.stdout.strip()
|
||||
|
||||
|
||||
def _git_fetch_and_reset(token: str) -> bool:
|
||||
"""Fetch from origin then hard-reset to FETCH_HEAD.
|
||||
|
||||
Not `pull --ff-only`: the marketplace bare repo on the server rebuilds
|
||||
as a fresh orphan commit on every content change, so two snapshots
|
||||
have unrelated histories and fast-forward is impossible.
|
||||
"""
|
||||
if not _git_fetch_only(token):
|
||||
return False
|
||||
|
||||
reset = subprocess.run(
|
||||
["git", "-C", str(CLONE_DIR), "reset", "--hard", "FETCH_HEAD"],
|
||||
|
|
@ -254,12 +319,12 @@ def _git_fetch_and_reset(token: str, *, quiet: bool) -> bool:
|
|||
typer.echo(reset.stderr, err=True)
|
||||
return False
|
||||
|
||||
if not quiet and reset.stdout:
|
||||
if reset.stdout:
|
||||
typer.echo(reset.stdout.rstrip())
|
||||
return True
|
||||
|
||||
|
||||
def _claude_marketplace_update(*, quiet: bool) -> None:
|
||||
def _claude_marketplace_update() -> None:
|
||||
"""Tell Claude Code to re-read the marketplace clone. Soft-fail if `claude` is missing."""
|
||||
if shutil.which("claude") is None:
|
||||
typer.echo(
|
||||
|
|
@ -280,13 +345,12 @@ def _claude_marketplace_update(*, quiet: bool) -> None:
|
|||
if result.stderr:
|
||||
typer.echo(result.stderr.rstrip(), err=True)
|
||||
return
|
||||
if not quiet and result.stdout:
|
||||
if result.stdout:
|
||||
typer.echo(result.stdout.rstrip())
|
||||
|
||||
|
||||
def _reconcile_with_manifest(
|
||||
*,
|
||||
quiet: bool,
|
||||
events: dict[str, list[str]],
|
||||
installed_pre: Optional[dict[str, str]] = None,
|
||||
) -> None:
|
||||
|
|
@ -329,15 +393,13 @@ def _reconcile_with_manifest(
|
|||
to_update.append(name)
|
||||
|
||||
if not to_install and not to_update:
|
||||
if not quiet:
|
||||
typer.echo(f"All {len(manifest)} Agnes-stack plugin(s) up to date.")
|
||||
typer.echo(f"All {len(manifest)} Agnes-stack plugin(s) up to date.")
|
||||
return
|
||||
|
||||
if not quiet:
|
||||
if to_install:
|
||||
typer.echo(f"Installing {len(to_install)} new plugin(s): " + ", ".join(to_install))
|
||||
if to_update:
|
||||
typer.echo(f"Updating {len(to_update)} plugin(s) to latest version: " + ", ".join(to_update))
|
||||
if to_install:
|
||||
typer.echo(f"Installing {len(to_install)} new plugin(s): " + ", ".join(to_install))
|
||||
if to_update:
|
||||
typer.echo(f"Updating {len(to_update)} plugin(s) to latest version: " + ", ".join(to_update))
|
||||
|
||||
for name in to_install:
|
||||
target = f"{name}@{MARKETPLACE_NAME}"
|
||||
|
|
@ -354,7 +416,7 @@ def _reconcile_with_manifest(
|
|||
typer.echo(result.stderr.rstrip(), err=True)
|
||||
continue
|
||||
events["installed"].append(name)
|
||||
if not quiet and result.stdout:
|
||||
if result.stdout:
|
||||
typer.echo(result.stdout.rstrip())
|
||||
|
||||
for name in to_update:
|
||||
|
|
@ -372,40 +434,28 @@ def _reconcile_with_manifest(
|
|||
typer.echo(result.stderr.rstrip(), err=True)
|
||||
continue
|
||||
events["updated"].append(name)
|
||||
if not quiet and result.stdout:
|
||||
if result.stdout:
|
||||
typer.echo(result.stdout.rstrip())
|
||||
|
||||
|
||||
def _emit_hook_message(events: dict[str, list[str]]) -> None:
|
||||
"""Emit Claude Code hook JSON summarizing what changed.
|
||||
def _emit_check_hook_message() -> None:
|
||||
"""Emit Claude Code hook JSON pointing the user at `/update-agnes-plugins`.
|
||||
|
||||
`systemMessage` is a transient toast (often missed). `additionalContext`
|
||||
is wrapped in a system reminder Claude reads at session start, so the
|
||||
model can mention the change if it's relevant to the user's first ask.
|
||||
Plugins land on disk during the hook; `/reload-plugins` loads them into
|
||||
the running session without a restart.
|
||||
`systemMessage` is a transient toast; `additionalContext` is wrapped in
|
||||
a system reminder Claude reads at session start, so the model can
|
||||
proactively mention the available update if the user's first ask is
|
||||
plugin-related. The hook itself does NOT install anything — running
|
||||
the slash command is the user's choice.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
if events["installed"]:
|
||||
parts.append(
|
||||
f"installed {len(events['installed'])} plugin(s): "
|
||||
+ ", ".join(events["installed"])
|
||||
)
|
||||
if events["updated"]:
|
||||
parts.append(
|
||||
f"updated {len(events['updated'])} plugin(s): "
|
||||
+ ", ".join(events["updated"])
|
||||
)
|
||||
summary = "Your Agnes stack changed: " + "; ".join(parts) + "."
|
||||
restart_hint = (
|
||||
"Run `/reload-plugins` to load the changes into this session — "
|
||||
"no restart needed."
|
||||
summary = (
|
||||
"Agnes marketplace has updates available. "
|
||||
"Run /update-agnes-plugins to install them."
|
||||
)
|
||||
payload = {
|
||||
"systemMessage": f"{summary} {restart_hint}",
|
||||
"systemMessage": summary,
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "SessionStart",
|
||||
"additionalContext": f"{summary} {restart_hint}",
|
||||
"additionalContext": summary,
|
||||
},
|
||||
}
|
||||
typer.echo(json.dumps(payload))
|
||||
|
|
|
|||
82
cli/lib/commands.py
Normal file
82
cli/lib/commands.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"""Workspace-scoped Claude Code slash-command installer.
|
||||
|
||||
Sibling to `cli/lib/hooks.py`. Where hooks live in
|
||||
`<workspace>/.claude/settings.json`, slash commands live as one
|
||||
markdown file per command in `<workspace>/.claude/commands/`. This
|
||||
module installs the Agnes-managed slash commands into a workspace.
|
||||
|
||||
Design notes:
|
||||
- Workspace-scoped (`<workspace>/.claude/commands/<name>.md`), NOT
|
||||
user-home. The slash commands appear only when Claude Code opens
|
||||
this workspace, matching the hook scoping in `hooks.py`.
|
||||
- Idempotent: always overwrites *our* files (server-managed canonical
|
||||
content, naturally evolves with the CLI version) but never touches
|
||||
third-party slash commands the user (or another tool) may have
|
||||
authored under `.claude/commands/`. Listing files individually
|
||||
rather than wiping the directory keeps custom commands safe.
|
||||
- Templates ship inside the wheel under `cli/templates/commands/`.
|
||||
`pyproject.toml` declares `cli` as a hatch wheel package, so
|
||||
hatchling includes the markdown bodies during the build the same
|
||||
way it ships `config/agnes_workspace_template.txt`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# Slash commands managed by `agnes init`. Source (template name on
|
||||
# disk under `cli/templates/commands/`) → destination filename in
|
||||
# `<workspace>/.claude/commands/`. Today both names match; the indirection
|
||||
# keeps the door open for renaming (e.g. internal template name vs the
|
||||
# `/<command>` slug exposed to Claude Code).
|
||||
_MANAGED_COMMANDS: tuple[tuple[str, str], ...] = (
|
||||
("update-agnes-plugins.md", "update-agnes-plugins.md"),
|
||||
)
|
||||
|
||||
|
||||
# Defensive fallback used when the bundled template is missing on disk
|
||||
# (broken install, stripped-down test environment). Mirrors the
|
||||
# `agnes_workspace_template.txt` fallback in `cli/commands/init.py` —
|
||||
# better to write a usable stub than to crash `agnes init`.
|
||||
_FALLBACK_BODY = (
|
||||
"---\n"
|
||||
"description: Update Agnes marketplace plugins to latest versions\n"
|
||||
"---\n"
|
||||
"\n"
|
||||
"Run `agnes refresh-marketplace` and report the output.\n"
|
||||
)
|
||||
|
||||
|
||||
def _templates_dir() -> Path:
|
||||
"""Locate the bundled-template directory.
|
||||
|
||||
`cli/lib/commands.py` → `cli/templates/commands/`.
|
||||
Two `.parent` hops: lib/ → cli/, then descend into templates/commands/.
|
||||
"""
|
||||
return Path(__file__).parent.parent / "templates" / "commands"
|
||||
|
||||
|
||||
def install_claude_commands(workspace: Path) -> None:
|
||||
"""Install Agnes-managed slash commands into `<workspace>/.claude/commands/`.
|
||||
|
||||
Always writes (overwrites) the managed command files; never touches
|
||||
other files the user may have under `.claude/commands/`. Idempotent.
|
||||
"""
|
||||
commands_dir = workspace / ".claude" / "commands"
|
||||
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
templates_dir = _templates_dir()
|
||||
for source_name, dest_name in _MANAGED_COMMANDS:
|
||||
source_path = templates_dir / source_name
|
||||
try:
|
||||
body = source_path.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
print(
|
||||
f"Warning: bundled slash-command template "
|
||||
f"{source_path} missing; writing defensive fallback.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
body = _FALLBACK_BODY
|
||||
(commands_dir / dest_name).write_text(body, encoding="utf-8")
|
||||
|
|
@ -18,9 +18,14 @@ Design notes:
|
|||
so any wire-protocol bump lands before pull tries to use the new
|
||||
CLI version. Both `|| true`-guarded so an upgrade failure doesn't
|
||||
block the pull.
|
||||
2. `agnes refresh-marketplace` — independent entry so a fresh
|
||||
workspace (no marketplace cloned yet) failing this command doesn't
|
||||
suppress the data pull above.
|
||||
2. `agnes refresh-marketplace --check` — independent entry. Detector-
|
||||
only (since the slash-command split): runs `git fetch` against the
|
||||
marketplace clone and emits a Claude Code hook JSON message
|
||||
hinting the user at `/update-agnes-plugins` when remote content
|
||||
changed. Does NOT install/update plugins itself — the slash
|
||||
command does that interactively, with full output visible in the
|
||||
Claude Code transcript and under user control. Failure (no clone,
|
||||
no token) silently no-ops via the surrounding `|| true`.
|
||||
3. `agnes push` — uploads any session JSONLs that haven't reached the
|
||||
server yet (orphans from `claude -p` headless mode where Claude Code
|
||||
does NOT fire SessionEnd, or from abnormal session exits). Symmetric
|
||||
|
|
@ -110,10 +115,17 @@ def install_claude_hooks(workspace: Path) -> None:
|
|||
# isn't churned for parity (the same redirection fluff applies but
|
||||
# changing the existing wire would force every workspace to re-write
|
||||
# its settings.json on the next `agnes init` for no behaviour gain).
|
||||
#
|
||||
# `--check` makes the marketplace entry a detector only: the actual
|
||||
# plugin install/update happens in the `/update-agnes-plugins` slash
|
||||
# command (installed by `cli.lib.commands.install_claude_commands`).
|
||||
# Workspaces still on the older `--quiet` form auto-upgrade here
|
||||
# because `_OUR_COMMAND_MARKERS` matches by substring on the
|
||||
# `agnes refresh-marketplace` prefix.
|
||||
_replace_or_add("SessionStart", [
|
||||
"agnes self-upgrade --quiet 2>/dev/null || true; "
|
||||
"agnes pull --quiet 2>/dev/null || true",
|
||||
'bash -c "agnes refresh-marketplace --quiet 2>/dev/null || true"',
|
||||
'bash -c "agnes refresh-marketplace --check 2>/dev/null || true"',
|
||||
'bash -c "agnes push --quiet 2>/dev/null || true"',
|
||||
])
|
||||
# SessionEnd push must run detached. Claude Code in `-p` (headless) mode
|
||||
|
|
|
|||
19
cli/templates/commands/update-agnes-plugins.md
Normal file
19
cli/templates/commands/update-agnes-plugins.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
---
|
||||
description: Update Agnes marketplace plugins to latest versions
|
||||
---
|
||||
|
||||
Run the following command to refresh the Agnes marketplace plugins
|
||||
for this workspace:
|
||||
|
||||
```bash
|
||||
agnes refresh-marketplace
|
||||
```
|
||||
|
||||
Stream the output to me as it runs. If any plugins were installed
|
||||
or updated, remind me to run `/reload-plugins` to load the changes
|
||||
into this session — no Claude Code restart needed.
|
||||
|
||||
If the command fails, report the exact error so we can diagnose it
|
||||
together (common causes: marketplace clone missing — fix with
|
||||
`agnes refresh-marketplace --bootstrap`; expired PAT — fix with
|
||||
`agnes auth import-token`).
|
||||
|
|
@ -130,14 +130,20 @@ def test_refresh_marketplace_help():
|
|||
result = runner.invoke(refresh_marketplace_app, ["--help"])
|
||||
assert result.exit_code == 0
|
||||
cleaned = _clean(result.output)
|
||||
assert "--quiet" in cleaned
|
||||
# --auto-upgrade is gone — version-aware reconcile is now the default.
|
||||
# --check is the SessionStart-hook-friendly detector mode (replaced
|
||||
# --quiet, which used to perform a full reconcile silently).
|
||||
assert "--check" in cleaned
|
||||
assert "--bootstrap" in cleaned
|
||||
# --quiet was removed in favour of --check + the /update-agnes-plugins
|
||||
# slash command. --auto-upgrade was removed earlier (version-aware
|
||||
# reconcile is the default).
|
||||
assert "--quiet" not in cleaned
|
||||
assert "--auto-upgrade" not in cleaned
|
||||
|
||||
|
||||
def test_refresh_marketplace_no_clone_is_silent_noop_with_quiet(tmp_path, monkeypatch, recorder):
|
||||
def test_refresh_marketplace_no_clone_is_silent_noop_with_check(tmp_path, monkeypatch, recorder):
|
||||
monkeypatch.setattr(rm_module, "CLONE_DIR", tmp_path / "nonexistent")
|
||||
result = runner.invoke(refresh_marketplace_app, ["--quiet"])
|
||||
result = runner.invoke(refresh_marketplace_app, ["--check"])
|
||||
assert result.exit_code == 0
|
||||
assert _clean(result.output) == ""
|
||||
assert recorder.calls == []
|
||||
|
|
@ -154,7 +160,7 @@ def test_refresh_marketplace_no_clone_explains_in_manual_mode(tmp_path, monkeypa
|
|||
def test_no_clone_short_circuits_before_token_check(tmp_path, monkeypatch, recorder):
|
||||
"""The no-clone no-op path must NOT require a token.
|
||||
|
||||
The SessionStart hook (`agnes refresh-marketplace --quiet`) runs in
|
||||
The SessionStart hook (`agnes refresh-marketplace --check`) runs in
|
||||
every workspace that has the hook installed, including ones where no
|
||||
agnes token is configured (e.g. a fresh CI checkout, a workspace
|
||||
that never went through `agnes init`, a project sharing the user's
|
||||
|
|
@ -173,8 +179,8 @@ def test_no_clone_short_circuits_before_token_check(tmp_path, monkeypatch, recor
|
|||
monkeypatch.delenv("AGNES_TOKEN", raising=False)
|
||||
monkeypatch.setattr(rm_module, "CLONE_DIR", tmp_path / "nonexistent")
|
||||
|
||||
# --quiet (hook context).
|
||||
result = runner.invoke(refresh_marketplace_app, ["--quiet"])
|
||||
# --check (hook context).
|
||||
result = runner.invoke(refresh_marketplace_app, ["--check"])
|
||||
assert result.exit_code == 0, (
|
||||
f"hook context should silent-noop without a token; got exit "
|
||||
f"{result.exit_code} and output {result.output!r}"
|
||||
|
|
@ -182,7 +188,7 @@ def test_no_clone_short_circuits_before_token_check(tmp_path, monkeypatch, recor
|
|||
assert _clean(result.output) == ""
|
||||
assert recorder.calls == []
|
||||
|
||||
# Manual mode (no --quiet): hint, but still exit 0 + no token resolution.
|
||||
# Manual mode (no flags): hint, but still exit 0 + no token resolution.
|
||||
result = runner.invoke(refresh_marketplace_app, [])
|
||||
assert result.exit_code == 0
|
||||
assert "No marketplace clone" in _clean(result.output)
|
||||
|
|
@ -440,39 +446,7 @@ def test_reconcile_warns_when_plugin_list_unparseable(
|
|||
assert not any(c.cmd[:3] == ["claude", "plugin", "update"] for c in recorder.calls)
|
||||
|
||||
|
||||
# --- Hook JSON output -----------------------------------------------------------
|
||||
|
||||
|
||||
def test_quiet_emits_hook_json_when_plugin_installed(
|
||||
with_clone, with_token, claude_in_path, recorder, monkeypatch, tmp_path,
|
||||
):
|
||||
"""--quiet + new install → hook JSON on stdout with systemMessage +
|
||||
additionalContext, both naming the plugin and the restart instruction."""
|
||||
workspace = tmp_path / "ws"
|
||||
workspace.mkdir()
|
||||
monkeypatch.chdir(workspace)
|
||||
_set_marketplace_manifest(with_clone, [{"name": "grpn-fin", "version": "0.5.0"}])
|
||||
recorder.script(("claude", "plugin", "list", "--json"),
|
||||
stdout=_plugin_list_json([]))
|
||||
|
||||
result = runner.invoke(refresh_marketplace_app, ["--quiet"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
out = _clean(result.output).strip()
|
||||
assert out, "expected hook JSON on stdout when a plugin was installed"
|
||||
payload = json.loads(out)
|
||||
assert "grpn-fin" in payload["systemMessage"]
|
||||
assert "Agnes stack" in payload["systemMessage"]
|
||||
assert "installed" in payload["systemMessage"]
|
||||
# Reload hint: `/reload-plugins` loads the on-disk plugins into the
|
||||
# running Claude Code session without a full restart.
|
||||
assert "/reload-plugins" in payload["systemMessage"]
|
||||
|
||||
hook_specific = payload.get("hookSpecificOutput", {})
|
||||
assert hook_specific.get("hookEventName") == "SessionStart"
|
||||
additional = hook_specific.get("additionalContext", "")
|
||||
assert "grpn-fin" in additional
|
||||
assert "/reload-plugins" in additional
|
||||
# --- Reload hint (default + slash-command chatty path) -------------------------
|
||||
|
||||
|
||||
def test_manual_mode_prints_reload_hint_when_anything_changed(
|
||||
|
|
@ -516,152 +490,13 @@ def test_manual_mode_no_change_does_not_print_reload_hint(
|
|||
assert "/reload-plugins" not in out
|
||||
|
||||
|
||||
def test_quiet_emits_hook_json_when_bundle_silently_auto_updated_by_claude(
|
||||
with_clone, with_token, claude_in_path, recorder, monkeypatch, tmp_path,
|
||||
):
|
||||
"""Regression: when a /store skill change bumps the agnes-store-bundle
|
||||
content hash, `claude plugin marketplace update agnes` silently
|
||||
auto-applies the new version on local-path marketplaces (Claude
|
||||
re-reads the manifest off disk and updates the installed cache).
|
||||
|
||||
If we captured the installed snapshot AFTER `claude plugin marketplace
|
||||
update`, the diff against the new manifest would be zero (Claude
|
||||
already updated installed → matches manifest), `events["updated"]`
|
||||
would stay empty, and the hook JSON wouldn't fire — leaving the user
|
||||
with no notification despite the plugin actually changing.
|
||||
|
||||
Pin this by scripting `claude plugin list --json` to return DIFFERENT
|
||||
versions before vs after the marketplace-update call. The first
|
||||
invocation (pre-snapshot) returns the old version; subsequent calls
|
||||
return the new version (Claude's auto-update). Reconcile must use
|
||||
the FIRST call's snapshot, detect the diff, and fire the
|
||||
notification."""
|
||||
workspace = tmp_path / "ws"
|
||||
workspace.mkdir()
|
||||
monkeypatch.chdir(workspace)
|
||||
_set_marketplace_manifest(with_clone, [
|
||||
{"name": "agnes-store-bundle", "version": "newhash"},
|
||||
])
|
||||
|
||||
# Track how many times `claude plugin list --json` was called so we
|
||||
# can return DIFFERENT data on each invocation. The recorder's
|
||||
# script() helper only does prefix-match with one fixed response
|
||||
# per prefix, so we wrap its run() instead.
|
||||
list_call_count = {"n": 0}
|
||||
real_run = recorder.run
|
||||
|
||||
def staged_run(cmd, *args, **kwargs):
|
||||
if cmd[:4] == ["claude", "plugin", "list", "--json"]:
|
||||
# Record the call ourselves — we're bypassing recorder.run
|
||||
# below, so we have to keep `recorder.calls` in sync.
|
||||
recorder.calls.append(
|
||||
_RecordedCall(cmd=list(cmd), env=dict(kwargs.get("env") or {}))
|
||||
)
|
||||
list_call_count["n"] += 1
|
||||
payload = (
|
||||
# Pre-snapshot (call 1): old version still observable.
|
||||
_plugin_list_json([
|
||||
{"id": "agnes-store-bundle@agnes",
|
||||
"version": "oldhash",
|
||||
"projectPath": str(workspace)},
|
||||
])
|
||||
if list_call_count["n"] == 1
|
||||
else
|
||||
# Post-marketplace-update (call 2+): Claude auto-applied
|
||||
# the new version, version now matches manifest.
|
||||
_plugin_list_json([
|
||||
{"id": "agnes-store-bundle@agnes",
|
||||
"version": "newhash",
|
||||
"projectPath": str(workspace)},
|
||||
])
|
||||
)
|
||||
return subprocess.CompletedProcess(args=list(cmd), returncode=0,
|
||||
stdout=payload, stderr="")
|
||||
return real_run(cmd, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(rm_module.subprocess, "run", staged_run)
|
||||
|
||||
result = runner.invoke(refresh_marketplace_app, ["--quiet"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Hook JSON must fire — even though by the time reconcile sees `claude
|
||||
# plugin list --json` the second time, versions match.
|
||||
out = _clean(result.output).strip()
|
||||
assert out, "hook JSON missing — pre-snapshot ordering regression"
|
||||
payload = json.loads(out)
|
||||
assert "agnes-store-bundle" in payload["systemMessage"]
|
||||
assert "updated" in payload["systemMessage"]
|
||||
|
||||
# Sanity: pre-snapshot was captured before `claude plugin marketplace update`.
|
||||
# We expect at least 2 list calls (pre-snapshot + reconcile re-read) but
|
||||
# the FIRST one must have come before the marketplace update call.
|
||||
list_indices = [
|
||||
i for i, c in enumerate(recorder.calls)
|
||||
if c.cmd[:4] == ["claude", "plugin", "list", "--json"]
|
||||
]
|
||||
market_update_indices = [
|
||||
i for i, c in enumerate(recorder.calls)
|
||||
if c.cmd[:4] == ["claude", "plugin", "marketplace", "update"]
|
||||
]
|
||||
assert list_indices, "no claude plugin list calls recorded"
|
||||
assert market_update_indices, "no claude plugin marketplace update call recorded"
|
||||
assert list_indices[0] < market_update_indices[0], (
|
||||
f"pre-snapshot must come before marketplace update; "
|
||||
f"list at {list_indices[0]}, marketplace update at {market_update_indices[0]}"
|
||||
)
|
||||
|
||||
|
||||
def test_quiet_emits_hook_json_when_plugin_updated(
|
||||
with_clone, with_token, claude_in_path, recorder, monkeypatch, tmp_path,
|
||||
):
|
||||
"""--quiet + version-mismatch update (e.g. /store skill add bumping
|
||||
the bundle) → hook JSON with `updated` count in systemMessage."""
|
||||
workspace = tmp_path / "ws"
|
||||
workspace.mkdir()
|
||||
monkeypatch.chdir(workspace)
|
||||
_set_marketplace_manifest(with_clone, [
|
||||
{"name": "agnes-store-bundle", "version": "newhash"},
|
||||
])
|
||||
recorder.script(
|
||||
("claude", "plugin", "list", "--json"),
|
||||
stdout=_plugin_list_json([
|
||||
{"id": "agnes-store-bundle@agnes", "version": "oldhash",
|
||||
"projectPath": str(workspace)},
|
||||
]),
|
||||
)
|
||||
result = runner.invoke(refresh_marketplace_app, ["--quiet"])
|
||||
assert result.exit_code == 0
|
||||
out = _clean(result.output).strip()
|
||||
assert out
|
||||
payload = json.loads(out)
|
||||
assert "updated" in payload["systemMessage"]
|
||||
assert "agnes-store-bundle" in payload["systemMessage"]
|
||||
|
||||
|
||||
def test_quiet_emits_no_hook_json_when_nothing_changed(
|
||||
with_clone, with_token, claude_in_path, recorder, monkeypatch, tmp_path,
|
||||
):
|
||||
"""--quiet + everything in sync → silent stdout (no spurious
|
||||
notification on every session start)."""
|
||||
workspace = tmp_path / "ws"
|
||||
workspace.mkdir()
|
||||
monkeypatch.chdir(workspace)
|
||||
_set_marketplace_manifest(with_clone, [{"name": "grpn-eng", "version": "1.0.0"}])
|
||||
recorder.script(
|
||||
("claude", "plugin", "list", "--json"),
|
||||
stdout=_plugin_list_json([
|
||||
{"id": "grpn-eng@agnes", "version": "1.0.0", "projectPath": str(workspace)},
|
||||
]),
|
||||
)
|
||||
result = runner.invoke(refresh_marketplace_app, ["--quiet"])
|
||||
assert result.exit_code == 0
|
||||
assert _clean(result.output).strip() == ""
|
||||
|
||||
|
||||
def test_manual_mode_does_not_emit_hook_json(
|
||||
with_clone, with_token, claude_in_path, recorder, monkeypatch, tmp_path,
|
||||
):
|
||||
"""Without --quiet, output is human-readable text — no JSON envelope."""
|
||||
"""Default mode (no flags) emits human-readable text — never a JSON envelope.
|
||||
|
||||
Hook JSON is reserved for `--check`. The slash command runs the
|
||||
default chatty path, so its output is plain prose for the user."""
|
||||
workspace = tmp_path / "ws"
|
||||
workspace.mkdir()
|
||||
monkeypatch.chdir(workspace)
|
||||
|
|
@ -813,3 +648,190 @@ def test_bootstrap_with_existing_clone_skips_clone_proceeds_to_refresh(
|
|||
assert fetch_calls
|
||||
reset_calls = [c for c in recorder.calls if "reset" in c.cmd and "--hard" in c.cmd]
|
||||
assert reset_calls
|
||||
|
||||
|
||||
# --- --check flag (SessionStart-hook detector mode) -----------------------------
|
||||
|
||||
|
||||
def _stage_rev_parse(monkeypatch, recorder, *, head: str, fetch_head: str) -> None:
|
||||
"""Wrap recorder.run so `git rev-parse HEAD` and
|
||||
`git rev-parse FETCH_HEAD` return scripted SHAs while every other
|
||||
command falls through to the recorder's normal handling.
|
||||
|
||||
Used by --check tests to drive the HEAD-vs-FETCH_HEAD comparison
|
||||
independently of the (mocked) git fetch.
|
||||
"""
|
||||
real_run = recorder.run
|
||||
|
||||
def staged_run(cmd, *args, **kwargs):
|
||||
# Match the trailing rev-parse target so `-C <path>` injection
|
||||
# doesn't break the prefix.
|
||||
if "rev-parse" in cmd:
|
||||
recorder.calls.append(
|
||||
_RecordedCall(cmd=list(cmd), env=dict(kwargs.get("env") or {}))
|
||||
)
|
||||
target = cmd[-1]
|
||||
stdout = head if target == "HEAD" else fetch_head if target == "FETCH_HEAD" else ""
|
||||
return subprocess.CompletedProcess(
|
||||
args=list(cmd), returncode=0, stdout=stdout + "\n", stderr="",
|
||||
)
|
||||
return real_run(cmd, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(rm_module.subprocess, "run", staged_run)
|
||||
|
||||
|
||||
def test_check_emits_hook_json_when_remote_changed(
|
||||
with_clone, with_token, claude_in_path, recorder, monkeypatch, tmp_path,
|
||||
):
|
||||
"""`--check` + local HEAD differs from remote FETCH_HEAD →
|
||||
Claude Code hook JSON on stdout pointing the user at
|
||||
`/update-agnes-plugins`. The hook never installs anything itself."""
|
||||
workspace = tmp_path / "ws"
|
||||
workspace.mkdir()
|
||||
monkeypatch.chdir(workspace)
|
||||
_stage_rev_parse(monkeypatch, recorder, head="abc123", fetch_head="def456")
|
||||
|
||||
result = runner.invoke(refresh_marketplace_app, ["--check"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
out = _clean(result.output).strip()
|
||||
assert out, "--check must emit hook JSON when remote has changes"
|
||||
payload = json.loads(out)
|
||||
assert "/update-agnes-plugins" in payload["systemMessage"], payload
|
||||
assert "marketplace" in payload["systemMessage"].lower(), payload
|
||||
assert payload["hookSpecificOutput"]["hookEventName"] == "SessionStart"
|
||||
assert "/update-agnes-plugins" in payload["hookSpecificOutput"]["additionalContext"]
|
||||
|
||||
|
||||
def test_check_silent_when_remote_unchanged(
|
||||
with_clone, with_token, claude_in_path, recorder, monkeypatch, tmp_path,
|
||||
):
|
||||
"""`--check` + HEAD == FETCH_HEAD → silent exit 0, no JSON output.
|
||||
Avoids spamming the user with "updates available" on every session
|
||||
start when nothing actually changed."""
|
||||
workspace = tmp_path / "ws"
|
||||
workspace.mkdir()
|
||||
monkeypatch.chdir(workspace)
|
||||
_stage_rev_parse(monkeypatch, recorder, head="samehash", fetch_head="samehash")
|
||||
|
||||
result = runner.invoke(refresh_marketplace_app, ["--check"])
|
||||
assert result.exit_code == 0
|
||||
assert _clean(result.output).strip() == ""
|
||||
|
||||
|
||||
def test_check_does_not_call_claude_plugin_anything(
|
||||
with_clone, with_token, claude_in_path, recorder, monkeypatch, tmp_path,
|
||||
):
|
||||
"""`--check` must NOT call `claude plugin install/update` or
|
||||
`claude plugin marketplace update`. Those side effects belong to
|
||||
the `/update-agnes-plugins` slash command, which the user runs
|
||||
interactively when they're ready."""
|
||||
workspace = tmp_path / "ws"
|
||||
workspace.mkdir()
|
||||
monkeypatch.chdir(workspace)
|
||||
# Even WITH a remote diff, --check must stay read-only.
|
||||
_stage_rev_parse(monkeypatch, recorder, head="abc", fetch_head="def")
|
||||
|
||||
result = runner.invoke(refresh_marketplace_app, ["--check"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
forbidden_prefixes = (
|
||||
["claude", "plugin", "install"],
|
||||
["claude", "plugin", "update"],
|
||||
["claude", "plugin", "marketplace", "update"],
|
||||
)
|
||||
for prefix in forbidden_prefixes:
|
||||
assert not any(c.cmd[: len(prefix)] == prefix for c in recorder.calls), (
|
||||
f"--check must not invoke {' '.join(prefix)}; got: "
|
||||
f"{[c.cmd for c in recorder.calls]!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_check_does_not_git_reset(
|
||||
with_clone, with_token, claude_in_path, recorder, monkeypatch, tmp_path,
|
||||
):
|
||||
"""`--check` is read-only against the git tree. Must NOT call
|
||||
`git reset --hard` — that would silently apply remote changes the
|
||||
user hasn't agreed to yet."""
|
||||
workspace = tmp_path / "ws"
|
||||
workspace.mkdir()
|
||||
monkeypatch.chdir(workspace)
|
||||
_stage_rev_parse(monkeypatch, recorder, head="abc", fetch_head="def")
|
||||
|
||||
result = runner.invoke(refresh_marketplace_app, ["--check"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
reset_calls = [c for c in recorder.calls if "reset" in c.cmd]
|
||||
assert reset_calls == [], (
|
||||
f"--check must not call git reset; got: {[c.cmd for c in reset_calls]!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_check_runs_git_fetch(
|
||||
with_clone, with_token, claude_in_path, recorder, monkeypatch, tmp_path,
|
||||
):
|
||||
"""`--check` must run `git fetch origin` (otherwise FETCH_HEAD is
|
||||
stale and we'd compare against an old snapshot, missing real
|
||||
remote changes)."""
|
||||
workspace = tmp_path / "ws"
|
||||
workspace.mkdir()
|
||||
monkeypatch.chdir(workspace)
|
||||
_stage_rev_parse(monkeypatch, recorder, head="abc", fetch_head="abc")
|
||||
|
||||
result = runner.invoke(refresh_marketplace_app, ["--check"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
fetch_calls = [
|
||||
c for c in recorder.calls
|
||||
if c.cmd and c.cmd[0] == "git" and "fetch" in c.cmd and "origin" in c.cmd
|
||||
]
|
||||
assert fetch_calls, (
|
||||
f"--check must run `git fetch origin`; got: {[c.cmd for c in recorder.calls]!r}"
|
||||
)
|
||||
# Same credential helper wiring as the default mode — PAT in env, not argv.
|
||||
fetch = fetch_calls[0]
|
||||
assert "-c" in fetch.cmd
|
||||
assert fetch.cmd[fetch.cmd.index("-c") + 1].startswith("credential.helper=")
|
||||
assert fetch.env.get("AGNES_TOKEN") == with_token
|
||||
|
||||
|
||||
def test_check_no_clone_silent_exit_zero(tmp_path, monkeypatch, with_token, recorder):
|
||||
"""`--check` on a workspace without a marketplace clone → silent
|
||||
exit 0 (matches the old --quiet hook no-op semantics, so workspaces
|
||||
that never bootstrapped don't spam "no clone" warnings on every
|
||||
session start)."""
|
||||
monkeypatch.setattr(rm_module, "CLONE_DIR", tmp_path / "nonexistent")
|
||||
result = runner.invoke(refresh_marketplace_app, ["--check"])
|
||||
assert result.exit_code == 0
|
||||
assert _clean(result.output).strip() == ""
|
||||
assert recorder.calls == []
|
||||
|
||||
|
||||
def test_check_fetch_failure_exits_one(
|
||||
with_clone, with_token, claude_in_path, recorder, monkeypatch, tmp_path,
|
||||
):
|
||||
"""A failed `git fetch` (network down, auth rejected, etc.) → exit 1
|
||||
so the surrounding `|| true` in the hook command swallows it cleanly.
|
||||
No hook JSON is emitted (we don't know if the remote changed)."""
|
||||
workspace = tmp_path / "ws"
|
||||
workspace.mkdir()
|
||||
monkeypatch.chdir(workspace)
|
||||
recorder.script(("git", "-c"), returncode=1, stderr="fatal: unable to access ...")
|
||||
|
||||
result = runner.invoke(refresh_marketplace_app, ["--check"])
|
||||
assert result.exit_code == 1
|
||||
# No hook JSON on failure — the hook surrounding `|| true` swallows
|
||||
# the non-zero exit so users don't see a half-written message.
|
||||
assert not _clean(result.output).strip().startswith("{")
|
||||
|
||||
|
||||
def test_check_and_bootstrap_are_mutually_exclusive(
|
||||
tmp_path, monkeypatch, with_token, recorder,
|
||||
):
|
||||
"""Mixing the two modes makes no sense (one is read-only detector,
|
||||
the other is destructive clone-and-reconcile). Reject the combo
|
||||
with a non-zero exit instead of silently picking one."""
|
||||
monkeypatch.setattr(rm_module, "CLONE_DIR", tmp_path / "fresh_marketplace")
|
||||
result = runner.invoke(refresh_marketplace_app, ["--check", "--bootstrap"])
|
||||
assert result.exit_code == 2
|
||||
assert recorder.calls == []
|
||||
|
|
|
|||
83
tests/test_lib_commands.py
Normal file
83
tests/test_lib_commands.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
"""Tests for cli/lib/commands.py:install_claude_commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from cli.lib.commands import install_claude_commands
|
||||
|
||||
|
||||
def _read_managed_command(workspace: Path) -> str:
|
||||
return (workspace / ".claude" / "commands" / "update-agnes-plugins.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def test_install_writes_slash_command_file(tmp_path):
|
||||
"""install_claude_commands writes update-agnes-plugins.md into
|
||||
<workspace>/.claude/commands/. The file is the bundled template
|
||||
(frontmatter + body) — the test just pins that *something* lands and
|
||||
that frontmatter shape is intact, so a future template edit doesn't
|
||||
silently lose its description metadata."""
|
||||
install_claude_commands(tmp_path)
|
||||
body = _read_managed_command(tmp_path)
|
||||
assert body.startswith("---"), body[:120]
|
||||
assert "description:" in body.split("---", 2)[1], body[:200]
|
||||
# The slash command's whole point: invoke `agnes refresh-marketplace`.
|
||||
# Pin that the bundled body actually references the command.
|
||||
assert "agnes refresh-marketplace" in body
|
||||
|
||||
|
||||
def test_install_creates_commands_dir_when_missing(tmp_path):
|
||||
"""Workspace without a .claude/ tree at all → install creates both
|
||||
.claude/ and .claude/commands/."""
|
||||
assert not (tmp_path / ".claude").exists()
|
||||
install_claude_commands(tmp_path)
|
||||
assert (tmp_path / ".claude" / "commands").is_dir()
|
||||
|
||||
|
||||
def test_install_overwrites_existing_managed_file(tmp_path):
|
||||
"""Even if the workspace has a hand-edited copy of the managed slash
|
||||
command, install overwrites with the canonical template — server-
|
||||
managed by design (Q2 of the design discussion). Users who want to
|
||||
customize copy to a different filename, which is preserved by the
|
||||
third-party-untouched test below."""
|
||||
cmd_dir = tmp_path / ".claude" / "commands"
|
||||
cmd_dir.mkdir(parents=True)
|
||||
(cmd_dir / "update-agnes-plugins.md").write_text(
|
||||
"USER EDIT THAT MUST BE OVERWRITTEN", encoding="utf-8",
|
||||
)
|
||||
install_claude_commands(tmp_path)
|
||||
body = _read_managed_command(tmp_path)
|
||||
assert "USER EDIT" not in body
|
||||
assert "agnes refresh-marketplace" in body
|
||||
|
||||
|
||||
def test_install_does_not_touch_other_command_files(tmp_path):
|
||||
"""User's own slash commands under .claude/commands/ (e.g.
|
||||
my-custom.md, project-specific helpers) must survive install
|
||||
untouched. Only the Agnes-managed command files are overwritten."""
|
||||
cmd_dir = tmp_path / ".claude" / "commands"
|
||||
cmd_dir.mkdir(parents=True)
|
||||
custom_path = cmd_dir / "my-custom.md"
|
||||
custom_body = "---\ndescription: my own slash command\n---\n\nhello"
|
||||
custom_path.write_text(custom_body, encoding="utf-8")
|
||||
|
||||
install_claude_commands(tmp_path)
|
||||
assert custom_path.read_text(encoding="utf-8") == custom_body
|
||||
|
||||
|
||||
def test_install_idempotent(tmp_path):
|
||||
"""Two consecutive installs produce identical state. Important
|
||||
because `agnes init --force` re-runs the installer and the
|
||||
SessionStart hook chain (in some future world where we wire it up)
|
||||
might too — neither should accumulate stray files or change content
|
||||
on a no-op invocation."""
|
||||
install_claude_commands(tmp_path)
|
||||
first = _read_managed_command(tmp_path)
|
||||
install_claude_commands(tmp_path)
|
||||
second = _read_managed_command(tmp_path)
|
||||
assert first == second
|
||||
# And no extra files appeared.
|
||||
cmd_dir_files = sorted(p.name for p in (tmp_path / ".claude" / "commands").iterdir())
|
||||
assert cmd_dir_files == ["update-agnes-plugins.md"]
|
||||
|
|
@ -52,6 +52,16 @@ def test_install_creates_settings_file(tmp_path):
|
|||
assert refresh.startswith("bash -c "), (
|
||||
f"refresh-marketplace hook must be wrapped in bash -c for Windows; got: {refresh!r}"
|
||||
)
|
||||
# Hook is now a detector — `--check` only. Plugin install/update
|
||||
# happens in the `/update-agnes-plugins` slash command instead.
|
||||
# Pinning the flag here prevents an accidental regression to the old
|
||||
# `--quiet` form (which performed a full reconcile silently).
|
||||
assert "--check" in refresh, (
|
||||
f"refresh-marketplace hook must use --check (detector mode); got: {refresh!r}"
|
||||
)
|
||||
assert "--quiet" not in refresh, (
|
||||
f"refresh-marketplace hook must NOT use --quiet (removed flag); got: {refresh!r}"
|
||||
)
|
||||
# The push self-heal entry is also bash-c-wrapped for Windows parity.
|
||||
push_start = next((c for c in starts if "agnes push" in c), None)
|
||||
assert push_start is not None, (
|
||||
|
|
@ -161,6 +171,53 @@ def test_install_replaces_v0_43_chained_self_upgrade_pull_entry(tmp_path):
|
|||
assert "agnes push --quiet" in ends[0]
|
||||
|
||||
|
||||
def test_install_replaces_old_quiet_refresh_with_check(tmp_path):
|
||||
"""A workspace bootstrapped before the slash-command split has the old
|
||||
`--quiet` form in its refresh-marketplace SessionStart entry. The next
|
||||
`agnes init` must replace that entry with the new `--check` form, NOT
|
||||
stack the new entry alongside the old one (which would re-run the
|
||||
full reconcile every session — exactly the behaviour we just moved
|
||||
behind the slash command).
|
||||
"""
|
||||
settings_path = tmp_path / ".claude" / "settings.json"
|
||||
settings_path.parent.mkdir(parents=True)
|
||||
settings_path.write_text(json.dumps({
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{"hooks": [{"type": "command", "command": (
|
||||
"agnes self-upgrade --quiet 2>/dev/null || true; "
|
||||
"agnes pull --quiet 2>/dev/null || true"
|
||||
)}]},
|
||||
{"hooks": [{"type": "command", "command": (
|
||||
'bash -c "agnes refresh-marketplace --quiet 2>/dev/null || true"'
|
||||
)}]},
|
||||
{"hooks": [{"type": "command", "command": (
|
||||
'bash -c "agnes push --quiet 2>/dev/null || true"'
|
||||
)}]},
|
||||
],
|
||||
"SessionEnd": [
|
||||
{"hooks": [{"type": "command", "command": (
|
||||
'bash -c "( nohup agnes push --quiet </dev/null '
|
||||
'>/dev/null 2>&1 & ) ; true"'
|
||||
)}]},
|
||||
],
|
||||
}
|
||||
}))
|
||||
install_claude_hooks(tmp_path)
|
||||
cfg = _read_settings(tmp_path)
|
||||
starts = _commands_for(cfg, "SessionStart")
|
||||
# Exactly one refresh-marketplace entry remains (no stacking).
|
||||
refresh_entries = [c for c in starts if "agnes refresh-marketplace" in c]
|
||||
assert len(refresh_entries) == 1, refresh_entries
|
||||
refresh = refresh_entries[0]
|
||||
assert "--check" in refresh, (
|
||||
f"old --quiet entry must have been rewritten to --check; got: {refresh!r}"
|
||||
)
|
||||
assert "--quiet" not in refresh, (
|
||||
f"old --quiet form must be gone after re-init; got: {refresh!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_install_preserves_third_party_hooks(tmp_path):
|
||||
settings_path = tmp_path / ".claude" / "settings.json"
|
||||
settings_path.parent.mkdir(parents=True)
|
||||
|
|
|
|||
Loading…
Reference in a new issue