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]
### 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

View file

@ -523,18 +523,12 @@
<span class="title">✓ Added to your stack</span>
<button class="dismiss" id="stack-hint-dismiss" type="button">Dont 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>Open a new Claude Code session and run:</div>
<div class="cmd-chip">
<span class="prompt">$</span>
<span class="cmd">agnes refresh-marketplace</span>
<span class="prompt">/</span>
<span class="cmd">update-agnes-plugins</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>
</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';

View file

@ -512,18 +512,12 @@
<span class="title">✓ Added to your stack</span>
<button class="dismiss" id="stack-hint-dismiss" type="button">Dont 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>Open a new Claude Code session and run:</div>
<div class="cmd-chip">
<span class="prompt">$</span>
<span class="cmd">agnes refresh-marketplace</span>
<span class="prompt">/</span>
<span class="cmd">update-agnes-plugins</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>
</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';

View file

@ -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

View file

@ -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,7 +186,6 @@ 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}...")
try:
@ -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}.")
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,11 +393,9 @@ 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.")
return
if not quiet:
if to_install:
typer.echo(f"Installing {len(to_install)} new plugin(s): " + ", ".join(to_install))
if to_update:
@ -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
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
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

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"])
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 == []

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 "), (
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)