Move marketplace plugin updates from hook to /update-agnes-plugins skill (#237)
* Move marketplace plugin updates from hook to /update-agnes-plugins skill The SessionStart hook used to run `agnes refresh-marketplace --quiet`, which performed a full fetch+reset+install cycle on every Claude Code session start. That work was invisible to the user, slowed session startup, and was unrecoverable interactively when something failed. Split the responsibility: - `agnes refresh-marketplace --check` is a new lightweight detector: `git fetch` only, compares local HEAD with remote FETCH_HEAD, emits a Claude Code hook JSON message pointing the user at `/update-agnes-plugins` when the marketplace has changes. No reset, no plugin install/update side effects. - `/update-agnes-plugins` is a new slash command (installed by `agnes init` into `<workspace>/.claude/commands/`) that runs `agnes refresh-marketplace` (default chatty path). Output streams into the Claude Code transcript so the user sees install/update progress and can react to errors interactively. - The SessionStart hook now runs `--check`. Existing workspaces auto-upgrade on next `agnes init` (substring marker matches both the old `--quiet` entry and the new `--check` one). BREAKING: `agnes refresh-marketplace --quiet` is removed. Old hooks calling it silent-noop after the CLI upgrade (the hook's `|| true` swallows the unknown-flag error) until re-init rewrites them. * Point marketplace 'Added to your stack' hint at /update-agnes-plugins The post-install green panel on plugin and skill/agent detail pages referenced the SessionStart auto-install path and a shell-prompt `agnes refresh-marketplace` invocation. With the hook now being detect-only, that copy was misleading — the actual install path is the new slash command. Condensed to a single instruction: "Open a new Claude Code session and run:" followed by `/update-agnes-plugins` in a copy-chip. JS clipboard string updated to match. --------- Co-authored-by: Minas Arustamyan <arustamyan.minas@gmail.com>
This commit is contained in:
parent
d269c69359
commit
d9405a6888
11 changed files with 647 additions and 283 deletions
47
CHANGELOG.md
47
CHANGELOG.md
|
|
@ -10,8 +10,55 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
|
||||||
|
|
||||||
## [Unreleased]
|
## [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
|
||||||
|
|
|
||||||
|
|
@ -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">Don’t show again</button>
|
<button class="dismiss" id="stack-hint-dismiss" type="button">Don’t 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';
|
||||||
|
|
|
||||||
|
|
@ -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">Don’t show again</button>
|
<button class="dismiss" id="stack-hint-dismiss" type="button">Don’t 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';
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
82
cli/lib/commands.py
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
"""Workspace-scoped Claude Code slash-command installer.
|
||||||
|
|
||||||
|
Sibling to `cli/lib/hooks.py`. Where hooks live in
|
||||||
|
`<workspace>/.claude/settings.json`, slash commands live as one
|
||||||
|
markdown file per command in `<workspace>/.claude/commands/`. This
|
||||||
|
module installs the Agnes-managed slash commands into a workspace.
|
||||||
|
|
||||||
|
Design notes:
|
||||||
|
- Workspace-scoped (`<workspace>/.claude/commands/<name>.md`), NOT
|
||||||
|
user-home. The slash commands appear only when Claude Code opens
|
||||||
|
this workspace, matching the hook scoping in `hooks.py`.
|
||||||
|
- Idempotent: always overwrites *our* files (server-managed canonical
|
||||||
|
content, naturally evolves with the CLI version) but never touches
|
||||||
|
third-party slash commands the user (or another tool) may have
|
||||||
|
authored under `.claude/commands/`. Listing files individually
|
||||||
|
rather than wiping the directory keeps custom commands safe.
|
||||||
|
- Templates ship inside the wheel under `cli/templates/commands/`.
|
||||||
|
`pyproject.toml` declares `cli` as a hatch wheel package, so
|
||||||
|
hatchling includes the markdown bodies during the build the same
|
||||||
|
way it ships `config/agnes_workspace_template.txt`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
# Slash commands managed by `agnes init`. Source (template name on
|
||||||
|
# disk under `cli/templates/commands/`) → destination filename in
|
||||||
|
# `<workspace>/.claude/commands/`. Today both names match; the indirection
|
||||||
|
# keeps the door open for renaming (e.g. internal template name vs the
|
||||||
|
# `/<command>` slug exposed to Claude Code).
|
||||||
|
_MANAGED_COMMANDS: tuple[tuple[str, str], ...] = (
|
||||||
|
("update-agnes-plugins.md", "update-agnes-plugins.md"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Defensive fallback used when the bundled template is missing on disk
|
||||||
|
# (broken install, stripped-down test environment). Mirrors the
|
||||||
|
# `agnes_workspace_template.txt` fallback in `cli/commands/init.py` —
|
||||||
|
# better to write a usable stub than to crash `agnes init`.
|
||||||
|
_FALLBACK_BODY = (
|
||||||
|
"---\n"
|
||||||
|
"description: Update Agnes marketplace plugins to latest versions\n"
|
||||||
|
"---\n"
|
||||||
|
"\n"
|
||||||
|
"Run `agnes refresh-marketplace` and report the output.\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _templates_dir() -> Path:
|
||||||
|
"""Locate the bundled-template directory.
|
||||||
|
|
||||||
|
`cli/lib/commands.py` → `cli/templates/commands/`.
|
||||||
|
Two `.parent` hops: lib/ → cli/, then descend into templates/commands/.
|
||||||
|
"""
|
||||||
|
return Path(__file__).parent.parent / "templates" / "commands"
|
||||||
|
|
||||||
|
|
||||||
|
def install_claude_commands(workspace: Path) -> None:
|
||||||
|
"""Install Agnes-managed slash commands into `<workspace>/.claude/commands/`.
|
||||||
|
|
||||||
|
Always writes (overwrites) the managed command files; never touches
|
||||||
|
other files the user may have under `.claude/commands/`. Idempotent.
|
||||||
|
"""
|
||||||
|
commands_dir = workspace / ".claude" / "commands"
|
||||||
|
commands_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
templates_dir = _templates_dir()
|
||||||
|
for source_name, dest_name in _MANAGED_COMMANDS:
|
||||||
|
source_path = templates_dir / source_name
|
||||||
|
try:
|
||||||
|
body = source_path.read_text(encoding="utf-8")
|
||||||
|
except OSError:
|
||||||
|
print(
|
||||||
|
f"Warning: bundled slash-command template "
|
||||||
|
f"{source_path} missing; writing defensive fallback.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
body = _FALLBACK_BODY
|
||||||
|
(commands_dir / dest_name).write_text(body, encoding="utf-8")
|
||||||
|
|
@ -18,9 +18,14 @@ Design notes:
|
||||||
so any wire-protocol bump lands before pull tries to use the new
|
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
|
||||||
|
|
|
||||||
19
cli/templates/commands/update-agnes-plugins.md
Normal file
19
cli/templates/commands/update-agnes-plugins.md
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
---
|
||||||
|
description: Update Agnes marketplace plugins to latest versions
|
||||||
|
---
|
||||||
|
|
||||||
|
Run the following command to refresh the Agnes marketplace plugins
|
||||||
|
for this workspace:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
agnes refresh-marketplace
|
||||||
|
```
|
||||||
|
|
||||||
|
Stream the output to me as it runs. If any plugins were installed
|
||||||
|
or updated, remind me to run `/reload-plugins` to load the changes
|
||||||
|
into this session — no Claude Code restart needed.
|
||||||
|
|
||||||
|
If the command fails, report the exact error so we can diagnose it
|
||||||
|
together (common causes: marketplace clone missing — fix with
|
||||||
|
`agnes refresh-marketplace --bootstrap`; expired PAT — fix with
|
||||||
|
`agnes auth import-token`).
|
||||||
|
|
@ -130,14 +130,20 @@ def test_refresh_marketplace_help():
|
||||||
result = runner.invoke(refresh_marketplace_app, ["--help"])
|
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 == []
|
||||||
|
|
|
||||||
83
tests/test_lib_commands.py
Normal file
83
tests/test_lib_commands.py
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
"""Tests for cli/lib/commands.py:install_claude_commands."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from cli.lib.commands import install_claude_commands
|
||||||
|
|
||||||
|
|
||||||
|
def _read_managed_command(workspace: Path) -> str:
|
||||||
|
return (workspace / ".claude" / "commands" / "update-agnes-plugins.md").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_writes_slash_command_file(tmp_path):
|
||||||
|
"""install_claude_commands writes update-agnes-plugins.md into
|
||||||
|
<workspace>/.claude/commands/. The file is the bundled template
|
||||||
|
(frontmatter + body) — the test just pins that *something* lands and
|
||||||
|
that frontmatter shape is intact, so a future template edit doesn't
|
||||||
|
silently lose its description metadata."""
|
||||||
|
install_claude_commands(tmp_path)
|
||||||
|
body = _read_managed_command(tmp_path)
|
||||||
|
assert body.startswith("---"), body[:120]
|
||||||
|
assert "description:" in body.split("---", 2)[1], body[:200]
|
||||||
|
# The slash command's whole point: invoke `agnes refresh-marketplace`.
|
||||||
|
# Pin that the bundled body actually references the command.
|
||||||
|
assert "agnes refresh-marketplace" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_creates_commands_dir_when_missing(tmp_path):
|
||||||
|
"""Workspace without a .claude/ tree at all → install creates both
|
||||||
|
.claude/ and .claude/commands/."""
|
||||||
|
assert not (tmp_path / ".claude").exists()
|
||||||
|
install_claude_commands(tmp_path)
|
||||||
|
assert (tmp_path / ".claude" / "commands").is_dir()
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_overwrites_existing_managed_file(tmp_path):
|
||||||
|
"""Even if the workspace has a hand-edited copy of the managed slash
|
||||||
|
command, install overwrites with the canonical template — server-
|
||||||
|
managed by design (Q2 of the design discussion). Users who want to
|
||||||
|
customize copy to a different filename, which is preserved by the
|
||||||
|
third-party-untouched test below."""
|
||||||
|
cmd_dir = tmp_path / ".claude" / "commands"
|
||||||
|
cmd_dir.mkdir(parents=True)
|
||||||
|
(cmd_dir / "update-agnes-plugins.md").write_text(
|
||||||
|
"USER EDIT THAT MUST BE OVERWRITTEN", encoding="utf-8",
|
||||||
|
)
|
||||||
|
install_claude_commands(tmp_path)
|
||||||
|
body = _read_managed_command(tmp_path)
|
||||||
|
assert "USER EDIT" not in body
|
||||||
|
assert "agnes refresh-marketplace" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_does_not_touch_other_command_files(tmp_path):
|
||||||
|
"""User's own slash commands under .claude/commands/ (e.g.
|
||||||
|
my-custom.md, project-specific helpers) must survive install
|
||||||
|
untouched. Only the Agnes-managed command files are overwritten."""
|
||||||
|
cmd_dir = tmp_path / ".claude" / "commands"
|
||||||
|
cmd_dir.mkdir(parents=True)
|
||||||
|
custom_path = cmd_dir / "my-custom.md"
|
||||||
|
custom_body = "---\ndescription: my own slash command\n---\n\nhello"
|
||||||
|
custom_path.write_text(custom_body, encoding="utf-8")
|
||||||
|
|
||||||
|
install_claude_commands(tmp_path)
|
||||||
|
assert custom_path.read_text(encoding="utf-8") == custom_body
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_idempotent(tmp_path):
|
||||||
|
"""Two consecutive installs produce identical state. Important
|
||||||
|
because `agnes init --force` re-runs the installer and the
|
||||||
|
SessionStart hook chain (in some future world where we wire it up)
|
||||||
|
might too — neither should accumulate stray files or change content
|
||||||
|
on a no-op invocation."""
|
||||||
|
install_claude_commands(tmp_path)
|
||||||
|
first = _read_managed_command(tmp_path)
|
||||||
|
install_claude_commands(tmp_path)
|
||||||
|
second = _read_managed_command(tmp_path)
|
||||||
|
assert first == second
|
||||||
|
# And no extra files appeared.
|
||||||
|
cmd_dir_files = sorted(p.name for p in (tmp_path / ".claude" / "commands").iterdir())
|
||||||
|
assert cmd_dir_files == ["update-agnes-plugins.md"]
|
||||||
|
|
@ -52,6 +52,16 @@ def test_install_creates_settings_file(tmp_path):
|
||||||
assert refresh.startswith("bash -c "), (
|
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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue