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:
minasarustamyan 2026-05-09 21:10:39 +02:00 committed by GitHub
parent d269c69359
commit d9405a6888
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 647 additions and 283 deletions

View file

@ -10,8 +10,55 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
## [Unreleased] ## [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 ### 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` - **BREAKING: legacy `git config --global http.<host>.sslVerify=false`
downgrade in the install setup prompt.** The marketplace step (step 5) downgrade in the install setup prompt.** The marketplace step (step 5)
used to emit this line on `AGNES_DEBUG_AUTH=1` instances when no used to emit this line on `AGNES_DEBUG_AUTH=1` instances when no

View file

@ -523,18 +523,12 @@
<span class="title">✓ Added to your stack</span> <span class="title">✓ Added to your stack</span>
<button class="dismiss" id="stack-hint-dismiss" type="button">Dont show again</button> <button class="dismiss" id="stack-hint-dismiss" type="button">Dont show again</button>
</div> </div>
<div>To use it in Claude Code:</div> <div>Open a new Claude Code session and run:</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"> <div class="cmd-chip">
<span class="prompt">$</span> <span class="prompt">/</span>
<span class="cmd">agnes refresh-marketplace</span> <span class="cmd">update-agnes-plugins</span>
<button class="btn-copy" id="stack-hint-copy" type="button">Copy</button> <button class="btn-copy" id="stack-hint-copy" type="button">Copy</button>
</div> </div>
Then in the running session: <code>/reload-plugins</code>
</li>
</ol>
</div> </div>
</div> </div>
<aside class="panel details"> <aside class="panel details">
@ -804,7 +798,7 @@
copyBtn.addEventListener('click', async (ev) => { copyBtn.addEventListener('click', async (ev) => {
const b = ev.currentTarget; const b = ev.currentTarget;
try { try {
await navigator.clipboard.writeText('agnes refresh-marketplace'); await navigator.clipboard.writeText('/update-agnes-plugins');
const orig = b.textContent; const orig = b.textContent;
b.classList.add('copied'); b.classList.add('copied');
b.textContent = 'Copied'; b.textContent = 'Copied';

View file

@ -512,18 +512,12 @@
<span class="title">✓ Added to your stack</span> <span class="title">✓ Added to your stack</span>
<button class="dismiss" id="stack-hint-dismiss" type="button">Dont show again</button> <button class="dismiss" id="stack-hint-dismiss" type="button">Dont show again</button>
</div> </div>
<div>To use it in Claude Code:</div> <div>Open a new Claude Code session and run:</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"> <div class="cmd-chip">
<span class="prompt">$</span> <span class="prompt">/</span>
<span class="cmd">agnes refresh-marketplace</span> <span class="cmd">update-agnes-plugins</span>
<button class="btn-copy" id="stack-hint-copy" type="button">Copy</button> <button class="btn-copy" id="stack-hint-copy" type="button">Copy</button>
</div> </div>
Then in the running session: <code>/reload-plugins</code>
</li>
</ol>
</div> </div>
</div> </div>
<div class="panel details" id="panel-details"> <div class="panel details" id="panel-details">
@ -709,7 +703,7 @@
document.getElementById('stack-hint-copy').addEventListener('click', async (ev) => { document.getElementById('stack-hint-copy').addEventListener('click', async (ev) => {
const copyBtn = ev.currentTarget; const copyBtn = ev.currentTarget;
try { try {
await navigator.clipboard.writeText('agnes refresh-marketplace'); await navigator.clipboard.writeText('/update-agnes-plugins');
const orig = copyBtn.textContent; const orig = copyBtn.textContent;
copyBtn.classList.add('copied'); copyBtn.classList.add('copied');
copyBtn.textContent = 'Copied'; copyBtn.textContent = 'Copied';

View file

@ -16,7 +16,9 @@ Steps in order:
RBAC-filtered, role-aware). RBAC-filtered, role-aware).
5. Seed `.claude/settings.json` with default model + permissions, then call 5. Seed `.claude/settings.json` with default model + permissions, then call
`cli.lib.hooks.install_claude_hooks` to merge in the SessionStart/End hook `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` 6. Write the `.claude/CLAUDE.local.md` stub only when absent `--force`
regenerates CLAUDE.md but **never** clobbers the operator-edited regenerates CLAUDE.md but **never** clobbers the operator-edited
CLAUDE.local.md. CLAUDE.local.md.
@ -44,6 +46,7 @@ import typer
from cli.client import api_get from cli.client import api_get
from cli.config import save_config, save_token from cli.config import save_config, save_token
from cli.error_render import render_error 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.hooks import install_claude_hooks
from cli.lib.pull import PullResult, _override_server_env, run_pull from cli.lib.pull import PullResult, _override_server_env, run_pull
@ -190,6 +193,7 @@ def init(
indent=2, indent=2,
), encoding="utf-8") ), encoding="utf-8")
install_claude_hooks(workspace) install_claude_hooks(workspace)
install_claude_commands(workspace)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Step 6: CLAUDE.local.md stub — only when absent. `--force` does NOT # Step 6: CLAUDE.local.md stub — only when absent. `--force` does NOT

View file

@ -5,15 +5,22 @@ Three call paths share the same code:
- `agnes refresh-marketplace --bootstrap` first-time setup; clones the - `agnes refresh-marketplace --bootstrap` first-time setup; clones the
per-user marketplace bare repo, registers it with Claude Code, then per-user marketplace bare repo, registers it with Claude Code, then
falls through to fetch+reset+reconcile so plugins land installed. falls through to fetch+reset+reconcile so plugins land installed.
- `agnes refresh-marketplace` manual re-sync after a known stack change. - `agnes refresh-marketplace` manual re-sync after a known stack
- `agnes refresh-marketplace --quiet` SessionStart hook context. Emits change. This is what the `/update-agnes-plugins` slash command runs
a Claude Code hook JSON object on stdout when something actually got inside Claude Code so the user sees install/update progress in the
installed/updated; silent otherwise. 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 / Reconcile (default + --bootstrap paths) is version-aware (install
skip on match). Server-side stack composition lives in missing / update on version diff / skip on match). Server-side stack
`src/marketplace_filter.py:resolve_user_marketplace`. Plugin installs use composition lives in `src/marketplace_filter.py:resolve_user_marketplace`.
`--scope project` so they land in the workspace the hook fired in. Plugin installs use `--scope project` so they land in the workspace the
caller invoked from.
""" """
from __future__ import annotations 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) @refresh_marketplace_app.callback(invoke_without_command=True)
def refresh_marketplace( def refresh_marketplace(
quiet: bool = typer.Option( check: bool = typer.Option(
False, "--quiet", False, "--check",
help="Suppress success stdout (errors and warnings still surface on stderr).", 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( bootstrap: bool = typer.Option(
False, "--bootstrap", False, "--bootstrap",
@ -62,6 +77,13 @@ def refresh_marketplace(
), ),
): ):
"""Sync the marketplace clone, re-register with Claude, install/update plugins.""" """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() clone_exists = (CLONE_DIR / ".git").is_dir()
# Hook contexts hit the no-clone path on every workspace that didn't # 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 # workspaces with the hook installed but no agnes token configured
# (fresh CI checkout, etc.) must silent-noop, not surface auth_failed. # (fresh CI checkout, etc.) must silent-noop, not surface auth_failed.
if not clone_exists and not bootstrap: if not clone_exists and not bootstrap:
if not quiet: if not check:
typer.echo( typer.echo(
f"No marketplace clone at {CLONE_DIR} — nothing to refresh. " f"No marketplace clone at {CLONE_DIR} — nothing to refresh. "
"Re-run setup with `agnes refresh-marketplace --bootstrap` " "Re-run setup with `agnes refresh-marketplace --bootstrap` "
@ -89,12 +111,22 @@ def refresh_marketplace(
raise typer.Exit(1) raise typer.Exit(1)
if not clone_exists: if not clone_exists:
if not _bootstrap_clone(token, quiet=quiet): if not _bootstrap_clone(token):
raise typer.Exit(1) 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": []} 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) raise typer.Exit(1)
# Snapshot installed versions BEFORE `claude plugin marketplace update`. # Snapshot installed versions BEFORE `claude plugin marketplace update`.
@ -105,20 +137,18 @@ def refresh_marketplace(
# would fire despite the plugin having actually changed. # would fire despite the plugin having actually changed.
installed_pre = _list_installed_agnes_plugins_in_cwd() 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"]): if events["installed"] or events["updated"]:
_emit_hook_message(events)
elif not quiet and (events["installed"] or events["updated"]):
typer.echo( typer.echo(
"\nRun `/reload-plugins` in Claude Code to load the " "\nRun `/reload-plugins` in Claude Code to load the "
"new/updated plugins into the running session — no restart needed." "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. """Initial clone of the per-user marketplace bare repo into ~/.agnes/marketplace.
Wrapping the destructive prep in the agnes binary lets the CLI's Wrapping the destructive prep in the agnes binary lets the CLI's
@ -156,7 +186,6 @@ def _bootstrap_clone(token: str, *, quiet: bool) -> bool:
auth_url = f"{scheme}://x:{token}@{server_host}/marketplace.git/" auth_url = f"{scheme}://x:{token}@{server_host}/marketplace.git/"
clean_url = f"{scheme}://{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: try:
@ -206,20 +235,21 @@ def _bootstrap_clone(token: str, *, quiet: bool) -> bool:
) )
if add.stderr: if add.stderr:
typer.echo(add.stderr.rstrip(), err=True) typer.echo(add.stderr.rstrip(), err=True)
elif not quiet and add.stdout: elif add.stdout:
typer.echo(add.stdout.rstrip()) 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 return True
def _git_fetch_and_reset(token: str, *, quiet: bool) -> bool: def _git_fetch_only(token: str) -> bool:
"""Fetch from origin then hard-reset to FETCH_HEAD. """Fetch from origin without resetting the working tree.
Not `pull --ff-only`: the marketplace bare repo on the server rebuilds Used by `--check` to learn whether the remote has new content without
as a fresh orphan commit on every content change, so two snapshots actually applying it. The bare repo on the server rebuilds as a fresh
have unrelated histories and fast-forward is impossible. 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} env = {**os.environ, "AGNES_TOKEN": token}
fetch_cmd = [ fetch_cmd = [
@ -234,7 +264,7 @@ def _git_fetch_and_reset(token: str, *, quiet: bool) -> bool:
encoding="utf-8", errors="replace", check=False, encoding="utf-8", errors="replace", check=False,
) )
except FileNotFoundError: 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 return False
if fetch.returncode != 0: if fetch.returncode != 0:
if fetch.stdout: if fetch.stdout:
@ -242,6 +272,41 @@ def _git_fetch_and_reset(token: str, *, quiet: bool) -> bool:
if fetch.stderr: if fetch.stderr:
typer.echo(fetch.stderr, err=True) typer.echo(fetch.stderr, err=True)
return False 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( reset = subprocess.run(
["git", "-C", str(CLONE_DIR), "reset", "--hard", "FETCH_HEAD"], ["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) typer.echo(reset.stderr, err=True)
return False return False
if not quiet and reset.stdout: if reset.stdout:
typer.echo(reset.stdout.rstrip()) typer.echo(reset.stdout.rstrip())
return True 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.""" """Tell Claude Code to re-read the marketplace clone. Soft-fail if `claude` is missing."""
if shutil.which("claude") is None: if shutil.which("claude") is None:
typer.echo( typer.echo(
@ -280,13 +345,12 @@ def _claude_marketplace_update(*, quiet: bool) -> None:
if result.stderr: if result.stderr:
typer.echo(result.stderr.rstrip(), err=True) typer.echo(result.stderr.rstrip(), err=True)
return return
if not quiet and result.stdout: if result.stdout:
typer.echo(result.stdout.rstrip()) typer.echo(result.stdout.rstrip())
def _reconcile_with_manifest( def _reconcile_with_manifest(
*, *,
quiet: bool,
events: dict[str, list[str]], events: dict[str, list[str]],
installed_pre: Optional[dict[str, str]] = None, installed_pre: Optional[dict[str, str]] = None,
) -> None: ) -> None:
@ -329,11 +393,9 @@ def _reconcile_with_manifest(
to_update.append(name) to_update.append(name)
if not to_install and not to_update: 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 return
if not quiet:
if to_install: if to_install:
typer.echo(f"Installing {len(to_install)} new plugin(s): " + ", ".join(to_install)) typer.echo(f"Installing {len(to_install)} new plugin(s): " + ", ".join(to_install))
if to_update: if to_update:
@ -354,7 +416,7 @@ def _reconcile_with_manifest(
typer.echo(result.stderr.rstrip(), err=True) typer.echo(result.stderr.rstrip(), err=True)
continue continue
events["installed"].append(name) events["installed"].append(name)
if not quiet and result.stdout: if result.stdout:
typer.echo(result.stdout.rstrip()) typer.echo(result.stdout.rstrip())
for name in to_update: for name in to_update:
@ -372,40 +434,28 @@ def _reconcile_with_manifest(
typer.echo(result.stderr.rstrip(), err=True) typer.echo(result.stderr.rstrip(), err=True)
continue continue
events["updated"].append(name) events["updated"].append(name)
if not quiet and result.stdout: if result.stdout:
typer.echo(result.stdout.rstrip()) typer.echo(result.stdout.rstrip())
def _emit_hook_message(events: dict[str, list[str]]) -> None: def _emit_check_hook_message() -> None:
"""Emit Claude Code hook JSON summarizing what changed. """Emit Claude Code hook JSON pointing the user at `/update-agnes-plugins`.
`systemMessage` is a transient toast (often missed). `additionalContext` `systemMessage` is a transient toast; `additionalContext` is wrapped in
is wrapped in a system reminder Claude reads at session start, so the a system reminder Claude reads at session start, so the model can
model can mention the change if it's relevant to the user's first ask. proactively mention the available update if the user's first ask is
Plugins land on disk during the hook; `/reload-plugins` loads them into plugin-related. The hook itself does NOT install anything running
the running session without a restart. the slash command is the user's choice.
""" """
parts: list[str] = [] summary = (
if events["installed"]: "Agnes marketplace has updates available. "
parts.append( "Run /update-agnes-plugins to install them."
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."
) )
payload = { payload = {
"systemMessage": f"{summary} {restart_hint}", "systemMessage": summary,
"hookSpecificOutput": { "hookSpecificOutput": {
"hookEventName": "SessionStart", "hookEventName": "SessionStart",
"additionalContext": f"{summary} {restart_hint}", "additionalContext": summary,
}, },
} }
typer.echo(json.dumps(payload)) typer.echo(json.dumps(payload))

82
cli/lib/commands.py Normal file
View 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")

View file

@ -18,9 +18,14 @@ Design notes:
so any wire-protocol bump lands before pull tries to use the new so any wire-protocol bump lands before pull tries to use the new
CLI version. Both `|| true`-guarded so an upgrade failure doesn't CLI version. Both `|| true`-guarded so an upgrade failure doesn't
block the pull. block the pull.
2. `agnes refresh-marketplace` independent entry so a fresh 2. `agnes refresh-marketplace --check` independent entry. Detector-
workspace (no marketplace cloned yet) failing this command doesn't only (since the slash-command split): runs `git fetch` against the
suppress the data pull above. 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 3. `agnes push` uploads any session JSONLs that haven't reached the
server yet (orphans from `claude -p` headless mode where Claude Code server yet (orphans from `claude -p` headless mode where Claude Code
does NOT fire SessionEnd, or from abnormal session exits). Symmetric 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 # isn't churned for parity (the same redirection fluff applies but
# changing the existing wire would force every workspace to re-write # changing the existing wire would force every workspace to re-write
# its settings.json on the next `agnes init` for no behaviour gain). # 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", [ _replace_or_add("SessionStart", [
"agnes self-upgrade --quiet 2>/dev/null || true; " "agnes self-upgrade --quiet 2>/dev/null || true; "
"agnes pull --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"', 'bash -c "agnes push --quiet 2>/dev/null || true"',
]) ])
# SessionEnd push must run detached. Claude Code in `-p` (headless) mode # SessionEnd push must run detached. Claude Code in `-p` (headless) mode

View 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`).

View file

@ -130,14 +130,20 @@ def test_refresh_marketplace_help():
result = runner.invoke(refresh_marketplace_app, ["--help"]) result = runner.invoke(refresh_marketplace_app, ["--help"])
assert result.exit_code == 0 assert result.exit_code == 0
cleaned = _clean(result.output) cleaned = _clean(result.output)
assert "--quiet" in cleaned # --check is the SessionStart-hook-friendly detector mode (replaced
# --auto-upgrade is gone — version-aware reconcile is now the default. # --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 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") 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 result.exit_code == 0
assert _clean(result.output) == "" assert _clean(result.output) == ""
assert recorder.calls == [] 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): 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 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 every workspace that has the hook installed, including ones where no
agnes token is configured (e.g. a fresh CI checkout, a workspace agnes token is configured (e.g. a fresh CI checkout, a workspace
that never went through `agnes init`, a project sharing the user's 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.delenv("AGNES_TOKEN", raising=False)
monkeypatch.setattr(rm_module, "CLONE_DIR", tmp_path / "nonexistent") monkeypatch.setattr(rm_module, "CLONE_DIR", tmp_path / "nonexistent")
# --quiet (hook context). # --check (hook context).
result = runner.invoke(refresh_marketplace_app, ["--quiet"]) result = runner.invoke(refresh_marketplace_app, ["--check"])
assert result.exit_code == 0, ( assert result.exit_code == 0, (
f"hook context should silent-noop without a token; got exit " f"hook context should silent-noop without a token; got exit "
f"{result.exit_code} and output {result.output!r}" 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 _clean(result.output) == ""
assert recorder.calls == [] 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, []) result = runner.invoke(refresh_marketplace_app, [])
assert result.exit_code == 0 assert result.exit_code == 0
assert "No marketplace clone" in _clean(result.output) 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) assert not any(c.cmd[:3] == ["claude", "plugin", "update"] for c in recorder.calls)
# --- Hook JSON output ----------------------------------------------------------- # --- Reload hint (default + slash-command chatty path) -------------------------
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
def test_manual_mode_prints_reload_hint_when_anything_changed( 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 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( def test_manual_mode_does_not_emit_hook_json(
with_clone, with_token, claude_in_path, recorder, monkeypatch, tmp_path, 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 = tmp_path / "ws"
workspace.mkdir() workspace.mkdir()
monkeypatch.chdir(workspace) monkeypatch.chdir(workspace)
@ -813,3 +648,190 @@ def test_bootstrap_with_existing_clone_skips_clone_proceeds_to_refresh(
assert fetch_calls assert fetch_calls
reset_calls = [c for c in recorder.calls if "reset" in c.cmd and "--hard" in c.cmd] reset_calls = [c for c in recorder.calls if "reset" in c.cmd and "--hard" in c.cmd]
assert reset_calls 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 == []

View 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"]

View file

@ -52,6 +52,16 @@ def test_install_creates_settings_file(tmp_path):
assert refresh.startswith("bash -c "), ( assert refresh.startswith("bash -c "), (
f"refresh-marketplace hook must be wrapped in bash -c for Windows; got: {refresh!r}" 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. # 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) push_start = next((c for c in starts if "agnes push" in c), None)
assert push_start is not 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] 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): def test_install_preserves_third_party_hooks(tmp_path):
settings_path = tmp_path / ".claude" / "settings.json" settings_path = tmp_path / ".claude" / "settings.json"
settings_path.parent.mkdir(parents=True) settings_path.parent.mkdir(parents=True)