agnes-the-ai-analyst/cli/lib/override.py
minasarustamyan 17159bfad9
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>
2026-05-14 18:43:32 +02:00

114 lines
4.5 KiB
Python

"""Single source of truth for "is this an override workspace?".
When the operator has configured an Initial Workspace Template on
``/admin/server-config``, ``agnes init`` extracts the admin's repo zip
into the analyst's workspace and writes an extended sentinel:
# .claude/init-complete
completed_at: 2026-05-13T14:32:00Z
agnes_version: 0.53.0
server_url: https://agnes.example.com
override: true
template_source: https://github.com/example/agnes-workspace-template
template_sha: 1a2b3c4d
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 init paths, so we keep imports cheap
(stdlib only — no YAML library).
"""
from __future__ import annotations
from pathlib import Path
from typing import Optional
# OVERRIDE MODE — init-time only.
#
# 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.
#
# 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"
def _read_sentinel(workspace: Path) -> Optional[dict]:
"""Parse the sentinel as a flat ``key: value`` map. Returns None when
the file is absent / unreadable / malformed.
The sentinel format is intentionally minimal (one key-value per
line, `key: value`) so this parser stays stdlib-only. If we ever
need nested structure we'll switch to YAML — for now the contract
fits on one screen.
"""
sentinel = workspace / _SENTINEL_PATH
if not sentinel.exists():
return None
try:
text = sentinel.read_text(encoding="utf-8")
except OSError:
return None
out: dict[str, str] = {}
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if ":" not in line:
continue
key, _, value = line.partition(":")
out[key.strip()] = value.strip()
return out
def is_override_workspace(workspace: Path) -> bool:
"""True iff ``workspace`` was inited from an admin-configured Initial
Workspace Template (the sentinel carries ``override: true``).
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:
return False
return data.get("override", "").strip().lower() == "true"
def read_override_metadata(workspace: Path) -> Optional[dict]:
"""Full sentinel contents (or None when no sentinel).
Useful for surfacing ``template_source`` / ``template_sha`` /
``applied_at`` in diagnostics (``agnes status``, ``agnes diagnose``)
so the operator can see which template version the workspace ran
last. Returns the raw key-value map without type coercion — caller
is responsible for interpreting ``override`` etc. (use
:func:`is_override_workspace` for the boolean question).
"""
return _read_sentinel(workspace)