diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f0153a..0327d44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -189,6 +189,47 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C fine because `.mp-type-row` contributes its own 24px. ### Fixed +- **`agnes refresh-marketplace` now enables stack plugins in workspace + settings.** The reconcile step previously stopped at `claude plugin + install --scope project`, which only writes the global plugin registry + (`~/.claude/plugins/installed_plugins.json`). Without a corresponding + entry in the workspace `.claude/settings.json` `enabledPlugins` map, + Claude Code treats every installed stack plugin as disabled — `/plugins` + hides them from the active section and their slash commands, skills, + and agents are unreachable. Refresh now writes + `"@agnes": true` to the workspace settings file after install + and update, treating the user's marketplace stack as the source of + truth and re-enabling any plugin that a prior local `claude plugin + disable` had turned off. +- **Runtime CLI commands now work on Initial Workspace Template + (override) workspaces.** The `.claude/init-complete` sentinel + carrying `override: true` previously short-circuited **every** + Agnes writer to `.claude/`, which trapped admin-templated workspaces + at a stale snapshot: `agnes refresh-marketplace` couldn't write the + `enabledPlugins` map (the fix above stayed inert), and + `agnes self-upgrade`'s `maybe_refresh_claude_hooks` couldn't migrate + workspaces to new Agnes hook layouts. The sentinel was meant to gate + **init-time** skip only — let admins ship the *initial* `.claude/` + contents — not to lock the workspace permanently. The override check + moves from inside the writers + (`cli/lib/hooks.py::install_claude_hooks`, + `cli/lib/hooks.py::maybe_refresh_claude_hooks`, + `cli/lib/commands.py::install_claude_commands`, + `cli/commands/refresh_marketplace.py::_enable_plugins_in_workspace_settings`) + to the init-time call site that always was the right place + (`cli/commands/init.py::init`, `if not override_active:`). Init-time + behavior unchanged — `agnes init` on an override workspace still + defers the workspace skeleton to admin's template. Admin custom hooks + survive runtime refresh: Agnes only rewrites entries matching + `_OUR_COMMAND_MARKERS` (`agnes self-upgrade` / `agnes pull` / ... + substring set in `cli/lib/hooks.py`); foreign commands fall through + unchanged, same contract as in default workspaces. Existing override + workspaces auto-converge on the next `agnes self-upgrade` (which + fires from every SessionStart hook); no manual operator action + needed. Retracts the earlier *"full responsibility transfer; future + Agnes hook fixes will NOT auto-propagate"* contract documented in + the `### Internal — risk-accepted by design` bullets immediately + below — that scope was wider than the feature's actual intent. - **Store guardrails — post-#290 follow-up.** Admin Rescan still writes `status='blocked_inline'` (the only post-v30 producer of that status). Re-add `blocked_inline` to the admin queue's "Needs review" filter chip and to `TERMINAL_BLOCKED_STATUSES` in the bundle-purge job, so a rescan-produced row surfaces in the default operator view and its bundle gets swept by the TTL purge instead of lingering on disk indefinitely. Documents the rescan-only asymmetry inline (chip + purge tuple + new code comments). - Stale doc strings referring to the pre-#290 `blocked_inline` quota counter on `app/api/store.py` spam-quota comment, `app/instance_config.py::get_guardrails_blocked_quota_per_day` docstring, and the operator-facing hint in `/admin/server-config` (`blocked_quota_per_day`). All three now correctly describe the narrowed `blocked_llm + review_error` counter that #290 actually shipped. @@ -227,11 +268,9 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C - New audit-log actions: `initial_workspace.register`, `initial_workspace.sync`, `initial_workspace.sync_failed`, `initial_workspace.delete`, `initial_workspace.fetch_started` (server-authored, anchors the trail), `initial_workspace.applied` (CLI-authored, best-effort confirmation). ### Internal — risk-accepted by design (see Initial Workspace Template feature) -- `cli/lib/hooks.py::maybe_refresh_claude_hooks` deliberately no-ops when the workspace sentinel carries `override: true`. Future Agnes hook fixes will NOT auto-propagate to override workspaces via `agnes self-upgrade`; admin owns hook freshness in their template repo. Not a regression of #242 (the migration-gap fix that motivated the function applies to Agnes-default workspaces only). -- `cli/lib/hooks.py::install_claude_hooks` and `cli/lib/commands.py::install_claude_commands` short-circuit on override workspaces. Admin's repo `settings.json` and `.claude/commands/` are the source of truth. - `agnes init --force` on override workspaces does NOT back up `CLAUDE.md` (no `CLAUDE.md.bak.` file). Source of truth is the admin's Git repo; recovery is `git log` / `git checkout`. Not a regression of #164. - `.claude/CLAUDE.local.md` IS overwritten by override extraction when the admin's repo includes it. The default-mode "never overwrite CLAUDE.local.md" promise is a default-mode promise; override mode hands full file-level control to admin. Documented. -- `cli/lib/override.py::is_override_workspace` is the single source of truth for the override gate — every CLI surface that writes into `.claude/` calls it before touching the workspace. Avoids per-feature drift. +- `cli/lib/override.py::is_override_workspace` gates the **init-time** skip block in `cli/commands/init.py` (the `if not override_active:` branch). Runtime CLI commands (`agnes refresh-marketplace`, `agnes self-upgrade`'s `maybe_refresh_claude_hooks`) do NOT consult the sentinel and keep the workspace in sync — see the `### Fixed` entry "Runtime CLI commands now work on Initial Workspace Template workspaces" above for the full contract. - `app/api/marketplaces.py::_persist_token` removed; both marketplaces and the new initial-workspace endpoint now route through the shared `app/secrets.py::persist_overlay_token` helper, which wraps the `.env_overlay` read-modify-write in a process-wide `threading.Lock`. Closes a pre-existing race where two concurrent `/admin/marketplaces` Save clicks could clobber each other's PATs on the overlay file. ## [0.54.8] — 2026-05-13 diff --git a/cli/commands/refresh_marketplace.py b/cli/commands/refresh_marketplace.py index a62a936..9af28ae 100644 --- a/cli/commands/refresh_marketplace.py +++ b/cli/commands/refresh_marketplace.py @@ -136,7 +136,7 @@ def refresh_marketplace( _emit_check_hook_message() raise typer.Exit(0) - events: dict[str, list[str]] = {"installed": [], "updated": []} + events: dict[str, list[str]] = {"installed": [], "updated": [], "enabled": []} if not _git_fetch_and_reset(token): raise typer.Exit(1) @@ -153,7 +153,7 @@ def refresh_marketplace( _reconcile_with_manifest(events=events, installed_pre=installed_pre) - if events["installed"] or events["updated"]: + if events["installed"] or events["updated"] or events["enabled"]: typer.echo( "\nRun `/reload-plugins` in Claude Code to load the " "new/updated plugins into the running session — no restart needed." @@ -483,10 +483,6 @@ def _reconcile_with_manifest( elif installed_version != manifest_version: to_update.append(name) - if not to_install and not to_update: - typer.echo(f"All {len(manifest)} Agnes-stack plugin(s) up to date.") - return - if to_install: typer.echo(f"Installing {len(to_install)} new plugin(s): " + ", ".join(to_install)) if to_update: @@ -528,6 +524,16 @@ def _reconcile_with_manifest( if result.stdout: typer.echo(result.stdout.rstrip()) + # Whether anything was installed or updated above, the workspace + # settings.json must end up with `enabledPlugins["@agnes"]: true` + # for every plugin in the stack — `claude plugin install` does not do + # this on its own, and a fresh refresh on a workspace where the user + # manually `claude plugin disable`-d a stack plugin must re-enable it. + _enable_plugins_in_workspace_settings(manifest, events=events) + + if not to_install and not to_update and not events["enabled"]: + typer.echo(f"All {len(manifest)} Agnes-stack plugin(s) up to date.") + def _emit_check_hook_message() -> None: """Emit Claude Code hook JSON pointing the user at `/update-agnes-plugins`. @@ -630,3 +636,84 @@ def _list_installed_agnes_plugins_in_cwd() -> Optional[dict[str, str]]: if name: versions[name] = version return versions + + +def _enable_plugins_in_workspace_settings( + manifest: dict[str, str], + *, + events: dict[str, list[str]], +) -> None: + """Ensure workspace `.claude/settings.json` has `enabledPlugins` entries + for every plugin in the user's stack manifest. + + `claude plugin install --scope project` only writes the global plugin + registry (`~/.claude/plugins/installed_plugins.json`); it does NOT add + the plugin to the workspace `enabledPlugins` map, so Claude Code treats + every stack plugin as disabled until something explicitly enables it. + This helper closes that gap: after install/update, we write + `"@agnes": true` for each manifest entry directly into the + workspace settings. + + Stack-as-source-of-truth: a locally `claude plugin disable`-d plugin + that still appears in the user's stack gets re-enabled. To permanently + exclude a plugin, remove it from the stack (`agnes marketplace remove`) + rather than relying on local disable, which is ephemeral between + refreshes. + + Runs unconditionally — `refresh-marketplace` is a runtime command, so + the Initial Workspace Template sentinel (`override: true`) does not + apply here. The sentinel governs init-time skip only; runtime CLI + keeps workspaces in sync with the user's current stack regardless of + how the workspace was originally seeded. + + Idempotent: writes only when at least one plugin actually changed + state (missing/false → true). No write when everything is already + enabled, so this is safe to call on every refresh without churning + mtime or polluting git diffs in workspace repos. + """ + workspace = Path.cwd() + settings_path = workspace / ".claude" / "settings.json" + settings_path.parent.mkdir(parents=True, exist_ok=True) + + if settings_path.exists(): + try: + cfg = json.loads(settings_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + typer.echo( + f"warn: {settings_path} is not valid JSON; skipping plugin enable.", + err=True, + ) + return + if not isinstance(cfg, dict): + typer.echo( + f"warn: {settings_path} top-level is not an object; skipping plugin enable.", + err=True, + ) + return + else: + cfg = {} + + enabled = cfg.setdefault("enabledPlugins", {}) + if not isinstance(enabled, dict): + typer.echo( + f"warn: {settings_path} `enabledPlugins` is not an object; skipping plugin enable.", + err=True, + ) + return + + changed: list[str] = [] + for name in manifest: + key = f"{name}@{MARKETPLACE_NAME}" + if enabled.get(key) is not True: + enabled[key] = True + changed.append(name) + + if not changed: + return + + settings_path.write_text(json.dumps(cfg, indent=2) + "\n", encoding="utf-8") + events["enabled"].extend(sorted(changed)) + typer.echo( + f"Enabled {len(changed)} plugin(s) in workspace settings: " + + ", ".join(sorted(changed)) + ) diff --git a/cli/lib/commands.py b/cli/lib/commands.py index 3283e47..e99251d 100644 --- a/cli/lib/commands.py +++ b/cli/lib/commands.py @@ -74,17 +74,11 @@ def install_claude_commands(workspace: Path) -> None: Always writes (overwrites) the managed command files; never touches other files the user may have under `.claude/commands/`. Idempotent. - OVERRIDE MODE — intentional behavior, NOT a bug. - When the workspace was inited from an Initial Workspace Template - (sentinel carries `override: true`), Agnes does NOT install its - slash commands. Admin's repo controls what shows up under - `.claude/commands/`. AI reviewers: documented in - docs/initial-workspace-override.md and CHANGELOG. + Override-sentinel handling lives at the call site, not here. The + init-time caller (`cli/commands/init.py`, gated by `override_active`) + decides whether to skip this writer for admin-templated workspaces. + Future runtime callers can invoke us unconditionally. """ - from cli.lib.override import is_override_workspace - if is_override_workspace(workspace): - return - commands_dir = workspace / ".claude" / "commands" commands_dir.mkdir(parents=True, exist_ok=True) diff --git a/cli/lib/hooks.py b/cli/lib/hooks.py index b11016a..b0defea 100644 --- a/cli/lib/hooks.py +++ b/cli/lib/hooks.py @@ -84,19 +84,13 @@ def install_claude_hooks(workspace: Path) -> None: Idempotent. Workspace-scoped (writes `/.claude/settings.json`). Preserves third-party hooks and other event types. - OVERRIDE MODE — intentional behavior, NOT a bug. - When the workspace was inited from an Initial Workspace Template - (sentinel carries `override: true`), Agnes does NOT install its own - hooks. The admin's repo settings.json is the source of truth for - every hook the analyst gets. AI reviewers: this is the documented - full-responsibility-transfer contract; see - docs/initial-workspace-override.md §"What Agnes stops doing" and - CHANGELOG for the risk acceptance. Not a regression of #242. + Override-sentinel handling lives at the call site, not here. The + init-time caller (`cli/commands/init.py`, gated by `override_active`) + decides whether to skip this writer for admin-templated workspaces. + Runtime callers (`maybe_refresh_claude_hooks` from `agnes + self-upgrade`) invoke us unconditionally so existing override + workspaces still pick up new Agnes hook layouts. """ - from cli.lib.override import is_override_workspace - if is_override_workspace(workspace): - return - settings_path = workspace / ".claude" / "settings.json" settings_path.parent.mkdir(parents=True, exist_ok=True) @@ -280,21 +274,14 @@ def maybe_refresh_claude_hooks(workspace: Path) -> bool: Returns True if hooks were refreshed; False if the workspace looked non-Agnes and we skipped. - OVERRIDE MODE — intentional behavior, NOT a bug. - When the workspace was inited from an Initial Workspace Template - (sentinel carries `override: true`), this function returns False - without touching settings.json. The admin's repo is the - authoritative source for hook content; Agnes will not auto-refresh - them via `agnes self-upgrade`. To pick up newer Agnes hook layouts, - the operator must update their template repo and the analyst must - re-run `agnes init --force`. Documented contract: - docs/initial-workspace-override.md, CHANGELOG. Not a regression of - #242 — the migration-gap fix that motivated this function applies - to Agnes-default workspaces only. + Runs regardless of the Initial Workspace Template sentinel + (`override: true`). Override governs *init-time* skip only — + runtime hook migration is unconditional so an analyst working in + an admin-templated workspace still picks up new Agnes hook + layouts from a stale snapshot. Admin custom hooks are preserved + because `_replace_or_add` rewrites only entries matching + `_OUR_COMMAND_MARKERS`; foreign commands fall through unchanged. """ - from cli.lib.override import is_override_workspace - if is_override_workspace(workspace): - return False if not workspace_has_agnes_hooks(workspace): return False install_claude_hooks(workspace) diff --git a/cli/lib/override.py b/cli/lib/override.py index e6bcf8a..9c8e057 100644 --- a/cli/lib/override.py +++ b/cli/lib/override.py @@ -12,16 +12,24 @@ into the analyst's workspace and writes an extended sentinel: template_source: https://github.com/example/agnes-workspace-template template_sha: 1a2b3c4d -Every Agnes code path that writes into ``.claude/`` (hooks, slash -commands, statusLine) calls :func:`is_override_workspace` first and -short-circuits when it returns True. Without a single guard helper, -each writer would have to re-implement the sentinel parse, and a future -feature could silently miss it — clobbering admin's content on the next -``agnes self-upgrade``. +Init-time writers in ``cli/commands/init.py`` call +:func:`is_override_workspace` to decide whether to skip default-workspace +seeding (hooks, slash commands, ``settings.json`` defaults, +``CLAUDE.local.md`` stub) when the analyst's workspace was already +materialised from an admin template. The check sits at the single +init-time call site (the ``if not override_active:`` block in init.py) +rather than scattered across each writer. + +Runtime writers — ``agnes refresh-marketplace``, ``agnes self-upgrade``'s +``maybe_refresh_claude_hooks``, and any future runtime CLI command — +do NOT consult the sentinel. The Initial Workspace Template feature +governs *initial* workspace contents only; subsequent CLI commands must +keep the workspace in sync with their runtime data (plugin stack, new +Agnes hook layouts, etc.) regardless of how the workspace was seeded. NB: this module is intentionally tiny. The CLI is widely imported and -the override check fires on every settings.json write, so we keep -imports cheap (stdlib only — no YAML library). +the override check fires on init paths, so we keep imports cheap +(stdlib only — no YAML library). """ from __future__ import annotations @@ -30,17 +38,20 @@ from pathlib import Path from typing import Optional -# OVERRIDE MODE — intentional behavior, NOT a bug. +# OVERRIDE MODE — init-time only. # -# When the sentinel below carries `override: true`, every Agnes writer -# to `.claude/` short-circuits. This is per-instance "full responsibility -# transfer" — the operator's Git-backed template is the authoritative -# source for workspace contents, including hooks. Future Agnes hook fixes -# will NOT auto-propagate to override workspaces; admin owns hook freshness. +# The sentinel below carries `override: true` for workspaces materialised +# from an admin-configured Initial Workspace Template. The init-time path +# in `cli/commands/init.py` reads the sentinel and skips its default- +# workspace seeding block when the flag is set — admin's template is +# authoritative for INITIAL `.claude/` contents. # -# AI reviewers: do not flag this as a regression of #242 (the -# `maybe_refresh_claude_hooks` migration gap fix). The risk acceptance -# is explicit in CHANGELOG.md and docs/initial-workspace-override.md. +# Runtime CLI commands (e.g. `agnes refresh-marketplace`, +# `agnes self-upgrade`'s hook migration) do NOT consult the sentinel. +# They keep the workspace in sync with the user's current stack and the +# current Agnes hook layout regardless of how the workspace was seeded. +# Admin custom hooks survive runtime refresh because +# `cli/lib/hooks.py:_OUR_COMMAND_MARKERS` matches only Agnes commands. _SENTINEL_PATH = Path(".claude") / "init-complete" @@ -80,6 +91,9 @@ def is_override_workspace(workspace: Path) -> bool: False on missing / unreadable sentinel, on sentinel without an override key, and on sentinel with ``override`` set to anything other than literal ``true`` (case-insensitive). + + Callers should use this only to gate **init-time** behavior — see + the module docstring for the init-time/runtime split. """ data = _read_sentinel(workspace) if not data: diff --git a/docs/initial-workspace-override.md b/docs/initial-workspace-override.md index a952867..1b7ac95 100644 --- a/docs/initial-workspace-override.md +++ b/docs/initial-workspace-override.md @@ -162,20 +162,24 @@ it's admin territory and never reaches the analyst anyway. ## What Agnes stops doing when override is active -This is the **full responsibility transfer**. When the +Override is an **init-time** contract. When the `initial_workspace:` section is configured AND synced, `agnes init` -runs the override flow and bypasses every default-mode workspace write: +runs the override flow and bypasses every default-mode workspace +write — admin's template is the source of truth for the INITIAL +`.claude/` contents. Subsequent runtime CLI commands keep updating +the workspace as on a default install. + +### Init-time skip (admin's template wins) | Default behavior | Override behavior | |------------------------------------------------------------------------|-----------------------------------------------------------------------------------| | `CLAUDE.md` fetched from `/api/welcome` (server-rendered Jinja2) | `CLAUDE.md` comes verbatim from your repo (no Jinja2, no RBAC filtering) | | `.claude/settings.json` seeded with `{model: sonnet, permissions: …}` | Whatever your repo ships (or no file at all) | -| `install_claude_hooks(workspace)` installs SessionStart/End/statusLine | Your repo's `settings.json` is the source of truth; Agnes installs nothing | -| `install_claude_commands(workspace)` installs `/update-agnes-plugins` + `/agnes-private` | Your repo controls `.claude/commands/` | +| `install_claude_hooks(workspace)` installs SessionStart/End/statusLine | Your repo's `settings.json` is the source of truth at init time; Agnes installs nothing during `agnes init` | +| `install_claude_commands(workspace)` installs `/update-agnes-plugins` + `/agnes-private` | Your repo controls `.claude/commands/` at init time | | `.claude/CLAUDE.local.md` stub written if absent | If your repo ships one, that wins; otherwise the file simply doesn't exist | | `AGNES_WORKSPACE.md` rendered from `config/agnes_workspace_template.txt` | Your repo controls (or doesn't ship at all) | | `--force` backs up `CLAUDE.md` to `CLAUDE.md.bak.` | **No backup.** Source of truth is your Git repo; recovery is `git log` / `git checkout`.| -| `agnes self-upgrade` auto-refreshes hooks via `maybe_refresh_claude_hooks` | No auto-refresh. Future Agnes hook changes do NOT propagate; admin updates the repo and runs `agnes init --force` to pick them up. | The remaining `agnes init` steps **still run** — they are data-plane concerns, not workspace-skeleton concerns: @@ -183,9 +187,31 @@ concerns, not workspace-skeleton concerns: - **PAT verification** against `/api/catalog/tables`. - **`agnes pull`** of the parquets, DuckDB views, and corporate-memory rules under `server/parquet/`, `user/duckdb/`, `.claude/rules/`. -- **Completion sentinel** at `.claude/init-complete` — but written with +- **Completion sentinel** at `.claude/init-complete` — written with extended fields (`override: true`, `template_source`, `template_sha`) - so future Agnes CLI invocations know not to auto-refresh hooks. + so future `agnes init` (re-)runs detect the override and skip the + default seeding block. + +### Runtime CLI keeps working (Agnes stays in sync) + +Runtime commands — anything the analyst invokes *after* init — ignore +the sentinel and update workspace `.claude/` content normally. This is +a documented contract, not an implementation detail. Concretely: + +| Runtime path | Behavior on override workspace | +|------------------------------------------------------------------------|-----------------------------------------------------------------------------------| +| `agnes self-upgrade` → `maybe_refresh_claude_hooks` | **Refreshes Agnes hook entries** in `.claude/settings.json` so analysts pick up new hook layouts (e.g. new SessionStart entries). Your custom hooks — anything whose command does NOT match `_OUR_COMMAND_MARKERS` in `cli/lib/hooks.py` — fall through unchanged. | +| `agnes refresh-marketplace` → `_enable_plugins_in_workspace_settings` | **Writes `enabledPlugins` map** for the user's curated stack (`"@agnes": true`). Stack is the source of truth — locally `claude plugin disable`-d plugins that remain in the stack get re-enabled. To permanently exclude, remove from stack via `agnes marketplace remove`. | +| Future runtime CLI commands that need to update `.claude/` | Treat override sentinel as non-existent. Same contract. | + +Practical implication for you (the operator): ship your template with +the INITIAL `.claude/` skeleton you want. You do NOT need to ship +`enabledPlugins`, nor do you need to keep `settings.json` Agnes hook +entries permanently frozen at one revision — Agnes will keep them +current via `agnes self-upgrade`. If you want to add custom commands +to a Session hook, just include them in your repo's `settings.json` +under an entry whose command does NOT contain any of the +`_OUR_COMMAND_MARKERS` substrings; runtime refresh leaves it alone. ## What you (the operator) must include in your repo diff --git a/tests/test_cli_init_override.py b/tests/test_cli_init_override.py index 17ceb10..24476d5 100644 --- a/tests/test_cli_init_override.py +++ b/tests/test_cli_init_override.py @@ -260,15 +260,20 @@ def test_confirmation_whitespace_yes_returns_true(monkeypatch): # =========================================================================== -def test_install_claude_hooks_noop_on_override(tmp_path): - """install_claude_hooks short-circuits when override sentinel present.""" +def test_install_claude_hooks_runs_regardless_of_sentinel(tmp_path): + """install_claude_hooks no longer consults the override sentinel + directly — that check moved to its init-time call site in + `cli/commands/init.py`. The writer itself runs unconditionally so + runtime callers (`maybe_refresh_claude_hooks`) can use it on + override workspaces too.""" from cli.lib.hooks import install_claude_hooks _write_sentinel(tmp_path, "override: true\n") install_claude_hooks(tmp_path) - # Should NOT have created settings.json or modified anything in .claude/ settings = tmp_path / ".claude" / "settings.json" - assert not settings.exists() + assert settings.exists() + cfg = json.loads(settings.read_text()) + assert "hooks" in cfg def test_install_claude_hooks_runs_on_default_workspace(tmp_path): @@ -282,43 +287,56 @@ def test_install_claude_hooks_runs_on_default_workspace(tmp_path): assert "hooks" in cfg -def test_maybe_refresh_claude_hooks_noop_on_override(tmp_path): - """maybe_refresh_claude_hooks returns False on override workspace - even when the workspace LOOKS like an Agnes workspace (has agnes hooks).""" +def test_maybe_refresh_claude_hooks_runs_regardless_of_sentinel(tmp_path): + """Runtime hook migration ignores the override sentinel — analysts + in admin-templated workspaces still pick up new Agnes hook layouts + via `agnes self-upgrade`. Admin custom hooks (commands NOT matching + `_OUR_COMMAND_MARKERS`) survive the refresh untouched.""" from cli.lib.hooks import maybe_refresh_claude_hooks - # First write some agnes-looking hooks so workspace_has_agnes_hooks True + # Seed an Agnes-looking workspace with one Agnes-managed hook (gets + # replaced) and one foreign hook (must survive). settings_path = tmp_path / ".claude" / "settings.json" settings_path.parent.mkdir(parents=True, exist_ok=True) settings_path.write_text(json.dumps({ "hooks": { "SessionStart": [ - {"hooks": [{"type": "command", "command": "agnes pull --quiet"}]} + {"hooks": [{"type": "command", "command": "agnes pull --quiet"}]}, + {"hooks": [{"type": "command", "command": "echo admin-custom-hook"}]}, ] } })) - # Override sentinel — should now short-circuit the refresh _write_sentinel(tmp_path, "override: true\n") - assert maybe_refresh_claude_hooks(tmp_path) is False - # Verify settings.json wasn't rewritten with the Agnes default hooks + assert maybe_refresh_claude_hooks(tmp_path) is True cfg = json.loads(settings_path.read_text()) cmds = [ h["command"] for entry in cfg["hooks"]["SessionStart"] for h in entry["hooks"] ] - # Original single command intact — no capture-session / refresh-marketplace added - assert cmds == ["agnes pull --quiet"] + # Foreign admin hook preserved (no Agnes substring → not matched by + # `_OUR_COMMAND_MARKERS`, so `_replace_or_add` leaves it). + assert "echo admin-custom-hook" in cmds + # Agnes hooks rewritten to current default layout (capture-session, + # self-upgrade+pull chain, refresh-marketplace --check). + assert any("agnes capture-session" in c for c in cmds) + assert any("agnes refresh-marketplace --check" in c for c in cmds) -def test_install_claude_commands_noop_on_override(tmp_path): +def test_install_claude_commands_runs_regardless_of_sentinel(tmp_path): + """install_claude_commands no longer consults the override sentinel + directly — init-time skip lives in `cli/commands/init.py`. The + writer itself runs unconditionally.""" from cli.lib.commands import install_claude_commands _write_sentinel(tmp_path, "override: true\n") install_claude_commands(tmp_path) commands_dir = tmp_path / ".claude" / "commands" - assert not commands_dir.exists() or list(commands_dir.iterdir()) == [] + assert commands_dir.exists() + assert list(commands_dir.iterdir()), ( + "expected at least one managed slash command file to be written" + ) # =========================================================================== diff --git a/tests/test_cli_refresh_marketplace.py b/tests/test_cli_refresh_marketplace.py index 05beeda..fa1bf37 100644 --- a/tests/test_cli_refresh_marketplace.py +++ b/tests/test_cli_refresh_marketplace.py @@ -473,10 +473,20 @@ def test_manual_mode_no_change_does_not_print_reload_hint( with_clone, with_token, claude_in_path, recorder, monkeypatch, tmp_path, ): """Manual `agnes refresh-marketplace` over an already-up-to-date stack - must NOT spam the reload hint — there's nothing to reload for.""" + must NOT spam the reload hint — there's nothing to reload for. + + "Up to date" now also means the workspace `enabledPlugins` map already + matches the stack; without that seed the enable step would otherwise + flip a missing entry to `true` and legitimately request a reload. + """ workspace = tmp_path / "ws" workspace.mkdir() monkeypatch.chdir(workspace) + settings_dir = workspace / ".claude" + settings_dir.mkdir() + (settings_dir / "settings.json").write_text( + json.dumps({"enabledPlugins": {"grpn-eng@agnes": True}}), encoding="utf-8", + ) _set_marketplace_manifest(with_clone, [{"name": "grpn-eng", "version": "1.0.0"}]) recorder.script( ("claude", "plugin", "list", "--json"), @@ -1039,3 +1049,242 @@ def test_bootstrap_recovery_add_failure_is_fatal_on_existing_clone( f"recovery must abort before `marketplace update` when add fails; got: " f"{[c.cmd for c in update_calls]!r}" ) + + +# --- enabledPlugins workspace-settings write ----------------------------------- +# +# Refresh's reconcile step doesn't just register plugins in the global +# `~/.claude/plugins/installed_plugins.json`; it also has to write +# `enabledPlugins["@agnes"] = true` into the workspace +# `.claude/settings.json`. Without that entry, Claude Code treats the +# plugin as disabled regardless of registry presence. These tests pin the +# helper's contract end-to-end through the Typer command, since the helper +# touches the filesystem and is easier to verify via the real settings.json +# state than via additional mocking. + + +def _read_workspace_settings(workspace: Path) -> dict: + settings_path = workspace / ".claude" / "settings.json" + return json.loads(settings_path.read_text(encoding="utf-8")) + + +def test_enable_writes_missing_key_to_workspace_settings( + with_clone, with_token, claude_in_path, recorder, monkeypatch, tmp_path, +): + """Fresh workspace with no `.claude/settings.json` → refresh creates the + file with `enabledPlugins` populated from the manifest.""" + workspace = tmp_path / "ws" + workspace.mkdir() + monkeypatch.chdir(workspace) + _set_marketplace_manifest(with_clone, [ + {"name": "grpn", "version": "1.0.0"}, + {"name": "grpn-data", "version": "1.1.0"}, + ]) + recorder.script(("claude", "plugin", "list", "--json"), + stdout=_plugin_list_json([])) + + result = runner.invoke(refresh_marketplace_app, []) + assert result.exit_code == 0, result.output + + settings = _read_workspace_settings(workspace) + assert settings.get("enabledPlugins") == { + "grpn@agnes": True, + "grpn-data@agnes": True, + } + + +def test_enable_writes_to_existing_settings_preserving_other_keys( + with_clone, with_token, claude_in_path, recorder, monkeypatch, tmp_path, +): + """Workspace already has settings.json with hooks/model/permissions. + Refresh must add `enabledPlugins` without disturbing existing keys.""" + workspace = tmp_path / "ws" + workspace.mkdir() + monkeypatch.chdir(workspace) + settings_dir = workspace / ".claude" + settings_dir.mkdir() + pre_existing = { + "model": "sonnet", + "permissions": {"allow": ["Read", "Bash"]}, + "hooks": {"SessionStart": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}, + } + (settings_dir / "settings.json").write_text( + json.dumps(pre_existing, indent=2), encoding="utf-8", + ) + + _set_marketplace_manifest(with_clone, [{"name": "grpn", "version": "1.0.0"}]) + recorder.script(("claude", "plugin", "list", "--json"), + stdout=_plugin_list_json([])) + + result = runner.invoke(refresh_marketplace_app, []) + assert result.exit_code == 0, result.output + + settings = _read_workspace_settings(workspace) + assert settings["model"] == "sonnet" + assert settings["permissions"] == {"allow": ["Read", "Bash"]} + assert settings["hooks"] == pre_existing["hooks"] + assert settings["enabledPlugins"] == {"grpn@agnes": True} + + +def test_enable_overrides_local_false_back_to_true( + with_clone, with_token, claude_in_path, recorder, monkeypatch, tmp_path, +): + """User locally `claude plugin disable`-d a stack plugin (enabledPlugins + has `false`). Stack is source of truth → refresh re-enables it.""" + workspace = tmp_path / "ws" + workspace.mkdir() + monkeypatch.chdir(workspace) + settings_dir = workspace / ".claude" + settings_dir.mkdir() + (settings_dir / "settings.json").write_text( + json.dumps({"enabledPlugins": {"grpn@agnes": False}}), encoding="utf-8", + ) + + _set_marketplace_manifest(with_clone, [{"name": "grpn", "version": "1.0.0"}]) + recorder.script(("claude", "plugin", "list", "--json"), + stdout=_plugin_list_json([ + {"id": "grpn@agnes", "version": "1.0.0", + "projectPath": str(workspace)}, + ])) + + result = runner.invoke(refresh_marketplace_app, []) + assert result.exit_code == 0, result.output + + settings = _read_workspace_settings(workspace) + assert settings["enabledPlugins"] == {"grpn@agnes": True} + # Re-enabled → reload hint should fire (even though no install/update). + assert "/reload-plugins" in _clean(result.output) + + +def test_enable_is_idempotent_when_already_true( + with_clone, with_token, claude_in_path, recorder, monkeypatch, tmp_path, +): + """Every plugin in manifest already `true` in settings → refresh must + not rewrite the file (mtime stable) and must not advertise enable + events.""" + workspace = tmp_path / "ws" + workspace.mkdir() + monkeypatch.chdir(workspace) + settings_dir = workspace / ".claude" + settings_dir.mkdir() + settings_path = settings_dir / "settings.json" + settings_path.write_text( + json.dumps({"enabledPlugins": {"grpn@agnes": True}}, indent=2), + encoding="utf-8", + ) + mtime_before = settings_path.stat().st_mtime_ns + + _set_marketplace_manifest(with_clone, [{"name": "grpn", "version": "1.0.0"}]) + recorder.script(("claude", "plugin", "list", "--json"), + stdout=_plugin_list_json([ + {"id": "grpn@agnes", "version": "1.0.0", + "projectPath": str(workspace)}, + ])) + + result = runner.invoke(refresh_marketplace_app, []) + assert result.exit_code == 0, result.output + + settings = _read_workspace_settings(workspace) + assert settings["enabledPlugins"] == {"grpn@agnes": True} + assert settings_path.stat().st_mtime_ns == mtime_before, ( + "no-op refresh must not rewrite settings.json" + ) + # No install/update/enable changes → no reload hint. + assert "/reload-plugins" not in _clean(result.output) + + +def test_enable_preserves_non_agnes_plugins_in_map( + with_clone, with_token, claude_in_path, recorder, monkeypatch, tmp_path, +): + """Workspace's `enabledPlugins` contains entries from other marketplaces + (e.g. coupons-team-skills). Refresh must not touch those keys; it only + adds/sets `@agnes` entries.""" + workspace = tmp_path / "ws" + workspace.mkdir() + monkeypatch.chdir(workspace) + settings_dir = workspace / ".claude" + settings_dir.mkdir() + (settings_dir / "settings.json").write_text( + json.dumps({"enabledPlugins": { + "coupons-skills@coupons-team-skills": True, + "platform-tools@coupons-team-skills": False, # user disabled + }}), + encoding="utf-8", + ) + + _set_marketplace_manifest(with_clone, [{"name": "grpn", "version": "1.0.0"}]) + recorder.script(("claude", "plugin", "list", "--json"), + stdout=_plugin_list_json([])) + + result = runner.invoke(refresh_marketplace_app, []) + assert result.exit_code == 0, result.output + + settings = _read_workspace_settings(workspace) + assert settings["enabledPlugins"] == { + "coupons-skills@coupons-team-skills": True, + "platform-tools@coupons-team-skills": False, + "grpn@agnes": True, + } + + +def test_enable_runs_regardless_of_override_sentinel( + with_clone, with_token, claude_in_path, recorder, monkeypatch, tmp_path, +): + """`refresh-marketplace` is a runtime command — it ignores the + Initial Workspace Template sentinel and updates `enabledPlugins` + even in admin-templated (override: true) workspaces. The sentinel + governs `agnes init` skip only; runtime must keep the workspace in + sync with the user's current marketplace stack.""" + workspace = tmp_path / "ws" + workspace.mkdir() + monkeypatch.chdir(workspace) + settings_dir = workspace / ".claude" + settings_dir.mkdir() + # Admin-managed sentinel — must NOT block runtime enable. + (settings_dir / "init-complete").write_text( + "completed_at: 2026-05-13T14:32:00Z\n" + "agnes_version: 0.53.0\n" + "override: true\n", + encoding="utf-8", + ) + # No pre-existing settings.json — refresh creates one with enabledPlugins. + + _set_marketplace_manifest(with_clone, [{"name": "grpn", "version": "1.0.0"}]) + recorder.script(("claude", "plugin", "list", "--json"), + stdout=_plugin_list_json([])) + + result = runner.invoke(refresh_marketplace_app, []) + assert result.exit_code == 0, result.output + + settings = _read_workspace_settings(workspace) + assert settings.get("enabledPlugins") == {"grpn@agnes": True} + + +def test_reload_hint_printed_when_only_enable_changes( + with_clone, with_token, claude_in_path, recorder, monkeypatch, tmp_path, +): + """Nothing to install/update, but enable map had a stale `false` entry + → refresh flips it to `true` and prints the /reload-plugins hint so + the user knows to reload the running session.""" + workspace = tmp_path / "ws" + workspace.mkdir() + monkeypatch.chdir(workspace) + settings_dir = workspace / ".claude" + settings_dir.mkdir() + (settings_dir / "settings.json").write_text( + json.dumps({"enabledPlugins": {"grpn@agnes": False}}), encoding="utf-8", + ) + _set_marketplace_manifest(with_clone, [{"name": "grpn", "version": "1.0.0"}]) + recorder.script(("claude", "plugin", "list", "--json"), + stdout=_plugin_list_json([ + {"id": "grpn@agnes", "version": "1.0.0", + "projectPath": str(workspace)}, + ])) + + result = runner.invoke(refresh_marketplace_app, []) + assert result.exit_code == 0, result.output + out = _clean(result.output) + assert "/reload-plugins" in out + # No install or update should have been triggered. + assert not any(c.cmd[:3] == ["claude", "plugin", "install"] for c in recorder.calls) + assert not any(c.cmd[:3] == ["claude", "plugin", "update"] for c in recorder.calls)