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:
parent
d63f1473ab
commit
17159bfad9
8 changed files with 500 additions and 86 deletions
45
CHANGELOG.md
45
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
|
||||
`"<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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue