* feat(initial-workspace): per-instance agnes init override Adds Initial Workspace Template — an admin-configurable per-instance override for the agnes init analyst workspace. When configured, agnes init downloads a server-rendered zip from a Git repo the admin registered and extracts it into the analyst's workspace, fully bypassing Agnes-default CLAUDE.md / settings.json / hooks / slash commands / AGNES_WORKSPACE.md. Repo layout convention: only the contents of a top-level `workspace/` subdirectory ship to analysts; admin docs (README, CI configs) at the repo root stay in the repo and never reach an analyst. Sync rejects repos without `workspace/` at root. Server side: - src/initial_workspace.py — clone (or fetch+reset), validate, build zip with strict path checks and reserved-path rejection (workspace/.claude/init-complete reserved by Agnes) - app/api/initial_workspace.py — admin CRUD + sync endpoint + analyst- facing status/zip/applied endpoints; config persists to instance.yaml overlay, PAT to .env_overlay - app/secrets.py — refactor: persist_overlay_token shared helper with threading.Lock for .env_overlay writes (closes pre-existing race between concurrent marketplaces saves) - app/web/templates/admin_server_config.html — new "Initial Workspace Template" section + modal + Sync/Edit/Delete/Download buttons (matches existing cfg-section visual language) CLI side: - cli/lib/override.py — single source of truth for is_override_workspace sentinel detection - cli/lib/initial_workspace.py — probe status, safe zip extraction with ../absolute/symlink rejection, typed-YES force confirmation - cli/commands/init.py — override branch (skips Agnes-default workspace writes); extended sentinel with override:true, template_source, template_sha so future agnes self-upgrade does not auto-refresh hooks - cli/lib/hooks.py + cli/lib/commands.py — short-circuit on override workspaces (install_claude_hooks, install_claude_commands, maybe_refresh_claude_hooks) Audit-event strategy: server writes initial_workspace.fetch_started inside GET /api/initial-workspace.zip (cannot be spoofed by PAT-holder); CLI POST /applied writes initial_workspace.applied as best-effort confirmation. Admin mutations log via the existing _audit pattern. Tests: 27 server (clone/validate/zip + workspace-subdir convention + concurrent persist_overlay_token + endpoint shapes + audit rows) + 29 CLI (override sentinel parse + probe fall-through + safe extraction + YES strictness + hook guards + e2e mocked init). Risk acceptance — documented in docs/initial-workspace-override.md + CHANGELOG Internal section so AI reviewers understand the deviations from defaults are intentional: - maybe_refresh_claude_hooks deliberately no-ops on override workspaces - --force on override does NOT back up CLAUDE.md (admin's repo is the source of truth) - .claude/CLAUDE.local.md IS overwritten by override extraction when admin's repo ships one * test+vendor-agnostic: drop Groupon tokens from #292 fixtures + extend admin-gate coverage Two fixes from the takeover review on #292: 1. **Vendor-agnostic OSS rule**: Replace `Groupon` / `groupon/template` tokens in test fixtures with `Acme` / `acme/template` (8 sites in test_cli_init_override.py + 1 in test_initial_workspace_api.py). Per CLAUDE.md "Vendor-agnostic OSS — no customer-specific content" rule: customer-specific tokens don't belong in shipped artifacts, even in test fixtures. The pre-existing FoundryAI mentions in test_instance_config.py + test_setup_instructions.py are out of scope for this PR (didn't introduce them). 2. **Admin-gate coverage gap**: `test_admin_endpoints_require_admin` only covered GET /api/admin/initial-workspace + POST .../sync. The register-write (POST .../initial-workspace) and delete (DELETE .../initial-workspace) endpoints used the same `Depends(require_admin)` wiring but had no regression test. Loop now covers all 4 verbs so a future refactor that drops the dependency from one endpoint fails here instead of silently exposing the write/delete paths to any analyst with a PAT. * release: 0.54.9 — Initial Workspace Template (per-instance agnes init override) Last commit on the PR per CLAUDE.md hard rule. Patch bump (0.54.8 → 0.54.9) for Mina's Initial Workspace Template feature. No DB migration (config lives in instance.yaml overlay). No mandatory operator action — empty default keeps OSS-default agnes init behavior. Operators wanting full template control link a Git repo on /admin/server-config → "Initial Workspace Template". See docs/initial-workspace-override.md for the full responsibility-transfer contract. --------- Co-authored-by: Minas Arustamyan <arustamyan.minas@gmail.com> Co-authored-by: ZdenekSrotyr <zdenek.srotyr@keboola.com>
103 lines
4 KiB
Python
103 lines
4 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 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.
|
|
"""
|
|
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)
|
|
|
|
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")
|