agnes-the-ai-analyst/docs/archive/superpowers/plans/2026-05-04-clean-analyst-bootstrap.md
ZdenekSrotyr a48524509a
docs: consolidate and de-clutter the documentation tree (#306)
CLAUDE.md rewritten (708 -> ~320 lines): four overlapping release
sections collapsed to one, stale v1->v35 schema history dropped (it
lives in CHANGELOG), marketplace endpoint internals and verbose
process sections moved out or tightened.

New focused docs:
- docs/RELEASING.md - release process, deploy workflows, CI quirks
  (RELEASE_TEMPLATE.md folded in as an appendix)
- docs/marketplace.md - marketplace ingestion + re-serving internals
- docs/README.md - documentation index by audience, linked from
  README.md and CLAUDE.md

Archived under docs/archive/: docs/superpowers/ (52 historical
planning artifacts), HACKATHON.md, pd-ps-comments.md,
security-audit-2026-04.md, future/NOTIFICATIONS.md.

Removed the docs/auto-install.md stub. Fixed dangling links in
connectors/jira/README.md and dev_docs/README.md, repointed
code/doc references to archived paths.
2026-05-14 18:54:22 +00:00

3459 lines
123 KiB
Markdown

# Clean Analyst Bootstrap Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the interactive `da analyst setup` flow with a single web→paste→done bootstrap. New analyst pastes a clipboard prompt from `/setup?role=analyst` into Claude Code in an empty folder, and ends up with `CLAUDE.md`, `AGNES_WORKSPACE.md`, hooks, fresh data, and DuckDB views — fully ready to query. Drop dead workspace dirs (`data/parquet/`, `data/duckdb/`, `data/metadata/`, `user/artifacts/`). Establish a lazy-mkdir contract so nothing creates empty directories.
**Architecture:** PAT-only auth. `agnes init` is a thin orchestrator that auths, fetches `CLAUDE.md` from `/api/welcome`, installs hooks, and calls `cli/lib/pull.py:run_pull` for first data refresh. CLI verbs renamed: `init/pull/push/status/snapshot create` (greenfield, no aliases). Server-side install prompt branches on `role` query param. `cli/lib/` shared library tree separates data primitives from Typer wrappers so `agnes init` can call them without subprocess. Reader contract: every reader handles missing dirs gracefully (exit 0 empty or exit 1 with friendly hint, never traceback).
**Tech Stack:** Python 3.11+, FastAPI, Typer, Pydantic, DuckDB, httpx, pytest, Hatchling.
**Spec:** `docs/superpowers/specs/2026-05-04-clean-analyst-bootstrap-design.md` (revision 5, cleared for implementation).
**CLI rename:** As part of this plan, the binary changes from `da` to `agnes`. References to legacy commands (`da sync`, `da fetch`, `da analyst setup`, `da metrics`) keep their `da` prefix throughout this document — they're historical artifacts being removed. New commands and hook strings use `agnes`.
---
## File Structure
### New files
| Path | Responsibility |
|---|---|
| `cli/lib/__init__.py` | Empty — makes `cli/lib/` a package so Hatchling includes it in the wheel. |
| `cli/lib/pull.py` | `run_pull(server_url, token, workspace, *, dry_run) -> PullResult` — pure-function data refresh primitive (manifest, parquet download, DuckDB rebuild, memory bundle write). Lazy mkdir. |
| `cli/lib/hooks.py` | `install_claude_hooks(workspace)` — idempotent SessionStart/End hook installer for `<workspace>/.claude/settings.json`. |
| `cli/commands/init.py` | `agnes init` Typer command — auth check, save config, write CLAUDE.md, install hooks, call `run_pull`, write `AGNES_WORKSPACE.md`. |
| `cli/commands/pull.py` | `agnes pull` Typer wrapper around `cli/lib/pull.py:run_pull`. Flags `--quiet`, `--json`, `--dry-run`. |
| `cli/commands/push.py` | `agnes push` Typer command — uploads `user/sessions/*.jsonl` and `.claude/CLAUDE.local.md`. |
| `cli/commands/admin_metrics.py` | `agnes admin metrics {import,export,validate}` sub-Typer (lifted from `cli/commands/metrics.py`). |
| `config/agnes_workspace_template.txt` | Static client-side template for `AGNES_WORKSPACE.md`. Three placeholders: `{created_at}`, `{server_url}`, `{workspace_path}`. |
| `tests/fixtures/analyst_bootstrap.py` | Test fixtures: `fastapi_test_server`, `test_pat`, `test_pat_no_grants`, `zero_grants_workspace`, `web_session`. |
| `tests/test_lib_hooks.py` | Tests for `install_claude_hooks` (idempotent, preserves third-party hooks, replaces old `agnes pull`/`da sync` entries). |
| `tests/test_lib_pull.py` | Tests for `run_pull` (lazy mkdir, partial failure handling, manifest empty case). |
| `tests/test_setup_instructions_analyst.py` | Tests `render_setup_instructions(role="analyst")` produces correct steps. |
| `tests/test_tokens_bootstrap_scope.py` | Tests `scope=bootstrap-analyst` PATs are TTL-clamped to ≤ 1 h; `ttl_seconds` upper bound; `ttl_seconds` wins over `expires_in_days`. |
| `tests/test_legacy_strings_scan.py` | Tests `_scan_legacy_strings` and `legacy_strings_detected` field on `GET /api/admin/workspace-prompt-template`. |
| `tests/test_clean_install_integration.py` | End-to-end clean-install integration tests (minimal grants, zero grants, force preserves, AGNES_WORKSPACE.md content). |
| `tests/test_reader_smoke_matrix.py` | Reader smoke matrix — every CLI command on a freshly-bootstrapped zero-grants workspace, asserts no traceback. |
### Modified files
| Path | Change |
|---|---|
| `app/api/tokens.py` | `CreateTokenRequest`: add `scope: str = "general"` and `ttl_seconds: Optional[int] = None`. Validate `ttl_seconds <= 315_360_000`. Resolution: `ttl_seconds` wins; fall back to `expires_in_days`. For `scope == "bootstrap-analyst"`, force-clamp resolved TTL ≤ 3600 s. Audit-log includes scope. |
| `app/api/claude_md.py` | Add module-level `_LEGACY_STRINGS = ("data/parquet", "da sync", "da fetch", "da analyst setup", "da metrics list", "da metrics show")`. Add helper `_scan_legacy_strings(text) -> list[str]`. Add field `legacy_strings_detected: list[str] = []` to `TemplateGetResponse`. Populate in `admin_get_workspace_template`. |
| `app/web/setup_instructions.py` | Add `role: Literal["analyst","admin"] = "admin"` to `resolve_lines()` and `render_setup_instructions()`. Analyst layout: TLS trust (when `ca_pem`) → install `agnes``agnes init --server-url X --token Y --workspace .``agnes catalog` smoke verify → confirm. Drop for analyst: marketplace, plugins, skills, diagnose, login, whoami. |
| `app/web/router.py` | `setup_page`: read `role` query param (default `"admin"`), pass to `render_setup_instructions(role=...)`. |
| `app/web/templates/setup.html` (or wherever `setup_page` renders) | Two role tiles (Analyst / Admin), POST `/auth/tokens` with matching `scope`. |
| `app/web/templates/admin_workspace_prompt.html` | Yellow banner above editor when `legacy_strings_detected` non-empty. |
| `config/claude_md_template.txt` | Update verb names: `da sync``agnes pull`, `da fetch``agnes snapshot create`, `da metrics list/show``agnes catalog --metrics`, `da analyst setup``agnes init`. Path strings: `data/parquet/``server/parquet/`, `data/duckdb/...``user/duckdb/analytics.duckdb`. |
| `cli/commands/snapshot.py` | Add `create` subcommand — moves logic from `cli/commands/fetch.py` verbatim. Add `if not db_path.exists(): exit 1` guard before `duckdb.connect()`. |
| `cli/commands/catalog.py` | Add `--metrics` flag (replaces `da metrics list`); `--metrics --show <id>` (replaces `da metrics show`). |
| `cli/commands/admin.py` | Register the new `admin_metrics_app` sub-Typer. |
| `cli/commands/query.py` | Update hint text "Run: da sync" → "Run: agnes pull" in two places. |
| `cli/commands/explore.py` | Update hint text "Run: da sync" → "Run: agnes pull". |
| `cli/main.py` | Drop registrations for `sync`, `analyst`, `metrics`, `fetch`, `status` (existing). Add `init`, `pull`, `push`. Re-register `status` to point at new workspace-status command. |
| `CLAUDE.md` (repo root) | Verb + path rewrites throughout. The "Local sync & Claude Code hooks" subsection rewrites verbatim with new commands. The "Querying Agnes data — agent rails" subsection keeps the 0.32.0 `query_mode='materialized'` and `query_mode='remote'` cost-guardrail prose verbatim, just verb-renaming `da fetch``agnes snapshot create`. |
| `CHANGELOG.md` | Entry under `[Unreleased]` per spec preview (Changed/Added/Fixed/Removed/Kept). |
| `pyproject.toml` | No change; `cli/lib/__init__.py` triggers Hatchling auto-discovery. |
### Deleted files
| Path | Reason |
|---|---|
| `cli/commands/sync.py` | Replaced by `cli/commands/pull.py` + `cli/commands/push.py` + `cli/lib/pull.py`. |
| `cli/commands/fetch.py` | Folded into `cli/commands/snapshot.py:create`. |
| `cli/commands/analyst.py` | Replaced by `cli/commands/init.py` + new `cli/commands/status.py` (workspace status). `_install_claude_hooks` lifted to `cli/lib/hooks.py`. |
| `cli/commands/metrics.py` | Read paths fold into `agnes catalog --metrics`; write paths move to `cli/commands/admin_metrics.py`. |
### Existing `cli/commands/status.py` rename
| Action | Detail |
|---|---|
| Existing `agnes status` ("System status") | Renamed to `agnes diagnose system` (subcommand under `diagnose_app`) — its content is a subset of what `agnes diagnose` already does. |
| New `agnes status` | Workspace status — fresh implementation, replaces `da analyst status`. Lives in `cli/commands/status.py` (overwrite). |
---
## Phase 0 — CLI binary rename (`da` → `agnes`)
### Task 0: Rename the CLI entry point
**Files:**
- Modify: `pyproject.toml` (`[project.scripts]`), `cli/main.py` (`Typer(name=...)`)
- Test: `tests/test_cli_binary_rename.py` (new)
**Why first:** Every later task that registers Typer apps, writes hook command strings, or asserts CLI output uses `agnes`. Rename the binary up front so tests in subsequent tasks reference the right name.
- [ ] **Step 1: Read current entry points**
```bash
grep -n "scripts\|^name\|tool.hatch" pyproject.toml | head
grep -n "Typer\|name=\"da\"\|name='da'" cli/main.py
```
- [ ] **Step 2: Update `pyproject.toml`**
In `[project.scripts]`, replace `da = "cli.main:app"` with:
```toml
[project.scripts]
agnes = "cli.main:app"
```
Single entry — no `da` alias kept. Greenfield.
- [ ] **Step 3: Update `cli/main.py`**
Change the Typer app construction from `name="da"` to `name="agnes"` and update the help string:
```python
app = typer.Typer(
name="agnes",
help="Agnes — AI Data Analyst CLI",
no_args_is_help=True,
)
```
- [ ] **Step 4: Reinstall the editable package**
```bash
uv pip install -e ".[dev]"
which agnes
agnes --version
```
Expected: `agnes <version>` prints; `da --version` now fails with "command not found".
- [ ] **Step 5: Write a binary-name regression test**
```python
# tests/test_cli_binary_rename.py
"""Confirm the wheel installs the binary as `agnes`, not `da`."""
import subprocess
def test_agnes_command_exists():
result = subprocess.run(["agnes", "--version"], capture_output=True, text=True)
assert result.returncode == 0
def test_da_command_no_longer_works():
"""Greenfield: no backward-compat alias."""
result = subprocess.run(["bash", "-c", "command -v da"],
capture_output=True, text=True)
assert result.returncode != 0, "da should NOT be on PATH after rename"
```
- [ ] **Step 6: Run the test**
```bash
pytest tests/test_cli_binary_rename.py -v
```
- [ ] **Step 7: Commit**
```bash
git add pyproject.toml cli/main.py tests/test_cli_binary_rename.py
git commit -m "feat(cli): rename binary from da to agnes (BREAKING)"
```
---
## Phase 1 — Server-side foundation (PAT scope, legacy-strings scan, install-prompt branching)
### Task 1: Add `scope` + `ttl_seconds` fields to `CreateTokenRequest`
**Files:**
- Modify: `app/api/tokens.py:23-25` (`CreateTokenRequest`), `app/api/tokens.py:85-101` (`create_token` route)
- Test: `tests/test_tokens_bootstrap_scope.py` (new)
- [ ] **Step 1: Write failing tests**
```python
# tests/test_tokens_bootstrap_scope.py
"""Tests for PAT scope + ttl_seconds fields (clean-analyst-bootstrap spec)."""
from __future__ import annotations
import jwt
import pytest
@pytest.fixture
def web_session(client, db_with_admin_user):
"""Authenticated test client with session cookie for admin user."""
# Form-login endpoint — see fixtures/analyst_bootstrap.py
resp = client.post("/auth/password/login/web",
data={"email": "admin@example.com", "password": "test-password"})
assert resp.status_code in (200, 302), f"login failed: {resp.text}"
return client
def _decode(pat: str) -> dict:
return jwt.decode(pat, options={"verify_signature": False})
def test_bootstrap_pat_ttl_clamped_to_one_hour(web_session):
resp = web_session.post("/auth/tokens", json={
"name": "init",
"scope": "bootstrap-analyst",
"ttl_seconds": 86400, # 1 day — must be ignored, clamped to 3600
})
assert resp.status_code == 201, resp.text
payload = _decode(resp.json()["token"])
assert payload.get("scope") == "bootstrap-analyst"
assert payload["exp"] - payload["iat"] <= 3600 + 5
def test_general_pat_uses_ttl_seconds_when_set(web_session):
resp = web_session.post("/auth/tokens", json={
"name": "test",
"ttl_seconds": 7200, # 2 hours
})
assert resp.status_code == 201
payload = _decode(resp.json()["token"])
assert payload["exp"] - payload["iat"] <= 7200 + 5
def test_general_pat_falls_back_to_expires_in_days(web_session):
resp = web_session.post("/auth/tokens", json={
"name": "test", "expires_in_days": 30,
})
assert resp.status_code == 201
payload = _decode(resp.json()["token"])
assert payload["exp"] - payload["iat"] <= 30 * 86400 + 5
def test_ttl_seconds_upper_bound(web_session):
# 3650 days * 86400 = 315_360_000 seconds. One past this must reject.
resp = web_session.post("/auth/tokens", json={
"name": "test", "ttl_seconds": 315_360_001,
})
assert resp.status_code == 400
def test_ttl_seconds_must_be_positive(web_session):
resp = web_session.post("/auth/tokens", json={
"name": "test", "ttl_seconds": 0,
})
assert resp.status_code == 400
def test_scope_default_is_general(web_session):
resp = web_session.post("/auth/tokens", json={"name": "test"})
assert resp.status_code == 201
payload = _decode(resp.json()["token"])
# scope=general is informational; check audit_log carries it
# (skipped here — tested in test_audit_log_includes_scope below)
assert payload.get("scope", "general") == "general"
```
The `db_with_admin_user` fixture is part of the existing test suite or will be added in `tests/fixtures/analyst_bootstrap.py` (Task 22). For now, this test depends on it; if it doesn't exist, mark these as `pytest.skip` until Task 22.
- [ ] **Step 2: Run tests to verify they fail**
```bash
cd "$(git rev-parse --show-toplevel)"
pytest tests/test_tokens_bootstrap_scope.py -v
```
Expected: tests FAIL with either fixture-missing error or `extra fields not permitted` from Pydantic (if the fixture exists).
- [ ] **Step 3: Update `CreateTokenRequest` model**
Replace `app/api/tokens.py:23-25`:
```python
class CreateTokenRequest(BaseModel):
name: str
expires_in_days: Optional[int] = 90 # null = no expiry
scope: str = "general" # informational; "bootstrap-analyst" force-clamps TTL ≤ 1 h
ttl_seconds: Optional[int] = None # if set, wins over expires_in_days
```
- [ ] **Step 4: Update `create_token` route**
Replace `app/api/tokens.py:85-118` (the `create_token` function body up through the `jwt_token = create_access_token(...)` call):
```python
@router.post("", response_model=CreateTokenResponse, status_code=201)
async def create_token(
payload: CreateTokenRequest,
user: dict = Depends(require_session_token),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
if not payload.name.strip():
raise HTTPException(status_code=400, detail="name is required")
if payload.expires_in_days is not None and payload.expires_in_days <= 0:
raise HTTPException(status_code=400, detail="expires_in_days must be a positive integer")
if payload.expires_in_days is not None and payload.expires_in_days > 3650:
raise HTTPException(status_code=400, detail="expires_in_days must not exceed 3650 (10 years)")
if payload.ttl_seconds is not None and payload.ttl_seconds <= 0:
raise HTTPException(status_code=400, detail="ttl_seconds must be a positive integer")
# Mirror the 3650-day cap on ttl_seconds so a hostile client can't
# bypass via field rename. 3650 days * 86400 = 315_360_000.
if payload.ttl_seconds is not None and payload.ttl_seconds > 315_360_000:
raise HTTPException(status_code=400, detail="ttl_seconds must not exceed 315360000 (10 years)")
# Resolve TTL: ttl_seconds wins; fall back to expires_in_days.
expires_delta: Optional[timedelta] = None
omit_exp = False
if payload.ttl_seconds is not None:
expires_delta = timedelta(seconds=payload.ttl_seconds)
elif payload.expires_in_days is not None:
expires_delta = timedelta(days=payload.expires_in_days)
else:
omit_exp = True # "no expiry"
# Force-clamp bootstrap-analyst PATs to ≤ 1 h regardless of request.
if payload.scope == "bootstrap-analyst":
ONE_HOUR = timedelta(hours=1)
if expires_delta is None or expires_delta > ONE_HOUR:
expires_delta = ONE_HOUR
omit_exp = False
expires_at: Optional[datetime] = None
if expires_delta is not None:
expires_at = datetime.now(timezone.utc) + expires_delta
repo = AccessTokenRepository(conn)
token_id = str(uuid.uuid4())
jwt_token = create_access_token(
user_id=user["id"], email=user["email"],
token_id=token_id, typ="pat",
expires_delta=expires_delta, omit_exp=omit_exp,
extra_claims={"scope": payload.scope},
)
```
- [ ] **Step 5: Update `create_access_token` to accept `extra_claims`**
Find `app/auth/jwt.py:create_access_token` (read it first to get the current signature). Add an `extra_claims: dict | None = None` parameter that gets merged into the JWT payload before encoding. Show your edit:
```bash
grep -n "def create_access_token" app/auth/jwt.py
# Read the function and update.
```
If the function already supports extra claims, this is a no-op. Otherwise add:
```python
def create_access_token(
user_id: str, email: str, token_id: Optional[str] = None,
typ: str = "session", expires_delta: Optional[timedelta] = None,
omit_exp: bool = False, extra_claims: Optional[dict] = None,
) -> str:
payload = {"sub": user_id, "email": email, "typ": typ}
if token_id:
payload["jti"] = token_id
if not omit_exp:
payload["iat"] = int(datetime.now(timezone.utc).timestamp())
if expires_delta:
payload["exp"] = int((datetime.now(timezone.utc) + expires_delta).timestamp())
if extra_claims:
payload.update(extra_claims)
return jwt.encode(payload, _SECRET, algorithm="HS256")
```
(Adapt to the actual function shape after reading it.)
- [ ] **Step 6: Update audit-log entry to include scope**
Search `app/api/tokens.py` for the `_audit(...)` call inside `create_token` and add `scope` to the `params` dict:
```python
_audit(conn, actor=user["id"], action="token.create",
target=token_id,
params={"name": payload.name,
"expires_at": str(expires_at) if expires_at else None,
"scope": payload.scope})
```
- [ ] **Step 7: Run tests to verify they pass**
```bash
pytest tests/test_tokens_bootstrap_scope.py -v
```
Expected: all PASS (or skip if `db_with_admin_user` fixture doesn't yet exist; in that case the failure mode is a clear fixture-not-found error, not a logic error).
- [ ] **Step 8: Run the full token test suite to verify no regression**
```bash
pytest tests/ -k token -v
```
Expected: all token-related tests PASS.
- [ ] **Step 9: Commit**
```bash
git add app/api/tokens.py app/auth/jwt.py tests/test_tokens_bootstrap_scope.py
git commit -m "feat(tokens): add scope + ttl_seconds fields with bootstrap-analyst clamp"
```
---
### Task 2: Add `_LEGACY_STRINGS` scan to admin workspace-prompt endpoint
**Files:**
- Modify: `app/api/claude_md.py` (add `_LEGACY_STRINGS`, `_scan_legacy_strings`, augment `TemplateGetResponse`, populate in `admin_get_workspace_template`)
- Test: `tests/test_legacy_strings_scan.py` (new)
- [ ] **Step 1: Write failing tests**
```python
# tests/test_legacy_strings_scan.py
"""Tests for legacy-string scan in admin CLAUDE.md template endpoint."""
from app.api.claude_md import _scan_legacy_strings, _LEGACY_STRINGS
def test_scan_finds_all_known_legacy_strings():
text = """
Run `da sync` then `da fetch web_sessions --where ...`.
Old workspace at data/parquet/ — see `da analyst setup`.
Use `da metrics list` and `da metrics show <id>`.
"""
hits = _scan_legacy_strings(text)
assert "da sync" in hits
assert "da fetch" in hits
assert "data/parquet" in hits
assert "da analyst setup" in hits
assert "da metrics list" in hits
assert "da metrics show" in hits
def test_scan_returns_empty_for_clean_text():
text = "Use `agnes pull` to refresh, `agnes snapshot create` for ad-hoc, `server/parquet/`."
assert _scan_legacy_strings(text) == []
def test_scan_returns_unique_sorted_hits():
text = "da sync da sync data/parquet/ data/parquet/foo"
hits = _scan_legacy_strings(text)
assert hits == sorted(set(hits))
def test_legacy_strings_constant_shape():
assert isinstance(_LEGACY_STRINGS, tuple)
assert all(isinstance(s, str) for s in _LEGACY_STRINGS)
assert "da sync" in _LEGACY_STRINGS
assert "data/parquet" in _LEGACY_STRINGS
```
- [ ] **Step 2: Run tests to verify they fail**
```bash
pytest tests/test_legacy_strings_scan.py -v
```
Expected: FAIL with `ImportError: cannot import name '_scan_legacy_strings' from 'app.api.claude_md'`.
- [ ] **Step 3: Add `_LEGACY_STRINGS` and `_scan_legacy_strings` to `app/api/claude_md.py`**
Insert near the other module-level constants (after the imports, before the class definitions — find a stable location, e.g., right before `class ClaudeMdResponse`):
```python
# Substrings that, when found in an admin-saved CLAUDE.md override, signal
# the override is stale relative to the post-clean-bootstrap CLI surface.
# Surfaced via TemplateGetResponse.legacy_strings_detected so the admin UI
# can render a yellow banner prompting re-authoring.
_LEGACY_STRINGS = (
"data/parquet",
"da sync",
"da fetch",
"da analyst setup",
"da metrics list",
"da metrics show",
)
def _scan_legacy_strings(text: str) -> list[str]:
"""Return sorted unique substrings from _LEGACY_STRINGS present in text."""
return sorted({s for s in _LEGACY_STRINGS if s in text})
```
- [ ] **Step 4: Run tests to verify they pass**
```bash
pytest tests/test_legacy_strings_scan.py -v
```
Expected: all PASS.
- [ ] **Step 5: Augment `TemplateGetResponse`**
Find `class TemplateGetResponse` (around `app/api/claude_md.py:72-76`) and add the field:
```python
class TemplateGetResponse(BaseModel):
content: Optional[str]
default: str
updated_at: Optional[str] = None
updated_by: Optional[str] = None
legacy_strings_detected: list[str] = [] # populated when override contains stale verbs/paths
```
- [ ] **Step 6: Populate the field in `admin_get_workspace_template`**
Find the route (search for `admin_get_workspace_template` in `app/api/claude_md.py`). Inside the function body, before constructing the response, add:
```python
# Scan the saved override (not the live default) for legacy strings.
# A non-empty list triggers the yellow banner in the admin UI.
override_text = override.content if override else ""
legacy_hits = _scan_legacy_strings(override_text)
```
Then include `legacy_strings_detected=legacy_hits` in the `TemplateGetResponse(...)` construction.
- [ ] **Step 7: Add an HTTP test for the populated field**
Append to `tests/test_legacy_strings_scan.py`:
```python
def test_admin_get_template_returns_legacy_strings_when_override_dirty(web_session):
"""Setting an override containing legacy strings populates the field."""
web_session.put("/api/admin/workspace-prompt-template",
json={"content": "Run `da sync` and check data/parquet/."})
resp = web_session.get("/api/admin/workspace-prompt-template")
assert resp.status_code == 200
body = resp.json()
assert "da sync" in body["legacy_strings_detected"]
assert "data/parquet" in body["legacy_strings_detected"]
def test_admin_get_template_returns_empty_when_clean(web_session):
web_session.put("/api/admin/workspace-prompt-template",
json={"content": "Use `agnes pull` and check `server/parquet/`."})
resp = web_session.get("/api/admin/workspace-prompt-template")
assert resp.json()["legacy_strings_detected"] == []
```
These depend on `web_session` fixture from Task 22; mark `pytest.skip` if not yet present.
- [ ] **Step 8: Run all `claude_md` tests**
```bash
pytest tests/test_legacy_strings_scan.py tests/ -k claude_md -v
```
Expected: PASS (skip the HTTP tests if fixture missing — that's OK).
- [ ] **Step 9: Commit**
```bash
git add app/api/claude_md.py tests/test_legacy_strings_scan.py
git commit -m "feat(admin): scan CLAUDE.md override for legacy strings"
```
---
### Task 3: Add `role` parameter to `setup_instructions.py` (analyst branch)
**Files:**
- Modify: `app/web/setup_instructions.py` (add `role` parameter to `resolve_lines` and `render_setup_instructions`; add analyst-branch helper)
- Test: `tests/test_setup_instructions_analyst.py` (new)
- [ ] **Step 1: Write failing tests**
```python
# tests/test_setup_instructions_analyst.py
"""Tests for analyst-branch rendering of /setup paste prompt."""
from app.web.setup_instructions import render_setup_instructions
def test_render_analyst_role_basic():
text = render_setup_instructions(
server_url="https://agnes.example.com",
token="agnes_pat_TEST",
wheel_filename="agnes-0.32.0-py3-none-any.whl",
role="analyst",
)
# Required content for analyst role:
assert "uv tool install" in text
assert "agnes init" in text
assert "--token" in text and "agnes_pat_TEST" in text
assert "--server-url" in text and "https://agnes.example.com" in text
assert "agnes catalog" in text # smoke verify step
# Forbidden content (admin-only):
assert "marketplace" not in text
assert "claude plugin install" not in text
assert "agnes skills install" not in text # analyst doesn't bulk-install skills
assert "agnes diagnose" not in text # analyst smoke verify is `agnes catalog`, not diagnose
def test_render_admin_role_unchanged():
"""Default role=admin keeps the existing 6/8-step layout."""
text = render_setup_instructions(
server_url="https://agnes.example.com",
token="agnes_pat_TEST",
wheel_filename="agnes-0.32.0-py3-none-any.whl",
# role omitted — defaults to "admin"
)
assert "agnes auth import-token" in text # admin uses import-token, not agnes init
assert "agnes diagnose" in text # admin keeps diagnose
def test_render_analyst_with_ca_pem():
"""Analyst role + private CA → TLS trust block reused from admin path."""
text = render_setup_instructions(
server_url="https://agnes.example.com",
token="agnes_pat_TEST",
wheel_filename="agnes-0.32.0-py3-none-any.whl",
role="analyst",
ca_pem="-----BEGIN CERTIFICATE-----\nMIIBxxx\n-----END CERTIFICATE-----",
)
assert "AGNES_CA_PEM" in text # heredoc marker from trust block
assert "ca-bundle.pem" in text
assert "agnes init" in text # analyst-specific step still present
```
- [ ] **Step 2: Run tests to verify they fail**
```bash
pytest tests/test_setup_instructions_analyst.py -v
```
Expected: FAIL — `render_setup_instructions()` doesn't accept `role` parameter.
- [ ] **Step 3: Add analyst-branch helper functions**
Insert after `_install_cli_lines` (around line 311 in `setup_instructions.py`):
```python
def _analyst_init_lines(server_url_placeholder: str = "{server_url}") -> list[str]:
"""Steps 2-3 — `agnes init` (auth + workspace bootstrap) + smoke verify.
Replaces the admin-flow login + verify steps (today: `agnes auth import-token`
+ `agnes auth whoami`). `agnes init` is non-interactive: `--token` carries the PAT,
`--server-url` carries the origin. The bootstrap PAT has a 1 h TTL — if the
user takes longer than that to paste this prompt, the init call returns 401
and the user re-clicks "Generate prompt" on the install page.
"""
return [
"",
"2) Bootstrap your analyst workspace in this directory:",
f" agnes init --server-url \"{server_url_placeholder}\" --token \"{{token}}\" --workspace .",
"",
" This authenticates with the PAT, fetches your CLAUDE.md (RBAC-filtered),",
" installs Claude Code SessionStart/End hooks (auto-refresh), and runs an",
" initial `agnes pull` so your DuckDB views are ready.",
"",
"3) Verify the data is queryable:",
" agnes catalog",
"",
" This should list the tables your account has grants for. Empty list",
" means your admin hasn't granted you access yet — contact them.",
]
def _analyst_finale_lines(confirm_step_num: str, has_ca: bool) -> list[str]:
"""Final Confirm step for analyst role. Shorter than admin: no marketplace,
no plugins, no skills."""
bullets = [
" - `agnes --version` output",
" - First few lines of `agnes catalog` (tables you can see)",
" - Confirmation that `./CLAUDE.md` and `./AGNES_WORKSPACE.md` exist",
" - Confirmation that `./.claude/settings.json` contains SessionStart/End hooks",
]
if has_ca:
bullets.append(
" - Which CA bundle source got picked in step 0(d)"
)
return [
"",
f"{confirm_step_num}) Confirm:",
" Tell me \"Agnes analyst workspace is ready\" and summarize:",
*bullets,
]
```
- [ ] **Step 4: Add `role` parameter to `resolve_lines` and `render_setup_instructions`**
Find `def resolve_lines(...)` (around line 609). Modify the signature and dispatch:
```python
from typing import Literal
def resolve_lines(
wheel_filename: str,
*,
plugin_install_names: list[str] | None = None,
self_signed_tls: bool = False,
server_host: str = "",
ca_pem: str | None = None,
role: Literal["analyst", "admin"] = "admin",
) -> list[str]:
"""..."""
if role == "analyst":
return _resolve_analyst_lines(wheel_filename, ca_pem=ca_pem)
# Existing admin path:
names = list(plugin_install_names or [])
has_marketplace = bool(names)
has_ca = bool(ca_pem and ca_pem.strip())
# ... (existing body unchanged)
```
Add the new analyst dispatcher right after `resolve_lines`:
```python
def _resolve_analyst_lines(wheel_filename: str, *, ca_pem: str | None) -> list[str]:
"""Analyst workspace-bootstrap layout. Self-contained — no admin-only steps."""
has_ca = bool(ca_pem and ca_pem.strip())
confirm_step = "4" if has_ca else "4" # numbering: 0 (TLS optional), 1, 2, 3, 4
lines: list[str] = []
if has_ca:
lines.extend(_tls_trust_block(ca_pem))
lines.extend(_preamble_lines(has_ca=has_ca))
lines.extend(_install_cli_lines(has_ca=has_ca)) # step 1
lines.extend(_analyst_init_lines()) # steps 2-3
lines.extend(_analyst_finale_lines(confirm_step, has_ca=has_ca)) # step 4
return [
line.replace("{wheel_filename}", wheel_filename)
for line in lines
]
```
Update `render_setup_instructions` to accept and forward `role`:
```python
def render_setup_instructions(
server_url: str,
token: str,
wheel_filename: str = "agnes.whl",
*,
plugin_install_names: list[str] | None = None,
self_signed_tls: bool = False,
server_host: str = "",
ca_pem: str | None = None,
role: Literal["analyst", "admin"] = "admin",
) -> str:
lines = resolve_lines(
wheel_filename,
plugin_install_names=plugin_install_names,
self_signed_tls=self_signed_tls,
server_host=server_host,
ca_pem=ca_pem,
role=role,
)
text = "\n".join(lines)
return text.replace("{server_url}", server_url).replace("{token}", token)
```
- [ ] **Step 5: Run tests to verify they pass**
```bash
pytest tests/test_setup_instructions_analyst.py -v
```
Expected: all PASS.
- [ ] **Step 6: Run regression on existing setup-instruction tests**
```bash
pytest tests/ -k setup_instructions -v
```
Expected: existing admin-role tests still PASS (no regression).
- [ ] **Step 7: Commit**
```bash
git add app/web/setup_instructions.py tests/test_setup_instructions_analyst.py
git commit -m "feat(setup): add analyst role to install-prompt renderer"
```
---
### Task 4: Add `role` query branching to `/setup` route
**Files:**
- Modify: `app/web/router.py` (`setup_page` around line 717 — read `role` query param, pass to renderer)
- Test: `tests/test_setup_page_roles.py` (new)
- [ ] **Step 1: Read existing `setup_page` to understand its current shape**
```bash
grep -n "setup_page\|/setup\|/install" app/web/router.py | head
```
Read the function (~30 lines around the match) to understand its current call sites and template rendering.
- [ ] **Step 2: Write failing tests**
```python
# tests/test_setup_page_roles.py
"""Tests for /setup role query-param branching."""
def test_setup_page_default_role_is_admin(client):
resp = client.get("/setup")
assert resp.status_code == 200
# Admin tile is active; analyst tile is linked.
assert "Admin CLI" in resp.text or "role=admin" in resp.text
def test_setup_page_analyst_role(client):
resp = client.get("/setup?role=analyst")
assert resp.status_code == 200
assert "Analyst workspace" in resp.text or "role=analyst" in resp.text
def test_install_redirects_to_setup(client):
resp = client.get("/install", follow_redirects=False)
assert resp.status_code in (302, 307)
assert "/setup" in resp.headers["location"]
```
- [ ] **Step 3: Run tests to verify they fail**
```bash
pytest tests/test_setup_page_roles.py -v
```
Expected: tests for analyst/role-branching content FAIL; redirect test may PASS (existing behavior).
- [ ] **Step 4: Modify `setup_page` to read `role` query param**
Find `setup_page` in `app/web/router.py`. Update its signature to add a `role` query param and pass it to the renderer:
```python
from typing import Literal
from fastapi import Query
@router.get("/setup", response_class=HTMLResponse)
async def setup_page(
request: Request,
role: Literal["analyst", "admin"] = Query(default="admin", description="Bootstrap target role"),
# ... existing dependencies (auth, etc.)
):
"""Renders the role-specific install paste prompt."""
# ... existing context-building code ...
ctx["role"] = role
return templates.TemplateResponse(request, "setup.html", ctx)
```
If `setup_page` already calls `render_setup_instructions(...)` server-side (vs. JS-rendered), pass `role` there too:
```python
prompt_text = render_setup_instructions(
server_url=str(request.base_url).rstrip("/"),
token="{token}", # placeholder filled by JS at click time
wheel_filename=resolved_wheel,
plugin_install_names=plugin_install_names if role == "admin" else None,
self_signed_tls=...,
server_host=...,
ca_pem=...,
role=role,
)
```
- [ ] **Step 5: Update `setup.html` template to render role tiles**
Find `app/web/templates/setup.html` (or whatever `setup_page` actually renders — `grep -n "setup.html\|TemplateResponse" app/web/router.py`). Add two role tiles near the top of the body:
```html
<div class="role-tiles" style="display:flex; gap:1rem; margin-bottom:2rem;">
<a href="/setup?role=analyst"
class="role-tile {% if role == 'analyst' %}is-active{% endif %}"
style="flex:1; padding:1rem; border:2px solid {% if role == 'analyst' %}#0070f3{% else %}#ddd{% endif %}; border-radius:8px; text-decoration:none;">
<h3>Analyst workspace</h3>
<p>Bootstrap a workspace folder with CLAUDE.md, hooks, and synced data.</p>
</a>
<a href="/setup?role=admin"
class="role-tile {% if role == 'admin' %}is-active{% endif %}"
style="flex:1; padding:1rem; border:2px solid {% if role == 'admin' %}#0070f3{% else %}#ddd{% endif %}; border-radius:8px; text-decoration:none;">
<h3>Admin CLI</h3>
<p>Install the CLI, register the marketplace, set up admin tooling.</p>
</a>
</div>
```
If the template is more sophisticated (e.g., with role-specific JS), wire the JS to use the `role` ctx variable when calling `POST /auth/tokens` for PAT minting:
```javascript
const role = "{{ role }}";
const scope = role === "analyst" ? "bootstrap-analyst" : "general";
const ttlSeconds = role === "analyst" ? 3600 : 86400; // analyst short-lived
await fetch('/auth/tokens', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: `setup-${role}`, scope, ttl_seconds: ttlSeconds }),
});
```
- [ ] **Step 6: Run tests to verify they pass**
```bash
pytest tests/test_setup_page_roles.py -v
```
Expected: all PASS.
- [ ] **Step 7: Commit**
```bash
git add app/web/router.py app/web/templates/setup.html tests/test_setup_page_roles.py
git commit -m "feat(setup): /setup?role=analyst|admin branching with role tiles"
```
---
### Task 5: Add legacy-strings banner to admin workspace-prompt template UI
**Files:**
- Modify: `app/web/templates/admin_workspace_prompt.html` (add banner above editor when `legacy_strings_detected` non-empty)
- Test: `tests/test_legacy_strings_scan.py` (extend with HTML rendering test)
- [ ] **Step 1: Read existing admin-prompt template**
```bash
cat app/web/templates/admin_workspace_prompt.html
```
Find where the editor (textarea) is rendered.
- [ ] **Step 2: Write extension test**
Append to `tests/test_legacy_strings_scan.py`:
```python
def test_admin_prompt_template_renders_banner_when_legacy_present(web_session):
web_session.put("/api/admin/workspace-prompt-template",
json={"content": "Run `da sync` daily."})
resp = web_session.get("/admin/workspace-prompt")
assert resp.status_code == 200
assert "yellow" in resp.text.lower() or "warning" in resp.text.lower()
assert "da sync" in resp.text # the hit is rendered in the banner
def test_admin_prompt_template_no_banner_when_clean(web_session):
web_session.put("/api/admin/workspace-prompt-template",
json={"content": "Run `agnes pull` daily."})
resp = web_session.get("/admin/workspace-prompt")
assert resp.status_code == 200
# The banner div is absent or empty
# Implementation: e.g., id="legacy-banner" wraps the warning; check empty
assert "legacy-banner" not in resp.text or "display: none" in resp.text or len(
[l for l in resp.text.split("\n") if "legacy-banner" in l and "hidden" not in l]
) <= 2
```
(The exact test is fragile — strengthen once the implementation lands.)
- [ ] **Step 3: Run tests to verify they fail**
```bash
pytest tests/test_legacy_strings_scan.py -v -k banner
```
- [ ] **Step 4: Modify `admin_workspace_prompt.html`**
Find the spot above the editor `<textarea>` and insert:
```html
{% if legacy_strings_detected %}
<div id="legacy-banner" style="background:#fff3cd; border:1px solid #ffc107; padding:0.75rem 1rem; border-radius:4px; margin-bottom:1rem;">
<strong>⚠ This override references CLI verbs / paths that were renamed:</strong>
<ul style="margin:0.5rem 0 0 1.5rem;">
{% for hit in legacy_strings_detected %}
<li><code>{{ hit }}</code></li>
{% endfor %}
</ul>
<p style="margin:0.5rem 0 0 0;">Re-author and Save to clear this warning. See CHANGELOG for the rename list.</p>
</div>
{% endif %}
```
In the route that renders this template (find via `grep -n "admin_workspace_prompt\.html" app/web/router.py app/web/admin_router.py`), pass `legacy_strings_detected` into the context. The data comes from the same `_scan_legacy_strings(override_text)` call as the API — DRY by importing from `app.api.claude_md`:
```python
from app.api.claude_md import _scan_legacy_strings
@router.get("/admin/workspace-prompt", response_class=HTMLResponse)
async def admin_workspace_prompt_page(...):
override = repo.get_workspace_prompt_template()
ctx = {
"override_content": override.content if override else "",
"legacy_strings_detected": _scan_legacy_strings(override.content) if override else [],
# ... rest of existing context
}
return templates.TemplateResponse(request, "admin_workspace_prompt.html", ctx)
```
- [ ] **Step 5: Run tests to verify they pass**
```bash
pytest tests/test_legacy_strings_scan.py -v
```
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add app/web/templates/admin_workspace_prompt.html app/web/router.py tests/test_legacy_strings_scan.py
git commit -m "feat(admin): yellow banner for legacy CLI verbs in workspace-prompt override"
```
---
### Task 6: Update `config/claude_md_template.txt` (server-side rendered to `/api/welcome`)
**Files:**
- Modify: `config/claude_md_template.txt` (verb + path rewrites)
- [ ] **Step 1: Read the current template**
```bash
cat config/claude_md_template.txt | head -100
wc -l config/claude_md_template.txt
```
- [ ] **Step 2: Apply systematic rewrites**
Replace throughout the file:
- `da sync``agnes pull` (everywhere)
- `da analyst setup``agnes init` (everywhere)
- `da fetch``agnes snapshot create`
- `da metrics list``agnes catalog --metrics`
- `da metrics show``agnes catalog --metrics --show`
- `data/parquet/``server/parquet/`
- `data/duckdb/``user/duckdb/`
- `data/metadata/` → (delete references; the path no longer exists)
Use `sed`:
```bash
sed -i.bak \
-e 's|da sync --upload-only|agnes push|g' \
-e 's|da sync|agnes pull|g' \
-e 's|da analyst setup|agnes init|g' \
-e 's|da fetch|agnes snapshot create|g' \
-e 's|da metrics list|agnes catalog --metrics|g' \
-e 's|da metrics show|agnes catalog --metrics --show|g' \
-e 's|data/parquet/|server/parquet/|g' \
-e 's|data/duckdb/|user/duckdb/|g' \
config/claude_md_template.txt
rm config/claude_md_template.txt.bak
```
- [ ] **Step 3: Read the result and review for any leftover legacy strings**
```bash
grep -nE 'da sync|da fetch|da analyst|da metrics list|da metrics show|data/parquet|data/duckdb|data/metadata' config/claude_md_template.txt
```
Expected: no matches.
- [ ] **Step 4: Add a top-of-file pointer to AGNES_WORKSPACE.md**
Insert near the top of the rendered template (e.g., after the `# {instance_name}` heading):
```markdown
> Looking for human-readable workspace docs? Open `AGNES_WORKSPACE.md` in this directory — that file documents what `agnes init` installed, where files live, and how to uninstall.
```
- [ ] **Step 5: Render the template via `/api/welcome` (manual smoke)**
(Defer the real test to Task 27 — clean-install integration.)
- [ ] **Step 6: Commit**
```bash
git add config/claude_md_template.txt
git commit -m "docs(claude-md-template): rewrite verbs + paths for new CLI surface"
```
---
## Phase 2 — Client-side library (`cli/lib/`)
### Task 7: Establish `cli/lib/` package + `install_claude_hooks`
**Files:**
- Create: `cli/lib/__init__.py`, `cli/lib/hooks.py`
- Test: `tests/test_lib_hooks.py` (new)
- [ ] **Step 1: Write failing tests**
```python
# tests/test_lib_hooks.py
"""Tests for cli/lib/hooks.py:install_claude_hooks."""
import json
from pathlib import Path
import pytest
from cli.lib.hooks import install_claude_hooks
def _read_settings(workspace: Path) -> dict:
return json.loads((workspace / ".claude" / "settings.json").read_text())
def test_install_creates_settings_file(tmp_path):
install_claude_hooks(tmp_path)
cfg = _read_settings(tmp_path)
assert cfg["hooks"]["SessionStart"]
assert "agnes pull --quiet" in cfg["hooks"]["SessionStart"][0]["hooks"][0]["command"]
assert cfg["hooks"]["SessionEnd"]
assert "agnes push --quiet" in cfg["hooks"]["SessionEnd"][0]["hooks"][0]["command"]
def test_install_idempotent(tmp_path):
install_claude_hooks(tmp_path)
install_claude_hooks(tmp_path)
cfg = _read_settings(tmp_path)
# Only ONE entry per event after second install (not duplicated)
assert len(cfg["hooks"]["SessionStart"]) == 1
assert len(cfg["hooks"]["SessionEnd"]) == 1
def test_install_replaces_old_da_sync_entries(tmp_path):
"""Hook from a pre-rewrite workspace gets replaced cleanly."""
settings_path = tmp_path / ".claude" / "settings.json"
settings_path.parent.mkdir(parents=True)
settings_path.write_text(json.dumps({
"hooks": {
"SessionStart": [{"hooks": [{"type": "command", "command": "da sync --quiet"}]}],
"SessionEnd": [{"hooks": [{"type": "command", "command": "da sync --upload-only --quiet"}]}],
}
}))
install_claude_hooks(tmp_path)
cfg = _read_settings(tmp_path)
assert len(cfg["hooks"]["SessionStart"]) == 1
assert "agnes pull" in cfg["hooks"]["SessionStart"][0]["hooks"][0]["command"]
assert "da sync" not in cfg["hooks"]["SessionStart"][0]["hooks"][0]["command"]
def test_install_preserves_third_party_hooks(tmp_path):
settings_path = tmp_path / ".claude" / "settings.json"
settings_path.parent.mkdir(parents=True)
settings_path.write_text(json.dumps({
"hooks": {
"SessionStart": [{"hooks": [{"type": "command", "command": "echo hi from another tool"}]}],
"PreToolUse": [{"hooks": [{"type": "command", "command": "echo pre"}]}],
}
}))
install_claude_hooks(tmp_path)
cfg = _read_settings(tmp_path)
# Third-party SessionStart entry survives; our agnes pull entry appended
starts = cfg["hooks"]["SessionStart"]
assert any("echo hi from another tool" in s["hooks"][0]["command"] for s in starts)
assert any("agnes pull" in s["hooks"][0]["command"] for s in starts)
# PreToolUse untouched
assert cfg["hooks"]["PreToolUse"][0]["hooks"][0]["command"] == "echo pre"
def test_install_handles_missing_settings_file(tmp_path):
"""No prior settings.json → create from scratch."""
install_claude_hooks(tmp_path)
assert (tmp_path / ".claude" / "settings.json").exists()
def test_install_handles_invalid_json(tmp_path, capsys):
"""Invalid existing settings.json → warn, skip."""
settings_path = tmp_path / ".claude" / "settings.json"
settings_path.parent.mkdir(parents=True)
settings_path.write_text("not valid json {")
# Should not raise; should print a warning
install_claude_hooks(tmp_path)
captured = capsys.readouterr()
assert "not valid JSON" in captured.err or "warning" in captured.err.lower()
```
- [ ] **Step 2: Run tests to verify they fail**
```bash
pytest tests/test_lib_hooks.py -v
```
Expected: `ImportError``cli.lib` doesn't exist.
- [ ] **Step 3: Create `cli/lib/__init__.py`**
```bash
touch cli/lib/__init__.py
```
- [ ] **Step 4: Create `cli/lib/hooks.py`**
```python
# cli/lib/hooks.py
"""Workspace-scoped Claude Code hook installer.
Replaces the in-place `_install_claude_hooks` from `cli/commands/analyst.py`
(deleted as part of the clean-analyst-bootstrap rewrite). Splits hook
installation into a pure-function library so `agnes init` and any future caller
can use it without dragging in the deleted command module.
Design notes:
- Workspace-scoped (`<workspace>/.claude/settings.json`), NOT user-home.
The hooks fire only when Claude Code opens this workspace.
- Idempotent: second invocation drops a prior `agnes pull` / `da sync` /
`agnes push` entry (matched by command substring) and appends fresh entries.
Third-party hooks (mixed entries, foreign commands) are left alone.
- Uses `\\| true` so the hook never blocks a session on a transient sync error.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
_OUR_COMMAND_MARKERS = ("agnes pull", "agnes push", "da sync")
def install_claude_hooks(workspace: Path) -> None:
"""Install SessionStart→`agnes pull` and SessionEnd→`agnes push` hooks.
Idempotent. Workspace-scoped (writes `<workspace>/.claude/settings.json`).
Preserves third-party hooks and other event types.
"""
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 json.JSONDecodeError:
print(
f"Warning: {settings_path} is not valid JSON; skipping hook install.",
file=sys.stderr,
)
return
else:
cfg = {}
hooks = cfg.setdefault("hooks", {})
def _replace_or_add(event: str, command: str) -> None:
existing = hooks.setdefault(event, [])
# Drop any prior entry whose every hook command matches one of our
# markers. Mixed entries (third-party + ours) are left alone.
for entry in list(existing):
entry_cmds = [h.get("command", "") for h in entry.get("hooks", [])]
if entry_cmds and all(
any(marker in c for marker in _OUR_COMMAND_MARKERS) for c in entry_cmds
):
existing.remove(entry)
existing.append({"hooks": [{"type": "command", "command": command}]})
_replace_or_add("SessionStart", "agnes pull --quiet 2>/dev/null || true")
_replace_or_add("SessionEnd", "agnes push --quiet 2>/dev/null || true")
settings_path.write_text(json.dumps(cfg, indent=2) + "\n", encoding="utf-8")
```
- [ ] **Step 5: Run tests to verify they pass**
```bash
pytest tests/test_lib_hooks.py -v
```
Expected: all PASS.
- [ ] **Step 6: Commit**
```bash
git add cli/lib/__init__.py cli/lib/hooks.py tests/test_lib_hooks.py
git commit -m "feat(cli-lib): cli/lib/hooks.py:install_claude_hooks"
```
---
### Task 8: `cli/lib/pull.py:run_pull` — extract data-refresh primitive from `sync.py`
**Files:**
- Create: `cli/lib/pull.py`
- Test: `tests/test_lib_pull.py` (new)
- [ ] **Step 1: Read `cli/commands/sync.py` to identify the function body to lift**
```bash
wc -l cli/commands/sync.py
grep -n "^def \|^class " cli/commands/sync.py
```
Identify:
- The Typer command function (e.g., `sync()` decorated with `@sync_app.command()`)
- The helper functions called from it: `_rebuild_duckdb_views`, `_fetch_and_write_rules`, `_is_valid_parquet`, etc.
- [ ] **Step 2: Write failing tests**
```python
# tests/test_lib_pull.py
"""Tests for cli/lib/pull.py:run_pull."""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import patch, MagicMock
import pytest
from cli.lib.pull import run_pull, PullResult
@pytest.fixture
def fake_server(monkeypatch):
"""Mock api_get to return canned manifest + memory bundle."""
canned = {
"/api/sync/manifest": {"tables": []},
"/api/memory/bundle": {"mandatory": [], "approved": []},
}
def _api_get(path, *args, **kwargs):
resp = MagicMock()
resp.status_code = 200
body = canned.get(path, {})
resp.json.return_value = body
resp.iter_bytes = lambda chunk_size=65536: iter([b""])
resp.raise_for_status = lambda: None
return resp
monkeypatch.setattr("cli.lib.pull.api_get", _api_get, raising=False)
return canned
def test_run_pull_empty_manifest_no_parquet_dir(tmp_path, fake_server):
result = run_pull(server_url="http://x", token="t", workspace=tmp_path)
assert isinstance(result, PullResult)
assert result.tables_updated == 0
assert not (tmp_path / "server" / "parquet").exists(), \
"lazy mkdir: empty manifest must not create server/parquet/"
def test_run_pull_empty_memory_no_rules_dir(tmp_path, fake_server):
run_pull(server_url="http://x", token="t", workspace=tmp_path)
assert not (tmp_path / ".claude" / "rules").exists(), \
"lazy mkdir: empty bundle must not create .claude/rules/"
def test_run_pull_creates_duckdb_unconditionally(tmp_path, fake_server):
"""Even with zero data, the DuckDB file is opened (it's the load-bearing
artifact and other readers expect its parent dir to exist)."""
run_pull(server_url="http://x", token="t", workspace=tmp_path)
assert (tmp_path / "user" / "duckdb" / "analytics.duckdb").exists()
def test_run_pull_with_one_table(tmp_path, monkeypatch):
"""Manifest with one table → server/parquet/ created, parquet downloaded."""
canned_manifest = {"tables": [{"id": "tbl1", "md5": "abc"}]}
canned_memory = {"mandatory": [], "approved": []}
parquet_bytes = b"PAR1" + b"\x00" * 1000 + b"PAR1" # minimal valid parquet shape
def _api_get(path, *args, **kwargs):
resp = MagicMock()
resp.status_code = 200
if path == "/api/sync/manifest":
resp.json.return_value = canned_manifest
elif path == "/api/memory/bundle":
resp.json.return_value = canned_memory
elif path.startswith("/api/data/tbl1/download"):
resp.iter_bytes = lambda chunk_size=65536: iter([parquet_bytes])
return resp
monkeypatch.setattr("cli.lib.pull.api_get", _api_get, raising=False)
monkeypatch.setattr("cli.lib.pull._is_valid_parquet", lambda p: True, raising=False)
result = run_pull(server_url="http://x", token="t", workspace=tmp_path)
assert (tmp_path / "server" / "parquet").exists()
assert (tmp_path / "server" / "parquet" / "tbl1.parquet").exists()
assert result.tables_updated == 1
def test_run_pull_dry_run_writes_nothing(tmp_path, fake_server):
run_pull(server_url="http://x", token="t", workspace=tmp_path, dry_run=True)
assert not (tmp_path / "server").exists()
assert not (tmp_path / "user" / "duckdb").exists()
```
- [ ] **Step 3: Run tests to verify they fail**
```bash
pytest tests/test_lib_pull.py -v
```
Expected: ImportError on `cli.lib.pull`.
- [ ] **Step 4: Create `cli/lib/pull.py`**
Lift the body of today's `cli/commands/sync.py:sync()` into a pure function. Specifically:
- Move `_rebuild_duckdb_views`, `_fetch_and_write_rules`, `_is_valid_parquet` (private helpers) into `cli/lib/pull.py`.
- Drop Typer decorators and `typer.echo` calls — replace with returning structured result.
- Apply lazy-mkdir fixes:
- `_fetch_and_write_rules`: check `mandatory + approved` non-empty before mkdir.
- Per-table download loop: mkdir `server/parquet/` inside the loop, only when about to write.
```python
# cli/lib/pull.py
"""Pure-function data-refresh primitive — used by `agnes pull` and `agnes init`.
Extracted from `cli/commands/sync.py` (deleted in the clean-bootstrap rewrite).
This module has no Typer dependency, no stdout side effects, no exit calls.
Callers decide what to print and how to handle errors.
Lazy-mkdir contract:
- `<workspace>/server/parquet/` — created only when the manifest has at least one
table to download. Empty manifest → directory is never created.
- `<workspace>/.claude/rules/` — created only when `/api/memory/bundle` returns
at least one mandatory or approved item. Empty bundle → directory absent.
- `<workspace>/user/duckdb/analytics.duckdb` — created unconditionally. The DB
file (not just the dir) is the load-bearing artifact every reader expects.
"""
from __future__ import annotations
import hashlib
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
from cli.client import api_get
_SAFE_ID_RE = re.compile(r"^[a-zA-Z0-9_\-]+$")
@dataclass
class PullResult:
tables_updated: int = 0
parquets_total: int = 0
rules_count: int = 0
duration_s: float = 0.0
errors: list[str] = field(default_factory=list)
def run_pull(
server_url: str,
token: str,
workspace: Path,
*,
dry_run: bool = False,
) -> PullResult:
"""Refresh registered data into <workspace>/server/parquet + user/duckdb.
Args:
server_url: Base URL of the Agnes server.
token: PAT for `Authorization: Bearer <token>`.
workspace: Local workspace root. Lazy-mkdir applies inside this dir.
dry_run: If True, computes deltas but writes nothing to disk.
Returns:
PullResult with summary counts.
"""
import time
start = time.time()
result = PullResult()
# 1. Fetch RBAC-filtered manifest
resp = api_get("/api/sync/manifest", server_url=server_url, token=token)
resp.raise_for_status()
manifest = resp.json()
tables = manifest.get("tables", [])
# 2. Per-table download with lazy mkdir
parquet_dir = workspace / "server" / "parquet"
for tbl in tables:
tbl_id = tbl.get("id", "")
if not _SAFE_ID_RE.match(tbl_id):
result.errors.append(f"unsafe table id skipped: {tbl_id!r}")
continue
target = parquet_dir / f"{tbl_id}.parquet"
# Skip if local md5 matches remote
remote_md5 = tbl.get("md5", "")
if target.exists() and _file_md5(target) == remote_md5:
continue
if dry_run:
result.tables_updated += 1
continue
# Lazy mkdir: only when about to write the FIRST parquet.
parquet_dir.mkdir(parents=True, exist_ok=True)
try:
stream_resp = api_get(
f"/api/data/{tbl_id}/download", server_url=server_url, token=token, stream=True,
)
stream_resp.raise_for_status()
with open(target, "wb") as fh:
for chunk in stream_resp.iter_bytes(chunk_size=65536):
fh.write(chunk)
if not _is_valid_parquet(target):
target.unlink()
result.errors.append(f"{tbl_id}: invalid parquet (missing PAR1)")
continue
result.tables_updated += 1
except Exception as exc:
if target.exists():
target.unlink()
result.errors.append(f"{tbl_id}: {exc}")
# 3. Rebuild DuckDB views
if not dry_run:
_rebuild_duckdb_views(workspace, parquet_dir)
result.parquets_total = len(list(parquet_dir.glob("*.parquet"))) if parquet_dir.exists() else 0
# 4. Fetch corporate-memory bundle (lazy mkdir for .claude/rules/)
if not dry_run:
result.rules_count = _fetch_and_write_rules(workspace, server_url, token)
result.duration_s = time.time() - start
return result
def _file_md5(path: Path) -> str:
h = hashlib.md5()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(8192), b""):
h.update(chunk)
return h.hexdigest()
def _is_valid_parquet(path: Path) -> bool:
"""Cheap structural check — parquet files begin and end with `PAR1`."""
try:
with open(path, "rb") as fh:
head = fh.read(4)
fh.seek(-4, 2)
tail = fh.read(4)
return head == b"PAR1" and tail == b"PAR1"
except OSError:
return False
def _rebuild_duckdb_views(workspace: Path, parquet_dir: Path) -> None:
import duckdb
db_path = workspace / "user" / "duckdb" / "analytics.duckdb"
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = duckdb.connect(str(db_path))
try:
# Drop all existing views
views = conn.execute(
"SELECT table_name FROM information_schema.tables WHERE table_type='VIEW'"
).fetchall()
for (view_name,) in views:
conn.execute(f'DROP VIEW IF EXISTS "{view_name}"')
# Recreate from parquets (if any)
if parquet_dir.exists():
for pq_file in parquet_dir.glob("*.parquet"):
view_name = pq_file.stem
if not _SAFE_ID_RE.match(view_name):
continue
if not _is_valid_parquet(pq_file):
continue
abs_path = str(pq_file.resolve()).replace("'", "''")
try:
conn.execute(
f'CREATE VIEW "{view_name}" AS SELECT * FROM read_parquet(\'{abs_path}\')'
)
except duckdb.Error:
continue
finally:
conn.close()
def _fetch_and_write_rules(workspace: Path, server_url: str, token: str) -> int:
"""Fetch /api/memory/bundle and write .claude/rules/km_*.md files.
Lazy mkdir: only creates `<workspace>/.claude/rules/` if the bundle is non-empty.
Returns the number of rule files written.
"""
rules_dir = workspace / ".claude" / "rules"
try:
resp = api_get("/api/memory/bundle", server_url=server_url, token=token)
resp.raise_for_status()
bundle = resp.json()
except Exception:
return 0
items = list(bundle.get("mandatory", [])) + list(bundle.get("approved", []))
if not items:
return 0 # no mkdir, nothing to write
rules_dir.mkdir(parents=True, exist_ok=True)
written = 0
for item in items:
item_id = item.get("id", "")
if not _SAFE_ID_RE.match(item_id):
continue
fname = f"km_{item_id}.md"
body = _item_to_md(item)
(rules_dir / fname).write_text(body, encoding="utf-8")
written += 1
return written
def _item_to_md(item: dict) -> str:
title = item.get("title", "")
body = item.get("body", "")
return f"# {title}\n\n{body}\n"
```
(If `cli/client.py:api_get` doesn't accept the `server_url`/`token`/`stream` kwargs as shown, adapt the calls to the actual signature.)
- [ ] **Step 5: Run tests to verify they pass**
```bash
pytest tests/test_lib_pull.py -v
```
Expected: all PASS.
- [ ] **Step 6: Commit**
```bash
git add cli/lib/pull.py tests/test_lib_pull.py
git commit -m "feat(cli-lib): cli/lib/pull.py:run_pull primitive with lazy mkdir"
```
---
## Phase 3 — New CLI commands
### Task 9: `agnes pull` Typer wrapper
**Files:**
- Create: `cli/commands/pull.py`
- Test: `tests/test_cli_pull.py` (new)
- [ ] **Step 1: Write failing test**
```python
# tests/test_cli_pull.py
"""Tests for `agnes pull` Typer wrapper."""
from typer.testing import CliRunner
from cli.commands.pull import pull_app
runner = CliRunner()
def test_pull_help():
result = runner.invoke(pull_app, ["--help"])
assert result.exit_code == 0
assert "--quiet" in result.output
assert "--json" in result.output
assert "--dry-run" in result.output
```
- [ ] **Step 2: Run test to verify it fails**
```bash
pytest tests/test_cli_pull.py -v
```
Expected: ImportError.
- [ ] **Step 3: Create `cli/commands/pull.py`**
```python
# cli/commands/pull.py
"""`agnes pull` — refresh registered data into the workspace.
Thin Typer wrapper around `cli/lib/pull.py:run_pull`. Used by:
- Manual invocation: analyst types `agnes pull` to force a refresh.
- SessionStart hook: `agnes pull --quiet 2>/dev/null || true` runs at the start
of every Claude Code session in this workspace.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
import typer
from cli.config import get_server_url, load_token
from cli.error_render import render_error
from cli.lib.pull import run_pull, PullResult
pull_app = typer.Typer(help="Refresh registered data from the server")
@pull_app.callback(invoke_without_command=True)
def pull(
quiet: bool = typer.Option(False, "--quiet", help="Suppress success stdout"),
as_json: bool = typer.Option(False, "--json", help="Machine-readable output"),
dry_run: bool = typer.Option(False, "--dry-run", help="Compute deltas without writing"),
):
"""Refresh data from the server into ./server/parquet + ./user/duckdb."""
server_url = get_server_url()
if not server_url:
typer.echo(render_error(0, {"detail": {
"kind": "server_unreachable",
"hint": "No server configured. Run: agnes init --server-url <URL> --token <PAT>",
}}), err=True)
raise typer.Exit(1)
token = load_token()
if not token:
typer.echo(render_error(0, {"detail": {
"kind": "auth_failed",
"hint": "No token. Run: agnes auth import-token --token <PAT>",
}}), err=True)
raise typer.Exit(1)
workspace = Path(os.environ.get("DA_LOCAL_DIR", ".")).resolve()
try:
result: PullResult = run_pull(server_url, token, workspace, dry_run=dry_run)
except Exception as exc:
typer.echo(render_error(0, {"detail": {
"kind": "manifest_unauthorized",
"hint": f"Pull failed: {exc}",
"message": str(exc),
}}), err=True)
raise typer.Exit(1)
if as_json:
typer.echo(json.dumps({
"tables_updated": result.tables_updated,
"parquets_total": result.parquets_total,
"rules_count": result.rules_count,
"duration_s": round(result.duration_s, 3),
"errors": result.errors,
}))
return
if quiet:
if result.errors:
for e in result.errors:
typer.echo(f"warn: {e}", err=True)
return
typer.echo(f"Updated {result.tables_updated} tables ({result.parquets_total} total).")
typer.echo(f"Rules: {result.rules_count}.")
if result.errors:
for e in result.errors:
typer.echo(f"warn: {e}", err=True)
```
- [ ] **Step 4: Run test to verify it passes**
```bash
pytest tests/test_cli_pull.py -v
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add cli/commands/pull.py tests/test_cli_pull.py
git commit -m "feat(cli): agnes pull command (Typer wrapper around lib.pull.run_pull)"
```
---
### Task 10: `agnes push` command (extract from `da sync --upload-only`)
**Files:**
- Create: `cli/commands/push.py`
- Test: `tests/test_cli_push.py` (new)
- [ ] **Step 1: Write failing tests**
```python
# tests/test_cli_push.py
from pathlib import Path
from typer.testing import CliRunner
from cli.commands.push import push_app
runner = CliRunner()
def test_push_help():
result = runner.invoke(push_app, ["--help"])
assert result.exit_code == 0
assert "--quiet" in result.output
assert "--json" in result.output
def test_push_no_sessions_no_mkdir(tmp_path, monkeypatch):
"""Empty workspace → push exits 0, doesn't create user/sessions/."""
monkeypatch.setenv("DA_LOCAL_DIR", str(tmp_path))
monkeypatch.setattr("cli.commands.push.get_server_url", lambda: "http://x")
monkeypatch.setattr("cli.commands.push.load_token", lambda: "test-pat")
result = runner.invoke(push_app, ["--quiet"])
assert result.exit_code == 0
assert not (tmp_path / "user" / "sessions").exists()
```
- [ ] **Step 2: Run test to verify it fails**
```bash
pytest tests/test_cli_push.py -v
```
- [ ] **Step 3: Create `cli/commands/push.py`**
```python
# cli/commands/push.py
"""`agnes push` — upload local sessions and CLAUDE.local.md to the server.
Extracted from today's `da sync --upload-only`. Hook command:
`agnes push --quiet 2>/dev/null || true` (runs at SessionEnd).
"""
from __future__ import annotations
import json
import os
from pathlib import Path
import typer
from cli.client import api_post
from cli.config import get_server_url, load_token
from cli.error_render import render_error
push_app = typer.Typer(help="Upload local sessions and notes to the server")
@push_app.callback(invoke_without_command=True)
def push(
quiet: bool = typer.Option(False, "--quiet", help="Suppress stdout"),
as_json: bool = typer.Option(False, "--json", help="Machine-readable output"),
dry_run: bool = typer.Option(False, "--dry-run", help="List what would upload, don't send"),
):
server_url = get_server_url()
token = load_token()
if not server_url or not token:
typer.echo(render_error(0, {"detail": {
"kind": "auth_failed",
"hint": "No server/token configured. Run: agnes init",
}}), err=True)
raise typer.Exit(1)
workspace = Path(os.environ.get("DA_LOCAL_DIR", ".")).resolve()
sessions_dir = workspace / "user" / "sessions"
local_md = workspace / ".claude" / "CLAUDE.local.md"
sessions = []
if sessions_dir.exists():
sessions = sorted(sessions_dir.glob("*.jsonl"))
has_local_md = local_md.exists()
summary = {"sessions_count": len(sessions), "local_md": has_local_md, "uploaded": 0}
if dry_run:
if as_json:
typer.echo(json.dumps(summary))
else:
typer.echo(f"Would upload: {len(sessions)} sessions, local_md={has_local_md}")
return
for session_file in sessions:
try:
with open(session_file, "rb") as fh:
resp = api_post("/api/upload/sessions", files={"file": (session_file.name, fh)})
if resp.status_code == 200:
summary["uploaded"] += 1
except Exception as exc:
if not quiet:
typer.echo(f"warn: failed to upload {session_file.name}: {exc}", err=True)
if has_local_md:
try:
with open(local_md, "rb") as fh:
api_post("/api/upload/local-md", files={"file": (local_md.name, fh)})
except Exception as exc:
if not quiet:
typer.echo(f"warn: failed to upload CLAUDE.local.md: {exc}", err=True)
if as_json:
typer.echo(json.dumps(summary))
elif not quiet:
typer.echo(f"Uploaded {summary['uploaded']} sessions, local_md={has_local_md}")
```
- [ ] **Step 4: Run tests to verify they pass**
```bash
pytest tests/test_cli_push.py -v
```
- [ ] **Step 5: Commit**
```bash
git add cli/commands/push.py tests/test_cli_push.py
git commit -m "feat(cli): agnes push command (extracted from sync --upload-only)"
```
---
### Task 11: `agnes init` — workspace bootstrap orchestrator
**Files:**
- Create: `cli/commands/init.py`, `config/agnes_workspace_template.txt`
- Test: `tests/test_cli_init.py` (new)
- [ ] **Step 1: Write the AGNES_WORKSPACE.md template**
Create `config/agnes_workspace_template.txt`:
```markdown
# Agnes analyst workspace
**Created:** {created_at}
**Server:** {server_url}
**Workspace:** {workspace_path}
This file documents what `agnes init` installed on this machine and in this folder.
Read this when you want to know "what is this thing", "how does it work", or
"how do I uninstall it". For Claude Code's instructions, see `CLAUDE.md`.
---
## What's installed (global, per-user)
| Path | What it is | How to remove |
|------|------------|---------------|
| `~/.local/bin/agnes` | The `agnes` CLI binary | `uv tool uninstall agnes-the-ai-analyst` |
| `~/.config/da/config.yaml` | Default Agnes server URL | `rm -rf ~/.config/da/` |
| `~/.config/da/token.json` | Personal access token (PAT) | `rm ~/.config/da/token.json` |
| `~/.agnes/ca.pem` | Server's CA cert (private CA installs only) | `rm ~/.agnes/ca.pem` |
| `~/.agnes/ca-bundle.pem` | Combined system + Agnes CA bundle | `rm ~/.agnes/ca-bundle.pem` |
| `~/.zshrc` / `~/.bashrc` block (marker `AGNES_CA_PEM_TRUST`) | `PATH` + `SSL_CERT_FILE` env | Edit rc, remove block |
---
## What's in this folder
| Path | What it is |
|------|------------|
| `./CLAUDE.md` | Rules + golden path for Claude Code (fetched from server's `/api/welcome`) |
| `./AGNES_WORKSPACE.md` | This file |
| `./.claude/settings.json` | Claude Code config: model, permissions, hooks |
| `./.claude/CLAUDE.local.md` | Your private notes (uploaded on session end) |
| `./.claude/rules/km_*.md` | Server-pushed corporate-knowledge rules (only when granted) |
| `./server/parquet/*.parquet` | Synced data — RBAC-filtered subset (only when grants exist) |
| `./user/duckdb/analytics.duckdb` | DuckDB views over the parquets — what `agnes query` reads |
| `./user/snapshots/*.parquet` | Ad-hoc materialized snapshots from `agnes snapshot create` |
| `./user/sessions/*.jsonl` | Captured Claude Code sessions (uploaded on session end) |
Some folders only exist when they have content — `agnes pull` and `agnes push`
only create them when there's something to write.
---
## How it stays fresh
Two hooks in `./.claude/settings.json` keep this workspace in sync without
you doing anything:
- **SessionStart** → `agnes pull --quiet` — new parquets, schema changes, and
updated rules pull down before Claude Code answers. Failure is silent;
your session continues with the last-known data.
- **SessionEnd** → `agnes push --quiet` — your session transcript and
`CLAUDE.local.md` ship to the server.
Both are workspace-scoped — they only run when Claude Code opens this folder.
---
## Cheat sheet
```bash
# Tables you can read (server-side catalog, RBAC-filtered)
agnes catalog
agnes catalog --json | jq '.[] | select(.query_mode=="local")'
# Schema and sample
agnes schema opportunity
agnes describe opportunity -n 10
# Run a SQL query (DuckDB flavor against local parquets)
agnes query "SELECT count(*) FROM opportunity WHERE stage='Closed Won'"
# Remote BigQuery query (server-side, no local materialization)
agnes query --remote "SELECT count(*) FROM web_sessions_example"
# Materialize a remote subset locally
agnes snapshot create web_sessions_example \
--select event_date,country_code \
--where "event_date >= DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY)" \
--as recent_sessions
# Manual data refresh (the SessionStart hook does this automatically)
agnes pull
# Workspace status (what's synced, when)
agnes status
# Re-generate this workspace from scratch (preserves CLAUDE.local.md)
agnes init --server-url https://agnes.example.com --token <PAT> --force
```
---
## Uninstall
```bash
# 1. Remove the CLI
uv tool uninstall agnes-the-ai-analyst
# 2. Remove global config and trust artifacts
rm -rf ~/.config/da
rm -rf ~/.agnes
# 3. Remove the env-var block from your shell rc
# Open ~/.zshrc or ~/.bashrc, find the lines between
# "# AGNES_CA_PEM_TRUST — added by Agnes setup" and the next blank line, delete.
# 4. Remove this workspace
rm -rf ./CLAUDE.md ./AGNES_WORKSPACE.md ./.claude ./server ./user
```
```
- [ ] **Step 2: Write failing tests for `agnes init`**
```python
# tests/test_cli_init.py
"""Tests for `agnes init` orchestrator command."""
import json
from pathlib import Path
from unittest.mock import patch
from typer.testing import CliRunner
from cli.commands.init import init_app
runner = CliRunner()
def test_init_help():
result = runner.invoke(init_app, ["--help"])
assert result.exit_code == 0
assert "--server-url" in result.output
assert "--token" in result.output
assert "--force" in result.output
assert "--workspace" in result.output
def test_init_writes_expected_files(tmp_path, monkeypatch):
"""Mocked end-to-end: init writes CLAUDE.md, settings.json, AGNES_WORKSPACE.md."""
# Mock all server-side calls
def _api_get(path, *args, **kwargs):
from unittest.mock import MagicMock
resp = MagicMock()
resp.status_code = 200
if path == "/api/catalog/tables":
resp.json.return_value = []
elif path == "/api/welcome":
resp.json.return_value = {"content": "# Test CLAUDE.md\n\nUse `agnes pull`.\n"}
elif path == "/api/sync/manifest":
resp.json.return_value = {"tables": []}
elif path == "/api/memory/bundle":
resp.json.return_value = {"mandatory": [], "approved": []}
return resp
monkeypatch.setattr("cli.commands.init.api_get", _api_get, raising=False)
monkeypatch.setattr("cli.lib.pull.api_get", _api_get, raising=False)
result = runner.invoke(init_app, [
"--server-url", "http://test.example.com",
"--token", "test-pat",
"--workspace", str(tmp_path),
])
assert result.exit_code == 0, result.output
assert (tmp_path / "CLAUDE.md").exists()
assert "agnes pull" in (tmp_path / "CLAUDE.md").read_text()
assert (tmp_path / ".claude" / "settings.json").exists()
assert (tmp_path / ".claude" / "CLAUDE.local.md").exists()
assert (tmp_path / "AGNES_WORKSPACE.md").exists()
assert (tmp_path / "user" / "duckdb" / "analytics.duckdb").exists()
def test_init_no_dead_dirs_zero_grants(tmp_path, monkeypatch):
"""Zero grants → no .claude/rules, no server/parquet, no user/sessions."""
# Same mock as above
def _api_get(path, *args, **kwargs):
from unittest.mock import MagicMock
resp = MagicMock()
resp.status_code = 200
if path == "/api/catalog/tables":
resp.json.return_value = []
elif path == "/api/welcome":
resp.json.return_value = {"content": "test"}
elif path == "/api/sync/manifest":
resp.json.return_value = {"tables": []}
elif path == "/api/memory/bundle":
resp.json.return_value = {"mandatory": [], "approved": []}
return resp
monkeypatch.setattr("cli.commands.init.api_get", _api_get, raising=False)
monkeypatch.setattr("cli.lib.pull.api_get", _api_get, raising=False)
runner.invoke(init_app, [
"--server-url", "http://x", "--token", "t", "--workspace", str(tmp_path),
])
for forbidden in ["data/parquet", "data/duckdb", "data/metadata",
"user/artifacts", "user/sessions",
"server/parquet", ".claude/rules"]:
assert not (tmp_path / forbidden).exists(), f"forbidden created: {forbidden}"
def test_init_force_preserves_local_md(tmp_path, monkeypatch):
"""--force regenerates CLAUDE.md but never touches CLAUDE.local.md."""
# First init
def _api_get(path, *args, **kwargs):
from unittest.mock import MagicMock
resp = MagicMock()
resp.status_code = 200
if path == "/api/catalog/tables": resp.json.return_value = []
elif path == "/api/welcome": resp.json.return_value = {"content": "v1"}
else: resp.json.return_value = {} if "manifest" in path else {"mandatory": [], "approved": []}
return resp
monkeypatch.setattr("cli.commands.init.api_get", _api_get, raising=False)
monkeypatch.setattr("cli.lib.pull.api_get", _api_get, raising=False)
runner.invoke(init_app, ["--server-url", "http://x", "--token", "t", "--workspace", str(tmp_path)])
(tmp_path / ".claude" / "CLAUDE.local.md").write_text("# my notes")
# Second init with --force
runner.invoke(init_app, ["--server-url", "http://x", "--token", "t",
"--workspace", str(tmp_path), "--force"])
assert "my notes" in (tmp_path / ".claude" / "CLAUDE.local.md").read_text()
```
- [ ] **Step 3: Run tests to verify they fail**
```bash
pytest tests/test_cli_init.py -v
```
- [ ] **Step 4: Create `cli/commands/init.py`**
```python
# cli/commands/init.py
"""`agnes init` — bootstrap an analyst workspace.
Single-paste flow: web user clicks "Generate prompt" on /setup?role=analyst,
pastes into Claude Code in an empty folder; Claude runs `agnes init` (among other
steps). Non-interactive: --token + --server-url required.
"""
from __future__ import annotations
import json
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
import typer
from cli.client import api_get
from cli.config import save_config, save_token
from cli.error_render import render_error
from cli.lib.hooks import install_claude_hooks
from cli.lib.pull import run_pull, PullResult
_INIT_MARKER = "AI Data Analyst" # Detect existing workspace via CLAUDE.md substring
init_app = typer.Typer(help="Bootstrap an analyst workspace in this directory")
@init_app.callback(invoke_without_command=True)
def init(
server_url: str = typer.Option(..., "--server-url", help="Agnes server URL"),
token: str = typer.Option(..., "--token", help="Personal access token"),
force: bool = typer.Option(False, "--force", help="Re-initialize an existing workspace"),
workspace_str: Optional[str] = typer.Option(None, "--workspace", help="Target dir (default: cwd)"),
):
"""Bootstrap workspace: auth, CLAUDE.md, hooks, first pull, AGNES_WORKSPACE.md."""
workspace = Path(workspace_str).resolve() if workspace_str else Path.cwd()
server_url = server_url.rstrip("/")
# Detect existing workspace
claude_md = workspace / "CLAUDE.md"
if claude_md.exists() and _INIT_MARKER in claude_md.read_text() and not force:
typer.echo(render_error(0, {"detail": {
"kind": "partial_state",
"hint": "Workspace already initialized. Re-run with --force to redo.",
}}), err=True)
raise typer.Exit(1)
# Step 1: Verify PAT via /api/catalog/tables (PAT-validating endpoint)
try:
resp = api_get("/api/catalog/tables", server_url=server_url, token=token)
if resp.status_code == 401:
typer.echo(render_error(401, {"detail": {
"kind": "auth_failed",
"hint": f"Token expired or invalid — get a fresh one at {server_url}/setup?role=analyst",
}}), err=True)
raise typer.Exit(1)
resp.raise_for_status()
except typer.Exit:
raise
except Exception as exc:
typer.echo(render_error(0, {"detail": {
"kind": "server_unreachable",
"hint": f"Cannot reach {server_url} — check network or server status",
"message": str(exc),
}}), err=True)
raise typer.Exit(1)
# Step 2: Save config + token globally
save_config({"server": server_url})
save_token(token, email="") # email empty — JWT carries it; we don't decode here
# Step 3: Fetch CLAUDE.md from /api/welcome (server-rendered, RBAC-filtered)
welcome_resp = api_get("/api/welcome", server_url=server_url, token=token,
params={"server_url": server_url})
welcome_resp.raise_for_status()
workspace.mkdir(parents=True, exist_ok=True)
claude_md.write_text(welcome_resp.json()["content"], encoding="utf-8")
# Step 4: Default settings.json + install hooks
settings_path = workspace / ".claude" / "settings.json"
if not settings_path.exists():
settings_path.parent.mkdir(parents=True, exist_ok=True)
settings_path.write_text(json.dumps(
{"model": "sonnet", "permissions": {"allow": ["Read", "Bash", "Grep", "Glob"]}},
indent=2,
))
install_claude_hooks(workspace)
# Step 5: CLAUDE.local.md stub (preserved on re-run)
local_md = workspace / ".claude" / "CLAUDE.local.md"
if not local_md.exists():
local_md.write_text(
"# My Notes\n\nPersonal notes for this workspace. Uploaded on `agnes push`.\n",
encoding="utf-8",
)
# Step 6: First pull
try:
result: PullResult = run_pull(server_url, token, workspace)
except Exception as exc:
typer.echo(render_error(0, {"detail": {
"kind": "manifest_unauthorized",
"hint": "Initial pull failed — workspace partially set up",
"message": str(exc),
}}), err=True)
raise typer.Exit(1)
# Step 7: Write AGNES_WORKSPACE.md from client-side template
here = Path(__file__).parent
template_path = here.parent.parent / "config" / "agnes_workspace_template.txt"
if template_path.exists():
template = template_path.read_text(encoding="utf-8")
else:
template = "# Agnes workspace\n\nCreated: {created_at}\nServer: {server_url}\n"
workspace_md = (template
.replace("{created_at}", datetime.now(timezone.utc).isoformat())
.replace("{server_url}", server_url)
.replace("{workspace_path}", str(workspace)))
(workspace / "AGNES_WORKSPACE.md").write_text(workspace_md, encoding="utf-8")
# Step 8: Summary
typer.echo("Workspace ready.")
typer.echo(f" Server : {server_url}")
typer.echo(f" Tables : {result.tables_updated} synced ({result.parquets_total} total)")
typer.echo(f" Rules : {result.rules_count}")
typer.echo(f" Workspace: {workspace}")
typer.echo("")
typer.echo("Try: agnes catalog")
```
- [ ] **Step 5: Run tests to verify they pass**
```bash
pytest tests/test_cli_init.py -v
```
- [ ] **Step 6: Commit**
```bash
git add cli/commands/init.py config/agnes_workspace_template.txt tests/test_cli_init.py
git commit -m "feat(cli): agnes init orchestrator + AGNES_WORKSPACE.md template"
```
---
### Task 12: New `agnes status` (workspace status, replaces `da analyst status`)
**Files:**
- Modify (overwrite): `cli/commands/status.py`
- Test: `tests/test_cli_status.py` (new)
- [ ] **Step 1: Read existing status.py to understand what to replace**
```bash
cat cli/commands/status.py
```
The existing `agnes status` shows server health. Per spec, this content moves to `agnes diagnose system` (Task 13); the file is repurposed for workspace status.
- [ ] **Step 2: Write failing tests**
```python
# tests/test_cli_status.py
from pathlib import Path
from typer.testing import CliRunner
from cli.commands.status import status_app
runner = CliRunner()
def test_status_uninitialized_workspace(tmp_path, monkeypatch):
monkeypatch.setenv("DA_LOCAL_DIR", str(tmp_path))
result = runner.invoke(status_app)
assert result.exit_code in (0, 1)
assert "not initialized" in result.output.lower() or "no workspace" in result.output.lower()
def test_status_initialized_workspace(tmp_path, monkeypatch):
"""A bootstrapped workspace shows 'initialized: yes' and basic stats."""
monkeypatch.setenv("DA_LOCAL_DIR", str(tmp_path))
(tmp_path / "CLAUDE.md").write_text("# AI Data Analyst\n")
(tmp_path / "user" / "duckdb").mkdir(parents=True)
(tmp_path / "user" / "duckdb" / "analytics.duckdb").touch()
(tmp_path / "server" / "parquet").mkdir(parents=True)
(tmp_path / "server" / "parquet" / "tbl1.parquet").touch()
result = runner.invoke(status_app)
assert result.exit_code == 0
assert "initialized" in result.output.lower()
assert "1" in result.output # one parquet
def test_status_json(tmp_path, monkeypatch):
monkeypatch.setenv("DA_LOCAL_DIR", str(tmp_path))
(tmp_path / "CLAUDE.md").write_text("# AI Data Analyst\n")
result = runner.invoke(status_app, ["--json"])
assert result.exit_code == 0
import json
body = json.loads(result.output)
assert "workspace" in body and "initialized" in body
```
- [ ] **Step 3: Run tests to verify they fail**
```bash
pytest tests/test_cli_status.py -v
```
- [ ] **Step 4: Overwrite `cli/commands/status.py`**
```python
# cli/commands/status.py
"""`agnes status` — workspace status: initialized? data fresh? hooks active?"""
from __future__ import annotations
import json
import os
from datetime import datetime, timezone
from pathlib import Path
import typer
_INIT_MARKER = "AI Data Analyst"
status_app = typer.Typer(help="Workspace status (was `da analyst status`)")
@status_app.callback(invoke_without_command=True)
def status(
as_json: bool = typer.Option(False, "--json", help="Machine-readable output"),
):
workspace = Path(os.environ.get("DA_LOCAL_DIR", ".")).resolve()
initialized = False
claude_md = workspace / "CLAUDE.md"
if claude_md.exists():
initialized = _INIT_MARKER in claude_md.read_text()
parquet_dir = workspace / "server" / "parquet"
parquets = list(parquet_dir.glob("*.parquet")) if parquet_dir.exists() else []
db_path = workspace / "user" / "duckdb" / "analytics.duckdb"
last_synced = None
if db_path.exists():
last_synced = datetime.fromtimestamp(db_path.stat().st_mtime, tz=timezone.utc).isoformat()
sessions_dir = workspace / "user" / "sessions"
session_count = len(list(sessions_dir.glob("*.jsonl"))) if sessions_dir.exists() else 0
info = {
"workspace": str(workspace),
"initialized": initialized,
"parquet_tables": len(parquets),
"duckdb_exists": db_path.exists(),
"last_synced": last_synced,
"sessions_pending_upload": session_count,
}
if as_json:
typer.echo(json.dumps(info, indent=2))
return
typer.echo(f"Workspace : {workspace}")
typer.echo(f"Initialized: {'yes' if initialized else 'no'}")
typer.echo(f"Parquets : {info['parquet_tables']}")
typer.echo(f"DuckDB : {'yes' if info['duckdb_exists'] else 'no'}")
typer.echo(f"Last sync : {last_synced or 'never'}")
typer.echo(f"Pending uploads: {session_count} sessions")
if not initialized:
typer.echo("")
typer.echo("Run `agnes init --server-url <URL> --token <PAT>` to bootstrap.")
```
- [ ] **Step 5: Run tests to verify they pass**
```bash
pytest tests/test_cli_status.py -v
```
- [ ] **Step 6: Commit**
```bash
git add cli/commands/status.py tests/test_cli_status.py
git commit -m "feat(cli): agnes status now shows workspace state (was system health)"
```
---
### Task 13: Move old `agnes status` content into `agnes diagnose system`
**Files:**
- Modify: `cli/commands/diagnose.py` (add `system` subcommand with the old status logic)
- Test: `tests/test_cli_diagnose_system.py` (new)
- [ ] **Step 1: Recover old `agnes status` logic**
```bash
git show HEAD~12:cli/commands/status.py
```
(Adjust the ref — find the commit before the rewrite via `git log --oneline cli/commands/status.py | head`.) Save the body to a scratch file.
- [ ] **Step 2: Read existing diagnose command structure**
```bash
cat cli/commands/diagnose.py
```
- [ ] **Step 3: Add `system` subcommand**
Append the old status logic as a `system` subcommand of `diagnose_app`. Keep diagnose's existing default behavior (overall health) intact.
- [ ] **Step 4: Test**
```python
# tests/test_cli_diagnose_system.py
from typer.testing import CliRunner
from cli.commands.diagnose import diagnose_app
def test_diagnose_system_help():
runner = CliRunner()
result = runner.invoke(diagnose_app, ["system", "--help"])
assert result.exit_code == 0
```
- [ ] **Step 5: Commit**
```bash
git add cli/commands/diagnose.py tests/test_cli_diagnose_system.py
git commit -m "refactor(cli): move old `agnes status` health check to `agnes diagnose system`"
```
---
### Task 14: `agnes snapshot create` — fold `da fetch` into snapshot group
**Files:**
- Modify: `cli/commands/snapshot.py` (add `create` subcommand)
- Test: `tests/test_cli_snapshot_create.py` (new)
- [ ] **Step 1: Read existing fetch.py and snapshot.py**
```bash
cat cli/commands/fetch.py
cat cli/commands/snapshot.py
```
- [ ] **Step 2: Add `create` subcommand to `snapshot_app`**
Move the body of `fetch.py:fetch()` into a new `@snapshot_app.command("create")`. Keep all flags. Update the existence check:
```python
local_db = _local_dir() / "user" / "duckdb" / "analytics.duckdb"
if not local_db.exists():
typer.echo("Local DuckDB not found. Run: agnes pull first.", err=True)
raise typer.Exit(1)
# (then proceed with duckdb.connect — no longer creates an empty DB)
```
- [ ] **Step 3: Add tests**
```python
# tests/test_cli_snapshot_create.py
from typer.testing import CliRunner
from cli.commands.snapshot import snapshot_app
def test_snapshot_create_help():
runner = CliRunner()
result = runner.invoke(snapshot_app, ["create", "--help"])
assert result.exit_code == 0
for flag in ["--select", "--where", "--limit", "--order-by", "--as", "--estimate", "--no-estimate", "--force"]:
assert flag in result.output
def test_snapshot_create_no_duckdb_friendly_exit(tmp_path, monkeypatch):
monkeypatch.setenv("DA_LOCAL_DIR", str(tmp_path))
runner = CliRunner()
result = runner.invoke(snapshot_app, ["create", "any_table", "--as", "x", "--estimate"])
assert result.exit_code == 1
assert "Run: agnes pull" in result.output or "Run: agnes pull" in (result.stderr or "")
```
- [ ] **Step 4: Run tests**
```bash
pytest tests/test_cli_snapshot_create.py -v
```
- [ ] **Step 5: Commit**
```bash
git add cli/commands/snapshot.py tests/test_cli_snapshot_create.py
git commit -m "feat(cli): agnes snapshot create (folded from da fetch); friendly exit if no DuckDB"
```
---
### Task 15: `agnes catalog --metrics` — fold `da metrics list/show`
**Files:**
- Modify: `cli/commands/catalog.py`
- Test: `tests/test_cli_catalog_metrics.py` (new)
- [ ] **Step 1: Read existing metrics list/show logic**
```bash
grep -n "def list\|def show\|@" cli/commands/metrics.py | head
```
- [ ] **Step 2: Add `--metrics` and `--metrics --show <id>` to catalog**
Modify `cli/commands/catalog.py`:
```python
@catalog_app.callback(invoke_without_command=True)
def catalog(
as_json: bool = typer.Option(False, "--json"),
metrics: bool = typer.Option(False, "--metrics", help="Show metric definitions instead of tables"),
show: Optional[str] = typer.Option(None, "--show", help="With --metrics: show one metric by id"),
):
if metrics and show:
return _show_one_metric(show, as_json)
if metrics:
return _list_metrics(as_json)
return _list_tables(as_json)
```
(`_list_metrics` and `_show_one_metric` lift from `metrics.py:list_metrics` and `metrics.py:show_metric`.)
- [ ] **Step 3: Add tests**
```python
# tests/test_cli_catalog_metrics.py
from typer.testing import CliRunner
from cli.commands.catalog import catalog_app
def test_catalog_metrics_help():
runner = CliRunner()
result = runner.invoke(catalog_app, ["--help"])
assert result.exit_code == 0
assert "--metrics" in result.output
assert "--show" in result.output
```
- [ ] **Step 4: Commit**
```bash
git add cli/commands/catalog.py tests/test_cli_catalog_metrics.py
git commit -m "feat(cli): agnes catalog --metrics replaces da metrics list/show"
```
---
### Task 16: Move `da metrics import/export/validate` to `agnes admin metrics`
**Files:**
- Create: `cli/commands/admin_metrics.py`
- Modify: `cli/commands/admin.py` (register the sub-Typer)
- [ ] **Step 1: Create `cli/commands/admin_metrics.py`**
Lift `import_metrics`, `export_metrics`, `validate_metrics` from `cli/commands/metrics.py`. Wrap in a sub-Typer:
```python
# cli/commands/admin_metrics.py
"""`agnes admin metrics {import,export,validate}` — lifted from metrics.py."""
import typer
admin_metrics_app = typer.Typer(help="Admin: metric definition management")
@admin_metrics_app.command("import")
def import_metrics(directory: str = typer.Argument(...)):
# ... lifted logic from cli/commands/metrics.py:import_metrics
pass
@admin_metrics_app.command("export")
def export_metrics(target: str = typer.Argument(...)):
# ... lifted logic
pass
@admin_metrics_app.command("validate")
def validate_metrics():
# ... lifted logic
pass
```
(Copy the actual implementations from `metrics.py` verbatim.)
- [ ] **Step 2: Register in admin app**
In `cli/commands/admin.py`, add:
```python
from cli.commands.admin_metrics import admin_metrics_app
admin_app.add_typer(admin_metrics_app, name="metrics")
```
- [ ] **Step 3: Test**
```python
# tests/test_cli_admin_metrics.py
from typer.testing import CliRunner
from cli.commands.admin import admin_app
def test_admin_metrics_subcommands_present():
runner = CliRunner()
result = runner.invoke(admin_app, ["metrics", "--help"])
assert result.exit_code == 0
assert "import" in result.output
assert "export" in result.output
assert "validate" in result.output
```
- [ ] **Step 4: Commit**
```bash
git add cli/commands/admin_metrics.py cli/commands/admin.py tests/test_cli_admin_metrics.py
git commit -m "feat(cli): agnes admin metrics {import,export,validate}"
```
---
## Phase 4 — Wiring + cleanup
### Task 17: Update reader hint texts
**Files:**
- Modify: `cli/commands/query.py` (two occurrences), `cli/commands/explore.py`
- [ ] **Step 1: Find all "Run: da sync" strings**
```bash
grep -rn "Run: da sync" cli/
```
- [ ] **Step 2: Replace with "Run: agnes pull"**
```bash
sed -i.bak 's/Run: da sync/Run: agnes pull/g' cli/commands/query.py cli/commands/explore.py
rm cli/commands/query.py.bak cli/commands/explore.py.bak
```
- [ ] **Step 3: Verify no leftover**
```bash
grep -rn "Run: da sync" cli/
```
Expected: no matches.
- [ ] **Step 4: Commit**
```bash
git add cli/commands/query.py cli/commands/explore.py
git commit -m "fix(cli): hint text 'Run: da sync' → 'Run: agnes pull'"
```
---
### Task 18: Update `cli/main.py` registrations + delete obsolete commands
**Files:**
- Modify: `cli/main.py`
- Delete: `cli/commands/sync.py`, `cli/commands/fetch.py`, `cli/commands/analyst.py`, `cli/commands/metrics.py`
- [ ] **Step 1: Update `cli/main.py`**
Replace lines 11-28 (imports) and 91-109 (registrations):
```python
from cli.commands.auth import auth_app
from cli.commands.init import init_app
from cli.commands.pull import pull_app
from cli.commands.push import push_app
from cli.commands.query import query_command
from cli.commands.status import status_app
from cli.commands.admin import admin_app
from cli.commands.diagnose import diagnose_app
from cli.commands.skills import skills_app
from cli.commands.setup import setup_app
from cli.commands.server import server_app
from cli.commands.explore import explore_app
from cli.commands.catalog import catalog_app
from cli.commands.schema import schema_app
from cli.commands.describe import describe_app
from cli.commands.snapshot import snapshot_app
from cli.commands.disk_info import disk_info_app
```
```python
# Register subcommands
app.add_typer(auth_app, name="auth")
app.add_typer(init_app, name="init")
app.add_typer(pull_app, name="pull")
app.add_typer(push_app, name="push")
app.command("query")(query_command)
app.add_typer(status_app, name="status")
app.add_typer(admin_app, name="admin")
app.add_typer(diagnose_app, name="diagnose")
app.add_typer(skills_app, name="skills")
app.add_typer(setup_app, name="setup")
app.add_typer(server_app, name="server")
app.add_typer(explore_app, name="explore")
app.add_typer(catalog_app, name="catalog")
app.add_typer(schema_app, name="schema")
app.add_typer(describe_app, name="describe")
app.add_typer(snapshot_app, name="snapshot")
app.add_typer(disk_info_app, name="disk-info")
```
- [ ] **Step 2: Delete obsolete files**
```bash
git rm cli/commands/sync.py cli/commands/fetch.py cli/commands/analyst.py cli/commands/metrics.py
```
- [ ] **Step 3: Verify no other code imports them**
```bash
grep -rn "from cli.commands.sync\|from cli.commands.fetch\|from cli.commands.analyst\|from cli.commands.metrics" .
```
Expected: no matches (anything found needs to be updated to use the new homes).
- [ ] **Step 4: Run the full test suite (smoke)**
```bash
pytest tests/ -x --ignore=tests/test_clean_install_integration.py --ignore=tests/test_reader_smoke_matrix.py 2>&1 | tail -30
```
Expected: tests for moved/deleted commands fail with import errors — those tests are also being deleted (or already updated in earlier tasks). Other tests should pass.
If old test files reference the deleted commands, `git rm` them too:
```bash
git rm tests/test_analyst*.py tests/test_sync*.py tests/test_fetch*.py tests/test_metrics_cli*.py 2>/dev/null || true
```
- [ ] **Step 5: Commit**
```bash
git add cli/main.py
git rm cli/commands/{sync,fetch,analyst,metrics}.py 2>/dev/null
git commit -m "refactor(cli): drop sync/fetch/analyst/metrics; register init/pull/push"
```
---
### Task 19: Update repo-root `CLAUDE.md`
**Files:**
- Modify: `CLAUDE.md`
- [ ] **Step 1: Apply systematic rewrites**
```bash
sed -i.bak \
-e 's|da sync --upload-only|agnes push|g' \
-e 's|da sync|agnes pull|g' \
-e 's|da analyst setup|agnes init|g' \
-e 's|da fetch|agnes snapshot create|g' \
-e 's|da metrics list|agnes catalog --metrics|g' \
-e 's|da metrics show|agnes catalog --metrics --show|g' \
-e 's|da metrics import|agnes admin metrics import|g' \
-e 's|data/parquet/|server/parquet/|g' \
-e 's|data/duckdb/|user/duckdb/|g' \
CLAUDE.md
rm CLAUDE.md.bak
```
- [ ] **Step 2: Manually rewrite the "Local sync & Claude Code hooks" subsection**
Find the section. Replace the surrounding prose so it describes `agnes pull` + `agnes push` hooks:
```markdown
### Local sync & Claude Code hooks
`agnes pull` is the canonical analyst-side distribution path: pulls the
RBAC-filtered manifest from the server, downloads parquets whose MD5 changed
(skipping `query_mode='remote'` rows), rebuilds local DuckDB views over them.
`agnes push` mirrors it for the upload direction (sessions, CLAUDE.local.md).
`agnes init` writes two hooks into `<workspace>/.claude/settings.json`:
- `SessionStart``agnes pull --quiet` — pulls fresh parquets at the start of every Claude Code session
- `SessionEnd``agnes push --quiet` — uploads session jsonl + `CLAUDE.local.md` to the server
Both pass `--quiet` so they don't pollute Claude Code stdout, and trail with `|| true` so a server outage never blocks a session. Workspace-level (not user-home) so the hooks fire only when Claude Code opens this analyst workspace, not in unrelated sessions on the same machine.
Admin RBAC for auto-sync: `query_mode IN ('local', 'materialized')` plus a `resource_grants` row for one of the analyst's groups → table appears in their manifest → `agnes pull` downloads it. No per-user sync config; the admin layer is the single source of truth.
```
- [ ] **Step 3: Verify no leftover legacy strings**
```bash
grep -nE 'da sync|da fetch|da analyst|da metrics list|da metrics show|data/parquet/|data/duckdb/' CLAUDE.md
```
Expected: no matches.
- [ ] **Step 4: Commit**
```bash
git add CLAUDE.md
git commit -m "docs(claude-md): rewrite verbs + paths for new CLI surface"
```
---
## Phase 5 — Test fixtures and integration tests
### Task 20: Create `tests/fixtures/analyst_bootstrap.py`
**Files:**
- Create: `tests/fixtures/analyst_bootstrap.py`
- Modify: `tests/conftest.py` (import the fixtures)
- [ ] **Step 1: Read existing test infrastructure**
```bash
grep -n "fastapi\|TestClient\|tmp_path" tests/conftest.py | head -30
```
- [ ] **Step 2: Create the fixtures**
```python
# tests/fixtures/analyst_bootstrap.py
"""Test fixtures for the clean-bootstrap test suite.
Per spec §"Test fixtures":
- fastapi_test_server, test_pat, test_pat_no_grants, zero_grants_workspace,
web_session, client.
"""
from __future__ import annotations
import json
import subprocess
import threading
import time
from contextlib import contextmanager
from pathlib import Path
from typing import Iterator
import httpx
import pytest
import uvicorn
NONEXISTENT_TABLE = "__nonexistent__" # Sentinel for reader smoke matrix
class _ServerHandle:
def __init__(self, url: str, server: uvicorn.Server, thread: threading.Thread):
self.url = url
self._server = server
self._thread = thread
def shutdown(self):
self._server.should_exit = True
self._thread.join(timeout=5)
def _seed_db(data_dir: Path):
"""Initialize a fresh system.duckdb with seeded admin/analyst users + tables.
Imports app modules at function scope to avoid circular imports during
collection.
"""
import os
os.environ["DATA_DIR"] = str(data_dir)
from src.db import get_db_connection
from src.repositories.users import UserRepository
from src.repositories.user_groups import UserGroupRepository
from src.repositories.table_registry import TableRegistryRepository
from src.repositories.user_group_members import UserGroupMembersRepository
from src.repositories.resource_grants import ResourceGrantsRepository
from app.auth.providers.password import _hash_password
conn = get_db_connection()
# Seed users
user_repo = UserRepository(conn)
admin_id = user_repo.create(email="admin@example.com", name="Admin",
password_hash=_hash_password("test-password"),
is_admin=True)
analyst_id = user_repo.create(email="analyst@example.com", name="Analyst",
password_hash=_hash_password("analyst-pw"),
is_admin=False)
# Seed groups (Admin + Everyone are seeded as is_system=TRUE on first run)
grp_repo = UserGroupRepository(conn)
admin_group = grp_repo.find_by_name("Admin")
everyone_group = grp_repo.find_by_name("Everyone")
# Memberships
members = UserGroupMembersRepository(conn)
members.add(user_id=admin_id, group_id=admin_group.id, source="system_seed")
members.add(user_id=analyst_id, group_id=everyone_group.id, source="system_seed")
# Tables
tbl_repo = TableRegistryRepository(conn)
tbl_repo.create(id="local_tbl", name="local_tbl", source_type="keboola",
bucket="test", source_table="local_tbl", query_mode="local")
tbl_repo.create(id="materialized_tbl", name="materialized_tbl", source_type="bigquery",
bucket="test", source_table="materialized_tbl", query_mode="materialized")
tbl_repo.create(id="remote_tbl", name="remote_tbl", source_type="bigquery",
bucket="test", source_table="remote_tbl", query_mode="remote")
return {"admin_id": admin_id, "analyst_id": analyst_id,
"admin_group_id": admin_group.id, "everyone_group_id": everyone_group.id}
@pytest.fixture
def fastapi_test_server(tmp_path) -> Iterator[_ServerHandle]:
"""Boot a real FastAPI server in a background thread against tmp_path DATA_DIR."""
data_dir = tmp_path / "agnes-data"
data_dir.mkdir()
seeded = _seed_db(data_dir)
handle_port = 18712 + (id(tmp_path) % 1000)
from app.main import app as fastapi_app
config = uvicorn.Config(fastapi_app, host="127.0.0.1", port=handle_port, log_level="warning")
server = uvicorn.Server(config)
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
# Wait for server up
url = f"http://127.0.0.1:{handle_port}"
for _ in range(50):
try:
httpx.get(f"{url}/api/health", timeout=0.2)
break
except Exception:
time.sleep(0.1)
else:
pytest.fail("fastapi_test_server failed to start")
handle = _ServerHandle(url, server, thread)
handle._seeded = seeded
handle.NONEXISTENT_TABLE = NONEXISTENT_TABLE
yield handle
handle.shutdown()
@pytest.fixture
def web_session(fastapi_test_server) -> Iterator[httpx.Client]:
"""Authenticated httpx.Client using cookie session for admin@example.com."""
client = httpx.Client(base_url=fastapi_test_server.url, follow_redirects=False)
resp = client.post("/auth/password/login/web",
data={"email": "admin@example.com", "password": "test-password"})
assert resp.status_code in (200, 302), f"web_session login failed: {resp.text}"
yield client
client.close()
@pytest.fixture
def test_pat(web_session) -> str:
"""Mint a PAT for analyst@example.com with 2 grants + 2 mandatory rules."""
# First, grant the analyst access to local_tbl + materialized_tbl
web_session.post("/api/admin/grants",
json={"group_id": "...everyone...", "resource_type": "table",
"resource_id": "local_tbl"})
# ... similarly for materialized_tbl + 2 mandatory memory items
# (Use the actual admin endpoints for grants and memory items.)
# Mint PAT (as analyst — log in as analyst first, then mint)
analyst_session = httpx.Client(base_url=web_session.base_url, follow_redirects=False)
analyst_session.post("/auth/password/login/web",
data={"email": "analyst@example.com", "password": "analyst-pw"})
resp = analyst_session.post("/auth/tokens",
json={"name": "test", "ttl_seconds": 3600})
assert resp.status_code == 201, resp.text
return resp.json()["token"]
@pytest.fixture
def test_pat_no_grants(web_session) -> str:
analyst_session = httpx.Client(base_url=web_session.base_url, follow_redirects=False)
analyst_session.post("/auth/password/login/web",
data={"email": "analyst@example.com", "password": "analyst-pw"})
resp = analyst_session.post("/auth/tokens",
json={"name": "test-nogrants", "ttl_seconds": 3600})
return resp.json()["token"]
@pytest.fixture
def zero_grants_workspace(tmp_path, fastapi_test_server, test_pat_no_grants) -> Path:
"""Run `agnes init` against a no-grants PAT; return the workspace path."""
workspace = tmp_path / "workspace"
workspace.mkdir()
result = subprocess.run([
"da", "init",
"--server-url", fastapi_test_server.url,
"--token", test_pat_no_grants,
"--workspace", str(workspace),
], capture_output=True, text=True)
assert result.returncode == 0, f"init failed: {result.stderr}"
return workspace
```
(Adapt API calls as needed — the actual repository/route names may differ. Spec the goal; implementer adapts.)
- [ ] **Step 3: Wire fixtures into conftest**
In `tests/conftest.py`, append:
```python
from tests.fixtures.analyst_bootstrap import (
fastapi_test_server, web_session, test_pat, test_pat_no_grants,
zero_grants_workspace, NONEXISTENT_TABLE,
)
```
- [ ] **Step 4: Smoke-test fixture creation**
```python
# tests/test_fixtures_smoke.py
def test_server_boots(fastapi_test_server):
import httpx
resp = httpx.get(f"{fastapi_test_server.url}/api/health")
assert resp.status_code == 200
def test_zero_grants_workspace_minimal(zero_grants_workspace):
assert (zero_grants_workspace / "CLAUDE.md").exists()
assert (zero_grants_workspace / "AGNES_WORKSPACE.md").exists()
assert not (zero_grants_workspace / "server" / "parquet").exists()
assert not (zero_grants_workspace / ".claude" / "rules").exists()
```
```bash
pytest tests/test_fixtures_smoke.py -v
```
- [ ] **Step 5: Commit**
```bash
git add tests/fixtures/analyst_bootstrap.py tests/conftest.py tests/test_fixtures_smoke.py
git commit -m "test: clean-bootstrap fixtures (fastapi_test_server, test_pat, etc.)"
```
---
### Task 21: Reader smoke matrix
**Files:**
- Create: `tests/test_reader_smoke_matrix.py`
- [ ] **Step 1: Write the matrix**
```python
# tests/test_reader_smoke_matrix.py
"""Reader smoke matrix — every CLI command on a freshly-bootstrapped
zero-grants workspace, asserts no traceback. The load-bearing test for
'nothing crashes on missing dirs'."""
import subprocess
import pytest
from tests.fixtures.analyst_bootstrap import NONEXISTENT_TABLE
READER_COMMANDS = [
["agnes", "catalog"],
["agnes", "catalog", "--metrics"],
["agnes", "schema", NONEXISTENT_TABLE],
["agnes", "describe", NONEXISTENT_TABLE],
["agnes", "query", "SELECT 1"],
["agnes", "explore", NONEXISTENT_TABLE],
["agnes", "disk-info"],
["agnes", "snapshot", "list"],
["agnes", "snapshot", "create", NONEXISTENT_TABLE, "--as", "x", "--estimate"],
["agnes", "status"],
["agnes", "diagnose"],
["agnes", "auth", "whoami"],
["agnes", "skills", "list"],
["agnes", "skills", "show", "agnes-data-querying"],
]
@pytest.mark.parametrize("cmd", READER_COMMANDS, ids=lambda c: " ".join(c))
def test_reader_does_not_crash_on_zero_grants(zero_grants_workspace, cmd):
"""Exit 0 (success) or exit 1 (friendly hint) is OK; tracebacks are forbidden."""
result = subprocess.run(cmd, cwd=zero_grants_workspace,
capture_output=True, text=True, timeout=30)
assert result.returncode in (0, 1), \
f"{cmd} crashed: rc={result.returncode}, stderr={result.stderr}"
assert "Traceback" not in result.stderr, f"{cmd} threw: {result.stderr}"
```
- [ ] **Step 2: Run**
```bash
pytest tests/test_reader_smoke_matrix.py -v
```
Expected: all parametrized cases PASS.
- [ ] **Step 3: Commit**
```bash
git add tests/test_reader_smoke_matrix.py
git commit -m "test: reader smoke matrix on zero-grants workspace"
```
---
### Task 22: Clean-install integration tests
**Files:**
- Create: `tests/test_clean_install_integration.py`
- [ ] **Step 1: Write the integration tests per spec §5.2**
```python
# tests/test_clean_install_integration.py
"""End-to-end clean-install integration tests for `agnes init`."""
import json
import subprocess
from pathlib import Path
def assert_no_dead_dirs(workspace: Path):
forbidden_unconditional = ["data/parquet", "data/duckdb", "data/metadata",
"user/artifacts", ".agnes"]
for d in forbidden_unconditional:
assert not (workspace / d).exists(), f"forbidden dir created: {d}"
for d in [".claude/rules", "server/parquet", "user/sessions", "user/snapshots"]:
path = workspace / d
if path.exists():
assert any(path.iterdir()), f"{d} exists but is empty"
def test_clean_install_minimal_grants(fastapi_test_server, tmp_path, test_pat):
workspace = tmp_path / "ws"
workspace.mkdir()
result = subprocess.run([
"da", "init",
"--server-url", fastapi_test_server.url,
"--token", test_pat,
"--workspace", str(workspace),
], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
for must in ["CLAUDE.md", "AGNES_WORKSPACE.md",
".claude/settings.json", ".claude/CLAUDE.local.md",
"user/duckdb/analytics.duckdb"]:
assert (workspace / must).exists(), f"missing: {must}"
parquets = list((workspace / "server" / "parquet").glob("*.parquet"))
assert len(parquets) == 2, "expected 2 parquets (local + materialized grants)"
rules = list((workspace / ".claude" / "rules").iterdir())
assert len(rules) == 2, "expected 2 mandatory rules"
assert_no_dead_dirs(workspace)
settings = json.loads((workspace / ".claude" / "settings.json").read_text())
assert any("agnes pull" in h["hooks"][0]["command"]
for h in settings["hooks"]["SessionStart"])
assert any("agnes push" in h["hooks"][0]["command"]
for h in settings["hooks"]["SessionEnd"])
claude_md = (workspace / "CLAUDE.md").read_text()
assert "agnes pull" in claude_md
assert "da sync" not in claude_md
workspace_md = (workspace / "AGNES_WORKSPACE.md").read_text()
assert test_pat not in workspace_md, "PAT must not leak into AGNES_WORKSPACE.md"
for placeholder in ["{created_at}", "{server_url}", "{workspace_path}"]:
assert placeholder not in workspace_md, f"placeholder leaked: {placeholder}"
assert fastapi_test_server.url in workspace_md
assert str(workspace) in workspace_md
assert "agnes pull" in workspace_md
def test_clean_install_zero_grants(fastapi_test_server, tmp_path, test_pat_no_grants):
workspace = tmp_path / "ws"
workspace.mkdir()
subprocess.run([
"da", "init",
"--server-url", fastapi_test_server.url,
"--token", test_pat_no_grants,
"--workspace", str(workspace),
], check=True)
must_exist = {"CLAUDE.md", "AGNES_WORKSPACE.md",
".claude/settings.json", ".claude/CLAUDE.local.md",
"user/duckdb/analytics.duckdb"}
must_not_exist = {".claude/rules", "server/parquet", "data/parquet",
"data/duckdb", "data/metadata", "user/artifacts",
"user/sessions", "user/snapshots", ".agnes"}
for p in must_exist:
assert (workspace / p).exists(), f"missing: {p}"
for p in must_not_exist:
assert not (workspace / p).exists(), f"unexpected: {p}"
assert_no_dead_dirs(workspace)
def test_init_force_preserves_local_md(fastapi_test_server, tmp_path, test_pat):
workspace = tmp_path / "ws"
workspace.mkdir()
subprocess.run(["agnes", "init", "--server-url", fastapi_test_server.url,
"--token", test_pat, "--workspace", str(workspace)], check=True)
(workspace / ".claude" / "CLAUDE.local.md").write_text("# my private notes\n")
subprocess.run(["agnes", "init", "--server-url", fastapi_test_server.url,
"--token", test_pat, "--workspace", str(workspace),
"--force"], check=True)
assert "my private notes" in (workspace / ".claude" / "CLAUDE.local.md").read_text()
def test_readers_in_pre_init_dir(tmp_path):
"""Reader commands in a folder that never had `agnes init`. Friendly hints, no tracebacks."""
for cmd in [["agnes", "query", "SELECT 1"],
["agnes", "snapshot", "create", "x", "--as", "y", "--estimate"],
["agnes", "explore", "x"],
["agnes", "snapshot", "list"]]:
result = subprocess.run(cmd, cwd=tmp_path,
capture_output=True, text=True, timeout=15)
assert "Traceback" not in result.stderr
```
- [ ] **Step 2: Run**
```bash
pytest tests/test_clean_install_integration.py -v
```
- [ ] **Step 3: Commit**
```bash
git add tests/test_clean_install_integration.py
git commit -m "test: clean-install integration suite (minimal/zero grants, force, pre-init)"
```
---
### Task 23: Manual clean-install protocol — document in RELEASE_CHECKLIST.md
**Files:**
- Modify or create: `docs/RELEASE_CHECKLIST.md`
- [ ] **Step 1: Add the manual protocol from spec §5.5**
If `docs/RELEASE_CHECKLIST.md` exists, append; otherwise create with header:
```markdown
# Release Checklist
## Bootstrap path changes (mandatory pre-merge)
For any PR touching the analyst-bootstrap path (`agnes init`, `cli/lib/pull.py`,
`cli/lib/hooks.py`, `app/web/setup_instructions.py`, `/api/welcome`), run
this protocol locally before requesting review:
1. `git clean -fdx` in the repo (no build artifacts).
2. Boot FastAPI locally against a clean test instance state.
3. Empty terminal in `/tmp/test-analyst-1`. From the web `/setup?role=analyst`, paste prompt.
4. `tree -a /tmp/test-analyst-1` and compare with the expected tree from
`docs/superpowers/specs/2026-05-04-clean-analyst-bootstrap-design.md` §5.2.
5. `claude` in that folder. Three queries: "what tables can I see",
"SELECT count(*) FROM <t>", "show me last 5 rows of <t>". All must work
without further intervention.
6. `/exit`. Verify SessionEnd hook ran (server-side audit log shows `agnes push`;
`du -sh /tmp/test-analyst-1/user/sessions/` non-empty).
7. Second `claude` in same folder. Verify SessionStart hook fires
(`agnes pull` request in audit log).
8. Second workspace `/tmp/test-analyst-2` with the same PAT (within TTL).
Repeat 3-5. Verify global `~/.config/da/` is not duplicated; the second
workspace has its own DuckDB.
```
- [ ] **Step 2: Commit**
```bash
git add docs/RELEASE_CHECKLIST.md
git commit -m "docs: clean-install manual protocol in release checklist"
```
---
## Phase 6 — CHANGELOG and final verification
### Task 24: Update CHANGELOG
**Files:**
- Modify: `CHANGELOG.md`
- [ ] **Step 1: Add an entry under `[Unreleased]`**
Open `CHANGELOG.md`, find the topmost `## [Unreleased]` section (it sits above the most recent released version, currently `## [0.32.0]`). Add the entry from the spec §"CHANGELOG entry (preview)":
```markdown
## [Unreleased]
### Changed
- **BREAKING** CLI binary renamed from `da` to `agnes`. No backward-compat alias is shipped. Update shell aliases, hook commands in any pre-existing `.claude/settings.json`, scripts, and cron jobs. Reinstall via `uv tool install <wheel>`; the wheel now ships an `agnes` entry point.
- **BREAKING** Analyst bootstrap rewritten end-to-end. `da analyst setup` is removed; replaced by `agnes init` (non-interactive, requires `--server-url` and `--token`). `da sync` is split into `agnes pull` (refresh) and `agnes push` (upload). `da fetch` is folded into `agnes snapshot create`. `da metrics list/show` is folded into `agnes catalog --metrics`; `da metrics import/export/validate` move to `agnes admin metrics {import,export,validate}`. The `da analyst` namespace is removed; the workspace status command is now `agnes status`. The previous `da status` (server-health overview) becomes `agnes diagnose system`.
- **BREAKING** Workspace layout simplified. Removed: `data/parquet/`, `data/duckdb/`, `data/metadata/`, `user/artifacts/`. Canonical paths: `server/parquet/` (synced parquets), `user/duckdb/analytics.duckdb` (DuckDB views), `user/snapshots/` (ad-hoc snapshots), `user/sessions/` (recorded sessions).
- The `/setup` web page now branches on a `role` query parameter: `/setup?role=analyst` renders the analyst workspace bootstrap prompt; `/setup?role=admin` renders the admin CLI install prompt. `/install` continues to 302 to `/setup`.
- `CLAUDE.md` server-side template + repo-root `CLAUDE.md` updated to reference the new CLI verbs and workspace paths. The admin UI for the `claude_md_template` DB override (`/admin/workspace-prompt`) renders a yellow banner when the saved override contains legacy strings (`data/parquet/`, `da sync`, `da fetch`, `da analyst setup`, `da metrics list/show`); admins re-author and save to clear it. Migration is manual.
### Added
- `AGNES_WORKSPACE.md` — human-readable workspace docs file generated by `agnes init` in the workspace root. Documents global install, workspace layout, hooks, cheat sheet, uninstall recipe.
- PAT request body now accepts `scope: str = "general"` and `ttl_seconds: int | None = None` fields. PATs minted with `scope="bootstrap-analyst"` are TTL-clamped to ≤ 1 h server-side. Existing `expires_in_days` field continues to work; `ttl_seconds` wins when both are set. `ttl_seconds` upper bound is 315_360_000 (matches `expires_in_days <= 3650` cap).
- `cli/lib/` shared-library tree, with `cli/lib/pull.py:run_pull` (data-refresh primitive callable from both the Typer wrapper and `agnes init`) and `cli/lib/hooks.py:install_claude_hooks` (workspace-scoped Claude Code hook installer).
### Fixed
- `agnes pull` (formerly `da sync`) no longer creates `.claude/rules/` when the corporate-memory bundle is empty.
- `agnes pull` no longer creates `server/parquet/` when the manifest is empty.
- `agnes snapshot create` (formerly `da fetch`) no longer materializes an empty `user/duckdb/analytics.duckdb` when run before any `agnes pull`.
- Workspace `agnes status` reads from the canonical `server/parquet/` and `user/duckdb/analytics.duckdb` paths (was reading legacy `data/parquet/`, `data/metadata/last_sync.json`).
- `agnes init` and `agnes pull` errors now use the `cli/error_render.py` typed-error renderer (added in 0.32.0), so analyst-facing error UX matches the structured shape `agnes query --remote` already produces.
### Removed
- `da analyst setup`, `da analyst status`, `da sync`, `da fetch`. See "Changed" above for replacements.
- `da metrics` namespace as a top-level group (subcommands moved to `agnes catalog --metrics` for read-only views and `agnes admin metrics …` for write operations).
- Legacy workspace directories `data/parquet/`, `data/duckdb/`, `data/metadata/`, `user/artifacts/`. Existing analyst workspaces should be reinitialized with `agnes init --server-url ... --token ... --force` (a fresh empty folder is recommended).
### Internal
- `cli/lib/__init__.py` (empty) makes `cli/lib/` a proper package picked up by Hatchling for wheel inclusion.
- `tests/fixtures/analyst_bootstrap.py` — reusable test fixtures for clean-install verification.
```
- [ ] **Step 2: Commit**
```bash
git add CHANGELOG.md
git commit -m "docs(changelog): clean-analyst-bootstrap rewrite (BREAKING)"
```
---
### Task 25: Final verification — full test suite
**Files:** none (just runs)
- [ ] **Step 1: Run the full test suite**
```bash
pytest tests/ -v 2>&1 | tail -50
```
Expected: all PASS (or expected skips for fixtures that depend on slow/external resources).
- [ ] **Step 2: Lint check (if project has one)**
```bash
ruff check . 2>&1 | tail -20
```
If ruff reports issues in code we touched, fix them.
- [ ] **Step 3: Manually run the clean-install protocol per Task 23**
(If on-machine: do steps 1-8 from `docs/RELEASE_CHECKLIST.md`. If headless CI: skip — the integration tests cover this.)
- [ ] **Step 4: If everything passes, declare ready for review**
```bash
git log --oneline origin/main..HEAD
```
Expected: a clean linear history of one commit per task. Push the branch and open a PR using the Spec + this Plan as the PR body context.
```bash
git push -u origin zs/clean-analyst-bootstrap-spec
```
Then `gh pr create` with the spec + CHANGELOG entry + `### Test plan` per `CLAUDE.md` discipline.
---
## Self-Review (post-write)
After writing the plan, I reviewed it against the spec for:
1. **Spec coverage:**
- Server-side: PAT scope/TTL ✅ (Task 1), legacy-strings scan ✅ (Tasks 2, 5), setup_instructions analyst branch ✅ (Task 3), /setup?role= branching ✅ (Task 4), claude_md_template rewrite ✅ (Task 6).
- Client-side library: cli/lib/__init__.py + hooks.py ✅ (Task 7), cli/lib/pull.py ✅ (Task 8).
- CLI commands: pull ✅ (9), push ✅ (10), init ✅ (11), status ✅ (12), diagnose system ✅ (13), snapshot create ✅ (14), catalog --metrics ✅ (15), admin metrics ✅ (16).
- Wiring: hint texts ✅ (17), main.py + deletes ✅ (18), CLAUDE.md ✅ (19).
- Tests: fixtures ✅ (20), reader smoke ✅ (21), clean install ✅ (22), manual protocol ✅ (23).
- CHANGELOG ✅ (24), final verification ✅ (25).
2. **Placeholder scan:** No "TBD"/"TODO" remain. Each step has the actual code or shell command. The `cli/commands/admin_metrics.py` task says "lift X from metrics.py verbatim" rather than restating — that's intentional since the engineer can `git show HEAD~N:cli/commands/metrics.py` to see exactly what to copy.
3. **Type consistency:** `PullResult` shape consistent between `cli/lib/pull.py` and `cli/commands/pull.py`. `install_claude_hooks` signature `(workspace: Path) -> None` consistent across hooks.py + init.py. `_LEGACY_STRINGS` tuple shape used identically in tests and module.
4. **Known fragility:** Some shell-based test assertions (Task 5 `legacy-banner` div presence) are heuristic; implementer may need to tighten once HTML lands. Marked with comment.
5. **Open questions in spec stay in spec:** Per-endpoint PAT scope enforcement (deferred), layered config (deferred), hook performance budget (monitoring-only), anti-coupling test (deferred). Not in this plan.
The plan is implementable. Tasks are roughly ordered by dependency: Phase 1 server foundation, Phase 2 client library, Phase 3 commands, Phase 4 wiring/cleanup, Phase 5 fixtures + tests, Phase 6 changelog/verification.