fix: refresh-marketplace enables stack plugins; override sentinel is init-time only (#307)

* fix(refresh-marketplace): also enable stack plugins in workspace settings

Reconcile previously stopped at `claude plugin install --scope project`,
which only writes the global plugin registry. Without an entry in the
workspace `.claude/settings.json` `enabledPlugins` map, Claude Code
treats every plugin as disabled — `/plugins` doesn't list them and
their slash commands, skills, and agents are unreachable.

Refresh now writes the enable map after install/update, treating the
user's marketplace stack as the source of truth (re-enables anything a
prior `claude plugin disable` locally turned off). Override workspaces
are skipped via `is_override_workspace`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(override): sentinel governs init only, not runtime CLI

Sentinel `.claude/init-complete` with `override: true` was meant to
let admins ship INITIAL workspace content. The implementation was
over-scoped — `is_override_workspace` check sat inside every Agnes
writer (`install_claude_hooks`, `install_claude_commands`,
`maybe_refresh_claude_hooks`, `_enable_plugins_in_workspace_settings`),
which blocked runtime commands too. Operators on override workspaces
got trapped at the template snapshot: no `enabledPlugins` map from
`agnes refresh-marketplace`, no hook auto-migration from
`agnes self-upgrade`.

Move the check to the init-time call site (cli/commands/init.py,
`if not override_active:`) — the single place where init-time skip
is the right behavior. Writers themselves become unconditional;
runtime CLI now updates `.claude/` regardless of the sentinel.

Admin custom hooks survive — refresh only rewrites entries matching
`_OUR_COMMAND_MARKERS` (foreign commands fall through unchanged,
same contract as default workspaces).

Existing override workspaces auto-converge on next
`agnes self-upgrade` (fires from every SessionStart). No manual
migration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Minas Arustamyan <arustamyan.minas@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
minasarustamyan 2026-05-14 18:43:32 +02:00 committed by GitHub
parent d63f1473ab
commit 17159bfad9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 500 additions and 86 deletions

View file

@ -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
`"<plugin>@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.<timestamp>` 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

View file

@ -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["<name>@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
`"<name>@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))
)

View file

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

View file

@ -84,19 +84,13 @@ def install_claude_hooks(workspace: Path) -> None:
Idempotent. Workspace-scoped (writes `<workspace>/.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)

View file

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

View file

@ -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.<timestamp>` | **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 (`"<plugin>@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

View file

@ -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"
)
# ===========================================================================

View file

@ -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["<name>@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)