agnes-the-ai-analyst/cli/lib/commands.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

97 lines
3.8 KiB
Python

"""Workspace-scoped Claude Code slash-command installer.
Sibling to `cli/lib/hooks.py`. Where hooks live in
`<workspace>/.claude/settings.json`, slash commands live as one
markdown file per command in `<workspace>/.claude/commands/`. This
module installs the Agnes-managed slash commands into a workspace.
Design notes:
- Workspace-scoped (`<workspace>/.claude/commands/<name>.md`), NOT
user-home. The slash commands appear only when Claude Code opens
this workspace, matching the hook scoping in `hooks.py`.
- Idempotent: always overwrites *our* files (server-managed canonical
content, naturally evolves with the CLI version) but never touches
third-party slash commands the user (or another tool) may have
authored under `.claude/commands/`. Listing files individually
rather than wiping the directory keeps custom commands safe.
- Templates ship inside the wheel under `cli/templates/commands/`.
`pyproject.toml` declares `cli` as a hatch wheel package, so
hatchling includes the markdown bodies during the build the same
way it ships `config/agnes_workspace_template.txt`.
"""
from __future__ import annotations
import sys
from pathlib import Path
# Slash commands managed by `agnes init`. Source (template name on
# disk under `cli/templates/commands/`) → destination filename in
# `<workspace>/.claude/commands/`. Today both names match; the indirection
# keeps the door open for renaming (e.g. internal template name vs the
# `/<command>` slug exposed to Claude Code).
_MANAGED_COMMANDS: tuple[tuple[str, str], ...] = (
("update-agnes-plugins.md", "update-agnes-plugins.md"),
("agnes-private.md", "agnes-private.md"),
)
# Defensive fallbacks used when the bundled template is missing on disk
# (broken install, stripped-down test environment). Keyed by source
# template filename so a missing `agnes-private.md` doesn't get
# clobbered with `update-agnes-plugins` content.
_FALLBACK_BODIES: dict[str, str] = {
"update-agnes-plugins.md": (
"---\n"
"description: Update Agnes marketplace plugins to latest versions\n"
"---\n"
"\n"
"Run `agnes refresh-marketplace` and report the output.\n"
),
"agnes-private.md": (
"---\n"
"description: Mark the current Claude Code session as private\n"
"---\n"
"\n"
"!`agnes mark-private`\n"
),
}
def _templates_dir() -> Path:
"""Locate the bundled-template directory.
`cli/lib/commands.py` → `cli/templates/commands/`.
Two `.parent` hops: lib/ → cli/, then descend into templates/commands/.
"""
return Path(__file__).parent.parent / "templates" / "commands"
def install_claude_commands(workspace: Path) -> None:
"""Install Agnes-managed slash commands into `<workspace>/.claude/commands/`.
Always writes (overwrites) the managed command files; never touches
other files the user may have under `.claude/commands/`. Idempotent.
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.
"""
commands_dir = workspace / ".claude" / "commands"
commands_dir.mkdir(parents=True, exist_ok=True)
templates_dir = _templates_dir()
for source_name, dest_name in _MANAGED_COMMANDS:
source_path = templates_dir / source_name
try:
body = source_path.read_text(encoding="utf-8")
except OSError:
print(
f"Warning: bundled slash-command template "
f"{source_path} missing; writing defensive fallback.",
file=sys.stderr,
)
body = _FALLBACK_BODIES.get(source_name, "")
(commands_dir / dest_name).write_text(body, encoding="utf-8")