docs(spec+plan): rename CLI binary from da to agnes (BREAKING)

- Spec rev 5: branding consistency. New CLI verbs use agnes prefix
  (agnes init, agnes pull, agnes push, agnes catalog, agnes status,
  agnes snapshot create, agnes admin, …).
- Plan: add Phase 0 / Task 0 — pyproject.toml [project.scripts] entry
  rename to "agnes = cli.main:app" + Typer(name="agnes") in cli/main.py.
- Legacy command references (da sync, da fetch, da analyst setup,
  da metrics) keep their da prefix throughout — they're historical
  artifacts being removed (preserved in CHANGELOG Removed section,
  _LEGACY_STRINGS constant for admin override scan, etc.).

Bulk rename via Python regex with verb whitelist: 286 verb refs
rewritten in plan, 265 in spec; 104+72 legacy refs restored to "da"
post-pass (false positives where the doc was describing the legacy
flow being replaced).
This commit is contained in:
ZdenekSrotyr 2026-05-04 15:50:44 +02:00
parent fb8f55c335
commit 5e7fa418d1
2 changed files with 370 additions and 281 deletions

View file

@ -4,11 +4,13 @@
**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. `da 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 `da 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).
**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 4, cleared for implementation).
**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`.
---
@ -21,13 +23,13 @@
| `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` | `da init` Typer command — auth check, save config, write CLAUDE.md, install hooks, call `run_pull`, write `AGNES_WORKSPACE.md`. |
| `cli/commands/pull.py` | `da pull` Typer wrapper around `cli/lib/pull.py:run_pull`. Flags `--quiet`, `--json`, `--dry-run`. |
| `cli/commands/push.py` | `da push` Typer command — uploads `user/sessions/*.jsonl` and `.claude/CLAUDE.local.md`. |
| `cli/commands/admin_metrics.py` | `da admin metrics {import,export,validate}` sub-Typer (lifted from `cli/commands/metrics.py`). |
| `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 `da pull`/`da sync` entries). |
| `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`. |
@ -41,18 +43,18 @@
|---|---|
| `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 `da` → `da init --server-url X --token Y --workspace .` → `da catalog` smoke verify → confirm. Drop for analyst: marketplace, plugins, skills, diagnose, login, whoami. |
| `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``da pull`, `da fetch``da snapshot create`, `da metrics list/show``da catalog --metrics`, `da analyst setup``da init`. Path strings: `data/parquet/``server/parquet/`, `data/duckdb/...``user/duckdb/analytics.duckdb`. |
| `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: da pull" in two places. |
| `cli/commands/explore.py` | Update hint text "Run: da sync" → "Run: da pull". |
| `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``da snapshot create`. |
| `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. |
@ -63,14 +65,99 @@
| `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 `da catalog --metrics`; write paths move to `cli/commands/admin_metrics.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 `da status` ("System status") | Renamed to `da diagnose system` (subcommand under `diagnose_app`) — its content is a subset of what `da diagnose` already does. |
| New `da status` | Workspace status — fresh implementation, replaces `da analyst status`. Lives in `cli/commands/status.py` (overwrite). |
| 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)"
```
---
@ -341,7 +428,7 @@ def test_scan_finds_all_known_legacy_strings():
def test_scan_returns_empty_for_clean_text():
text = "Use `da pull` to refresh, `da snapshot create` for ad-hoc, `server/parquet/`."
text = "Use `agnes pull` to refresh, `agnes snapshot create` for ad-hoc, `server/parquet/`."
assert _scan_legacy_strings(text) == []
@ -442,7 +529,7 @@ def test_admin_get_template_returns_legacy_strings_when_override_dirty(web_sessi
def test_admin_get_template_returns_empty_when_clean(web_session):
web_session.put("/api/admin/workspace-prompt-template",
json={"content": "Use `da pull` and check `server/parquet/`."})
json={"content": "Use `agnes pull` and check `server/parquet/`."})
resp = web_session.get("/api/admin/workspace-prompt-template")
assert resp.json()["legacy_strings_detected"] == []
```
@ -490,15 +577,15 @@ def test_render_analyst_role_basic():
)
# Required content for analyst role:
assert "uv tool install" in text
assert "da init" 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 "da catalog" in text # smoke verify step
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 "da skills install" not in text # analyst doesn't bulk-install skills
assert "da diagnose" not in text # analyst smoke verify is `da catalog`, not diagnose
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():
@ -509,8 +596,8 @@ def test_render_admin_role_unchanged():
wheel_filename="agnes-0.32.0-py3-none-any.whl",
# role omitted — defaults to "admin"
)
assert "da auth import-token" in text # admin uses import-token, not da init
assert "da diagnose" in text # admin keeps diagnose
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():
@ -524,7 +611,7 @@ def test_render_analyst_with_ca_pem():
)
assert "AGNES_CA_PEM" in text # heredoc marker from trust block
assert "ca-bundle.pem" in text
assert "da init" in text # analyst-specific step still present
assert "agnes init" in text # analyst-specific step still present
```
- [ ] **Step 2: Run tests to verify they fail**
@ -541,10 +628,10 @@ 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 — `da init` (auth + workspace bootstrap) + smoke verify.
"""Steps 2-3 — `agnes init` (auth + workspace bootstrap) + smoke verify.
Replaces the admin-flow login + verify steps (today: `da auth import-token`
+ `da auth whoami`). `da init` is non-interactive: `--token` carries the PAT,
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.
@ -552,14 +639,14 @@ def _analyst_init_lines(server_url_placeholder: str = "{server_url}") -> list[st
return [
"",
"2) Bootstrap your analyst workspace in this directory:",
f" da init --server-url \"{server_url_placeholder}\" --token \"{{token}}\" --workspace .",
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 `da pull` so your DuckDB views are ready.",
" initial `agnes pull` so your DuckDB views are ready.",
"",
"3) Verify the data is queryable:",
" da catalog",
" 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.",
@ -570,8 +657,8 @@ 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 = [
" - `da --version` output",
" - First few lines of `da catalog` (tables you can see)",
" - `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",
]
@ -851,7 +938,7 @@ def test_admin_prompt_template_renders_banner_when_legacy_present(web_session):
def test_admin_prompt_template_no_banner_when_clean(web_session):
web_session.put("/api/admin/workspace-prompt-template",
json={"content": "Run `da pull` daily."})
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
@ -935,11 +1022,11 @@ wc -l config/claude_md_template.txt
- [ ] **Step 2: Apply systematic rewrites**
Replace throughout the file:
- `da sync``da pull` (everywhere)
- `da analyst setup``da init` (everywhere)
- `da fetch``da snapshot create`
- `da metrics list``da catalog --metrics`
- `da metrics show``da catalog --metrics --show`
- `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)
@ -948,12 +1035,12 @@ Use `sed`:
```bash
sed -i.bak \
-e 's|da sync --upload-only|da push|g' \
-e 's|da sync|da pull|g' \
-e 's|da analyst setup|da init|g' \
-e 's|da fetch|da snapshot create|g' \
-e 's|da metrics list|da catalog --metrics|g' \
-e 's|da metrics show|da catalog --metrics --show|g' \
-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
@ -974,7 +1061,7 @@ Expected: no matches.
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 `da init` installed, where files live, and how to uninstall.
> 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)**
@ -1020,9 +1107,9 @@ def test_install_creates_settings_file(tmp_path):
install_claude_hooks(tmp_path)
cfg = _read_settings(tmp_path)
assert cfg["hooks"]["SessionStart"]
assert "da pull --quiet" in cfg["hooks"]["SessionStart"][0]["hooks"][0]["command"]
assert "agnes pull --quiet" in cfg["hooks"]["SessionStart"][0]["hooks"][0]["command"]
assert cfg["hooks"]["SessionEnd"]
assert "da push --quiet" in cfg["hooks"]["SessionEnd"][0]["hooks"][0]["command"]
assert "agnes push --quiet" in cfg["hooks"]["SessionEnd"][0]["hooks"][0]["command"]
def test_install_idempotent(tmp_path):
@ -1047,7 +1134,7 @@ def test_install_replaces_old_da_sync_entries(tmp_path):
install_claude_hooks(tmp_path)
cfg = _read_settings(tmp_path)
assert len(cfg["hooks"]["SessionStart"]) == 1
assert "da pull" in cfg["hooks"]["SessionStart"][0]["hooks"][0]["command"]
assert "agnes pull" in cfg["hooks"]["SessionStart"][0]["hooks"][0]["command"]
assert "da sync" not in cfg["hooks"]["SessionStart"][0]["hooks"][0]["command"]
@ -1062,10 +1149,10 @@ def test_install_preserves_third_party_hooks(tmp_path):
}))
install_claude_hooks(tmp_path)
cfg = _read_settings(tmp_path)
# Third-party SessionStart entry survives; our da pull entry appended
# 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("da pull" 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"
@ -1109,14 +1196,14 @@ touch cli/lib/__init__.py
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 `da init` and any future caller
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 `da pull` / `da sync` /
`da push` entry (matched by command substring) and appends fresh entries.
- 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.
"""
@ -1127,11 +1214,11 @@ import json
import sys
from pathlib import Path
_OUR_COMMAND_MARKERS = ("da pull", "da push", "da sync")
_OUR_COMMAND_MARKERS = ("agnes pull", "agnes push", "da sync")
def install_claude_hooks(workspace: Path) -> None:
"""Install SessionStart→`da pull` and SessionEnd→`da push` hooks.
"""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.
@ -1165,8 +1252,8 @@ def install_claude_hooks(workspace: Path) -> None:
existing.remove(entry)
existing.append({"hooks": [{"type": "command", "command": command}]})
_replace_or_add("SessionStart", "da pull --quiet 2>/dev/null || true")
_replace_or_add("SessionEnd", "da push --quiet 2>/dev/null || true")
_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")
```
@ -1313,7 +1400,7 @@ Lift the body of today's `cli/commands/sync.py:sync()` into a pure function. Spe
```python
# cli/lib/pull.py
"""Pure-function data-refresh primitive — used by `da pull` and `da init`.
"""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.
@ -1537,7 +1624,7 @@ git commit -m "feat(cli-lib): cli/lib/pull.py:run_pull primitive with lazy mkdir
## Phase 3 — New CLI commands
### Task 9: `da pull` Typer wrapper
### Task 9: `agnes pull` Typer wrapper
**Files:**
- Create: `cli/commands/pull.py`
@ -1547,7 +1634,7 @@ git commit -m "feat(cli-lib): cli/lib/pull.py:run_pull primitive with lazy mkdir
```python
# tests/test_cli_pull.py
"""Tests for `da pull` Typer wrapper."""
"""Tests for `agnes pull` Typer wrapper."""
from typer.testing import CliRunner
from cli.commands.pull import pull_app
@ -1575,11 +1662,11 @@ Expected: ImportError.
```python
# cli/commands/pull.py
"""`da pull` — refresh registered data into the workspace.
"""`agnes pull` — refresh registered data into the workspace.
Thin Typer wrapper around `cli/lib/pull.py:run_pull`. Used by:
- Manual invocation: analyst types `da pull` to force a refresh.
- SessionStart hook: `da pull --quiet 2>/dev/null || true` runs at the start
- 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.
"""
@ -1610,7 +1697,7 @@ def pull(
if not server_url:
typer.echo(render_error(0, {"detail": {
"kind": "server_unreachable",
"hint": "No server configured. Run: da init --server-url <URL> --token <PAT>",
"hint": "No server configured. Run: agnes init --server-url <URL> --token <PAT>",
}}), err=True)
raise typer.Exit(1)
@ -1618,7 +1705,7 @@ def pull(
if not token:
typer.echo(render_error(0, {"detail": {
"kind": "auth_failed",
"hint": "No token. Run: da auth import-token --token <PAT>",
"hint": "No token. Run: agnes auth import-token --token <PAT>",
}}), err=True)
raise typer.Exit(1)
@ -1669,12 +1756,12 @@ Expected: PASS.
```bash
git add cli/commands/pull.py tests/test_cli_pull.py
git commit -m "feat(cli): da pull command (Typer wrapper around lib.pull.run_pull)"
git commit -m "feat(cli): agnes pull command (Typer wrapper around lib.pull.run_pull)"
```
---
### Task 10: `da push` command (extract from `da sync --upload-only`)
### Task 10: `agnes push` command (extract from `da sync --upload-only`)
**Files:**
- Create: `cli/commands/push.py`
@ -1719,10 +1806,10 @@ pytest tests/test_cli_push.py -v
```python
# cli/commands/push.py
"""`da push` — upload local sessions and CLAUDE.local.md to the server.
"""`agnes push` — upload local sessions and CLAUDE.local.md to the server.
Extracted from today's `da sync --upload-only`. Hook command:
`da push --quiet 2>/dev/null || true` (runs at SessionEnd).
`agnes push --quiet 2>/dev/null || true` (runs at SessionEnd).
"""
from __future__ import annotations
@ -1752,7 +1839,7 @@ def push(
if not server_url or not token:
typer.echo(render_error(0, {"detail": {
"kind": "auth_failed",
"hint": "No server/token configured. Run: da init",
"hint": "No server/token configured. Run: agnes init",
}}), err=True)
raise typer.Exit(1)
@ -1809,12 +1896,12 @@ pytest tests/test_cli_push.py -v
```bash
git add cli/commands/push.py tests/test_cli_push.py
git commit -m "feat(cli): da push command (extracted from sync --upload-only)"
git commit -m "feat(cli): agnes push command (extracted from sync --upload-only)"
```
---
### Task 11: `da init` — workspace bootstrap orchestrator
### Task 11: `agnes init` — workspace bootstrap orchestrator
**Files:**
- Create: `cli/commands/init.py`, `config/agnes_workspace_template.txt`
@ -1831,7 +1918,7 @@ Create `config/agnes_workspace_template.txt`:
**Server:** {server_url}
**Workspace:** {workspace_path}
This file documents what `da init` installed on this machine and in this folder.
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`.
@ -1841,7 +1928,7 @@ Read this when you want to know "what is this thing", "how does it work", or
| Path | What it is | How to remove |
|------|------------|---------------|
| `~/.local/bin/da` | The `da` CLI binary | `uv tool uninstall agnes-the-ai-analyst` |
| `~/.local/bin/da` | 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` |
@ -1860,11 +1947,11 @@ Read this when you want to know "what is this thing", "how does it work", or
| `./.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 `da query` reads |
| `./user/snapshots/*.parquet` | Ad-hoc materialized snapshots from `da snapshot create` |
| `./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 — `da pull` and `da push`
Some folders only exist when they have content — `agnes pull` and `agnes push`
only create them when there's something to write.
---
@ -1874,10 +1961,10 @@ only create them when there's something to write.
Two hooks in `./.claude/settings.json` keep this workspace in sync without
you doing anything:
- **SessionStart**`da pull --quiet` — new parquets, schema changes, and
- **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**`da push --quiet` — your session transcript and
- **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.
@ -1888,33 +1975,33 @@ Both are workspace-scoped — they only run when Claude Code opens this folder.
```bash
# Tables you can read (server-side catalog, RBAC-filtered)
da catalog
da catalog --json | jq '.[] | select(.query_mode=="local")'
agnes catalog
agnes catalog --json | jq '.[] | select(.query_mode=="local")'
# Schema and sample
da schema opportunity
da describe opportunity -n 10
agnes schema opportunity
agnes describe opportunity -n 10
# Run a SQL query (DuckDB flavor against local parquets)
da query "SELECT count(*) FROM opportunity WHERE stage='Closed Won'"
agnes query "SELECT count(*) FROM opportunity WHERE stage='Closed Won'"
# Remote BigQuery query (server-side, no local materialization)
da query --remote "SELECT count(*) FROM web_sessions_example"
agnes query --remote "SELECT count(*) FROM web_sessions_example"
# Materialize a remote subset locally
da snapshot create web_sessions_example \
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)
da pull
agnes pull
# Workspace status (what's synced, when)
da status
agnes status
# Re-generate this workspace from scratch (preserves CLAUDE.local.md)
da init --server-url https://agnes.example.com --token <PAT> --force
agnes init --server-url https://agnes.example.com --token <PAT> --force
```
---
@ -1938,11 +2025,11 @@ rm -rf ./CLAUDE.md ./AGNES_WORKSPACE.md ./.claude ./server ./user
```
```
- [ ] **Step 2: Write failing tests for `da init`**
- [ ] **Step 2: Write failing tests for `agnes init`**
```python
# tests/test_cli_init.py
"""Tests for `da init` orchestrator command."""
"""Tests for `agnes init` orchestrator command."""
import json
from pathlib import Path
@ -1973,7 +2060,7 @@ def test_init_writes_expected_files(tmp_path, monkeypatch):
if path == "/api/catalog/tables":
resp.json.return_value = []
elif path == "/api/welcome":
resp.json.return_value = {"content": "# Test CLAUDE.md\n\nUse `da pull`.\n"}
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":
@ -1989,7 +2076,7 @@ def test_init_writes_expected_files(tmp_path, monkeypatch):
])
assert result.exit_code == 0, result.output
assert (tmp_path / "CLAUDE.md").exists()
assert "da pull" in (tmp_path / "CLAUDE.md").read_text()
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()
@ -2057,10 +2144,10 @@ pytest tests/test_cli_init.py -v
```python
# cli/commands/init.py
"""`da init` — bootstrap an analyst workspace.
"""`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 `da init` (among other
pastes into Claude Code in an empty folder; Claude runs `agnes init` (among other
steps). Non-interactive: --token + --server-url required.
"""
@ -2152,7 +2239,7 @@ def init(
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 `da push`.\n",
"# My Notes\n\nPersonal notes for this workspace. Uploaded on `agnes push`.\n",
encoding="utf-8",
)
@ -2187,7 +2274,7 @@ def init(
typer.echo(f" Rules : {result.rules_count}")
typer.echo(f" Workspace: {workspace}")
typer.echo("")
typer.echo("Try: da catalog")
typer.echo("Try: agnes catalog")
```
- [ ] **Step 5: Run tests to verify they pass**
@ -2200,12 +2287,12 @@ pytest tests/test_cli_init.py -v
```bash
git add cli/commands/init.py config/agnes_workspace_template.txt tests/test_cli_init.py
git commit -m "feat(cli): da init orchestrator + AGNES_WORKSPACE.md template"
git commit -m "feat(cli): agnes init orchestrator + AGNES_WORKSPACE.md template"
```
---
### Task 12: New `da status` (workspace status, replaces `da analyst status`)
### Task 12: New `agnes status` (workspace status, replaces `da analyst status`)
**Files:**
- Modify (overwrite): `cli/commands/status.py`
@ -2217,7 +2304,7 @@ git commit -m "feat(cli): da init orchestrator + AGNES_WORKSPACE.md template"
cat cli/commands/status.py
```
The existing `da status` shows server health. Per spec, this content moves to `da diagnose system` (Task 13); the file is repurposed for workspace status.
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**
@ -2272,7 +2359,7 @@ pytest tests/test_cli_status.py -v
```python
# cli/commands/status.py
"""`da status` — workspace status: initialized? data fresh? hooks active?"""
"""`agnes status` — workspace status: initialized? data fresh? hooks active?"""
from __future__ import annotations
@ -2334,7 +2421,7 @@ def status(
if not initialized:
typer.echo("")
typer.echo("Run `da init --server-url <URL> --token <PAT>` to bootstrap.")
typer.echo("Run `agnes init --server-url <URL> --token <PAT>` to bootstrap.")
```
- [ ] **Step 5: Run tests to verify they pass**
@ -2347,18 +2434,18 @@ pytest tests/test_cli_status.py -v
```bash
git add cli/commands/status.py tests/test_cli_status.py
git commit -m "feat(cli): da status now shows workspace state (was system health)"
git commit -m "feat(cli): agnes status now shows workspace state (was system health)"
```
---
### Task 13: Move old `da status` content into `da diagnose system`
### 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 `da status` logic**
- [ ] **Step 1: Recover old `agnes status` logic**
```bash
git show HEAD~12:cli/commands/status.py
@ -2394,12 +2481,12 @@ def test_diagnose_system_help():
```bash
git add cli/commands/diagnose.py tests/test_cli_diagnose_system.py
git commit -m "refactor(cli): move old `da status` health check to `da diagnose system`"
git commit -m "refactor(cli): move old `agnes status` health check to `agnes diagnose system`"
```
---
### Task 14: `da snapshot create` — fold `da fetch` into snapshot group
### Task 14: `agnes snapshot create` — fold `da fetch` into snapshot group
**Files:**
- Modify: `cli/commands/snapshot.py` (add `create` subcommand)
@ -2419,7 +2506,7 @@ Move the body of `fetch.py:fetch()` into a new `@snapshot_app.command("create")`
```python
local_db = _local_dir() / "user" / "duckdb" / "analytics.duckdb"
if not local_db.exists():
typer.echo("Local DuckDB not found. Run: da pull first.", err=True)
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)
```
@ -2445,7 +2532,7 @@ def test_snapshot_create_no_duckdb_friendly_exit(tmp_path, monkeypatch):
runner = CliRunner()
result = runner.invoke(snapshot_app, ["create", "any_table", "--as", "x", "--estimate"])
assert result.exit_code == 1
assert "Run: da pull" in result.output or "Run: da pull" in (result.stderr or "")
assert "Run: agnes pull" in result.output or "Run: agnes pull" in (result.stderr or "")
```
- [ ] **Step 4: Run tests**
@ -2458,12 +2545,12 @@ pytest tests/test_cli_snapshot_create.py -v
```bash
git add cli/commands/snapshot.py tests/test_cli_snapshot_create.py
git commit -m "feat(cli): da snapshot create (folded from da fetch); friendly exit if no DuckDB"
git commit -m "feat(cli): agnes snapshot create (folded from da fetch); friendly exit if no DuckDB"
```
---
### Task 15: `da catalog --metrics` — fold `da metrics list/show`
### Task 15: `agnes catalog --metrics` — fold `da metrics list/show`
**Files:**
- Modify: `cli/commands/catalog.py`
@ -2515,12 +2602,12 @@ def test_catalog_metrics_help():
```bash
git add cli/commands/catalog.py tests/test_cli_catalog_metrics.py
git commit -m "feat(cli): da catalog --metrics replaces da metrics list/show"
git commit -m "feat(cli): agnes catalog --metrics replaces da metrics list/show"
```
---
### Task 16: Move `da metrics import/export/validate` to `da admin metrics`
### Task 16: Move `da metrics import/export/validate` to `agnes admin metrics`
**Files:**
- Create: `cli/commands/admin_metrics.py`
@ -2532,7 +2619,7 @@ Lift `import_metrics`, `export_metrics`, `validate_metrics` from `cli/commands/m
```python
# cli/commands/admin_metrics.py
"""`da admin metrics {import,export,validate}` — lifted from metrics.py."""
"""`agnes admin metrics {import,export,validate}` — lifted from metrics.py."""
import typer
@ -2589,7 +2676,7 @@ def test_admin_metrics_subcommands_present():
```bash
git add cli/commands/admin_metrics.py cli/commands/admin.py tests/test_cli_admin_metrics.py
git commit -m "feat(cli): da admin metrics {import,export,validate}"
git commit -m "feat(cli): agnes admin metrics {import,export,validate}"
```
---
@ -2607,10 +2694,10 @@ git commit -m "feat(cli): da admin metrics {import,export,validate}"
grep -rn "Run: da sync" cli/
```
- [ ] **Step 2: Replace with "Run: da pull"**
- [ ] **Step 2: Replace with "Run: agnes pull"**
```bash
sed -i.bak 's/Run: da sync/Run: da pull/g' cli/commands/query.py cli/commands/explore.py
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
```
@ -2626,7 +2713,7 @@ Expected: no matches.
```bash
git add cli/commands/query.py cli/commands/explore.py
git commit -m "fix(cli): hint text 'Run: da sync' → 'Run: da pull'"
git commit -m "fix(cli): hint text 'Run: da sync' → 'Run: agnes pull'"
```
---
@ -2729,13 +2816,13 @@ git commit -m "refactor(cli): drop sync/fetch/analyst/metrics; register init/pul
```bash
sed -i.bak \
-e 's|da sync --upload-only|da push|g' \
-e 's|da sync|da pull|g' \
-e 's|da analyst setup|da init|g' \
-e 's|da fetch|da snapshot create|g' \
-e 's|da metrics list|da catalog --metrics|g' \
-e 's|da metrics show|da catalog --metrics --show|g' \
-e 's|da metrics import|da admin metrics import|g' \
-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
@ -2744,24 +2831,24 @@ 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 `da pull` + `da push` hooks:
Find the section. Replace the surrounding prose so it describes `agnes pull` + `agnes push` hooks:
```markdown
### Local sync & Claude Code hooks
`da pull` is the canonical analyst-side distribution path: pulls the
`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.
`da push` mirrors it for the upload direction (sessions, CLAUDE.local.md).
`agnes push` mirrors it for the upload direction (sessions, CLAUDE.local.md).
`da init` writes two hooks into `<workspace>/.claude/settings.json`:
`agnes init` writes two hooks into `<workspace>/.claude/settings.json`:
- `SessionStart``da pull --quiet` — pulls fresh parquets at the start of every Claude Code session
- `SessionEnd``da push --quiet` — uploads session jsonl + `CLAUDE.local.md` to the server
- `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 → `da pull` downloads it. No per-user sync config; the admin layer is the single source of truth.
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**
@ -2960,7 +3047,7 @@ def test_pat_no_grants(web_session) -> str:
@pytest.fixture
def zero_grants_workspace(tmp_path, fastapi_test_server, test_pat_no_grants) -> Path:
"""Run `da init` against a no-grants PAT; return the workspace path."""
"""Run `agnes init` against a no-grants PAT; return the workspace path."""
workspace = tmp_path / "workspace"
workspace.mkdir()
result = subprocess.run([
@ -3090,7 +3177,7 @@ git commit -m "test: reader smoke matrix on zero-grants workspace"
```python
# tests/test_clean_install_integration.py
"""End-to-end clean-install integration tests for `da init`."""
"""End-to-end clean-install integration tests for `agnes init`."""
import json
import subprocess
@ -3133,13 +3220,13 @@ def test_clean_install_minimal_grants(fastapi_test_server, tmp_path, test_pat):
assert_no_dead_dirs(workspace)
settings = json.loads((workspace / ".claude" / "settings.json").read_text())
assert any("da pull" in h["hooks"][0]["command"]
assert any("agnes pull" in h["hooks"][0]["command"]
for h in settings["hooks"]["SessionStart"])
assert any("da push" in h["hooks"][0]["command"]
assert any("agnes push" in h["hooks"][0]["command"]
for h in settings["hooks"]["SessionEnd"])
claude_md = (workspace / "CLAUDE.md").read_text()
assert "da pull" in claude_md
assert "agnes pull" in claude_md
assert "da sync" not in claude_md
workspace_md = (workspace / "AGNES_WORKSPACE.md").read_text()
@ -3148,7 +3235,7 @@ def test_clean_install_minimal_grants(fastapi_test_server, tmp_path, test_pat):
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 "da pull" in workspace_md
assert "agnes pull" in workspace_md
def test_clean_install_zero_grants(fastapi_test_server, tmp_path, test_pat_no_grants):
@ -3188,7 +3275,7 @@ def test_init_force_preserves_local_md(fastapi_test_server, tmp_path, test_pat):
def test_readers_in_pre_init_dir(tmp_path):
"""Reader commands in a folder that never had `da init`. Friendly hints, no tracebacks."""
"""Reader commands in a folder that never had `agnes init`. Friendly hints, no tracebacks."""
for cmd in [["da", "query", "SELECT 1"],
["da", "snapshot", "create", "x", "--as", "y", "--estimate"],
["da", "explore", "x"],
@ -3227,7 +3314,7 @@ If `docs/RELEASE_CHECKLIST.md` exists, append; otherwise create with header:
## Bootstrap path changes (mandatory pre-merge)
For any PR touching the analyst-bootstrap path (`da init`, `cli/lib/pull.py`,
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:
@ -3239,10 +3326,10 @@ this protocol locally before requesting review:
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 `da push`;
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
(`da pull` request in audit log).
(`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.
@ -3272,27 +3359,27 @@ Open `CHANGELOG.md`, find the topmost `## [Unreleased]` section (it sits above t
## [Unreleased]
### Changed
- **BREAKING** Analyst bootstrap rewritten end-to-end. `da analyst setup` is removed; replaced by `da init` (non-interactive, requires `--server-url` and `--token`). `da sync` is split into `da pull` (refresh) and `da push` (upload). `da fetch` is folded into `da snapshot create`. `da metrics list/show` is folded into `da catalog --metrics`; `da metrics import/export/validate` move to `da admin metrics {import,export,validate}`. The `da analyst` namespace is removed; the workspace status command is now `da status`. The previous `da status` (server-health overview) becomes `da diagnose system`.
- **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 `agnes 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 `da init` in the workspace root. Documents global install, workspace layout, hooks, cheat sheet, uninstall recipe.
- `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 `da init`) and `cli/lib/hooks.py:install_claude_hooks` (workspace-scoped Claude Code hook installer).
- `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
- `da pull` (formerly `da sync`) no longer creates `.claude/rules/` when the corporate-memory bundle is empty.
- `da pull` no longer creates `server/parquet/` when the manifest is empty.
- `da snapshot create` (formerly `da fetch`) no longer materializes an empty `user/duckdb/analytics.duckdb` when run before any `da pull`.
- Workspace `da status` reads from the canonical `server/parquet/` and `user/duckdb/analytics.duckdb` paths (was reading legacy `data/parquet/`, `data/metadata/last_sync.json`).
- `da init` and `da 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 `da query --remote` already produces.
- `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 `da catalog --metrics` for read-only views and `da admin metrics …` for write operations).
- Legacy workspace directories `data/parquet/`, `data/duckdb/`, `data/metadata/`, `user/artifacts/`. Existing analyst workspaces should be reinitialized with `da init --server-url ... --token ... --force` (a fresh empty folder is recommended).
- `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.

View file

@ -1,10 +1,12 @@
# Clean analyst bootstrap — design
**Date:** 2026-05-04 (revision 4 — round-3 review fixes; cleared for implementation)
**Date:** 2026-05-04 (revision 5 — CLI binary renamed from `da` to `agnes` for branding consistency)
**Branch:** `zs/clean-analyst-bootstrap-spec`
**Status:** Draft (approved by user, pre-implementation)
**Successor to:** today's `da analyst setup` flow (interactive email/password) and the empty-folder bug under `da sync`.
**CLI binary rename:** As part of this rewrite the CLI binary changes from `da` to `agnes`. References to legacy command names (`da sync`, `da fetch`, `da analyst setup`, `da metrics`) keep their `da` prefix throughout this document — they're historical artifacts being removed. New commands use `agnes` (`agnes init`, `agnes pull`, `agnes push`, `agnes catalog`, …).
## Problem
A new analyst should be able to:
@ -12,9 +14,9 @@ A new analyst should be able to:
1. Sign in to the Agnes web UI.
2. Click a button on `/setup?role=analyst`, copy a single Claude-Code-paste prompt to the clipboard.
3. In an empty terminal, in an empty folder, paste the prompt into Claude Code.
4. Have Claude Code do **all** of the local setup — install the `da` CLI, trust the server's TLS cert (when needed), authenticate, generate `CLAUDE.md`, install Claude Code hooks, pull the RBAC-allowed parquets, build the local DuckDB views, write a human-readable workspace docs file.
4. Have Claude Code do **all** of the local setup — install the `agnes` CLI, trust the server's TLS cert (when needed), authenticate, generate `CLAUDE.md`, install Claude Code hooks, pull the RBAC-allowed parquets, build the local DuckDB views, write a human-readable workspace docs file.
5. Immediately start asking questions about the data — without ever typing a follow-up command.
6. From the second session onwards, have data freshness handled automatically by hooks (no `da pull` ever typed by hand).
6. From the second session onwards, have data freshness handled automatically by hooks (no `agnes pull` ever typed by hand).
Today this flow does not exist. The closest piece (`da analyst setup` in `cli/commands/analyst.py`) is interactive (prompts for email + password), produces a workspace layout that does not match what `da sync` later writes (the `data/parquet/`, `data/duckdb/`, `data/metadata/`, `user/artifacts/` directories it creates are never read by anything; `da sync` writes parquets to a sibling `server/parquet/` and DuckDB to `user/duckdb/analytics.duckdb`), and never registers the SessionStart/End hooks unless the analyst already managed to authenticate.
@ -58,11 +60,11 @@ In addition, `da sync` itself creates empty directories (`.claude/rules/` is `mk
│ │
│ paste prompt; Claude runs: │
│ 0. (TLS trust if needed) │
│ 1. uv tool install da
│ 2. da init │
│ 1. uv tool install <wheel> # binary: agnes
│ 2. agnes init │
│ --server-url URL │
│ --token PAT │
│ 3. da catalog (smoke) │
│ 3. agnes catalog (smoke) │
└─────────────┬────────────────┘
@ -75,8 +77,8 @@ In addition, `da sync` itself creates empty directories (`.claude/rules/` is `mk
│ ├── AGNES_WORKSPACE.md (human docs) │
│ ├── .claude/ │
│ │ ├── settings.json (model, perms, │
│ │ │ hooks: SessionStart→`da pull`, │
│ │ │ SessionEnd →`da push`) │
│ │ │ hooks: SessionStart→`agnes pull`, │
│ │ │ SessionEnd →`agnes push`) │
│ │ ├── CLAUDE.local.md (stub) │
│ │ └── rules/km_*.md (only if non-empty) │
│ ├── server/parquet/*.parquet │
@ -88,80 +90,80 @@ In addition, `da sync` itself creates empty directories (`.claude/rules/` is `mk
│ └── sessions/*.jsonl (lazy, on push)│
└────────────────────────────────────────────┘
next session: SessionStart hook → da pull (incremental MD5)
SessionEnd hook → da push (sessions + CLAUDE.local.md)
next session: SessionStart hook → agnes pull (incremental MD5)
SessionEnd hook → agnes push (sessions + CLAUDE.local.md)
```
Single source of truth for data path: `da pull`. `da init` is a thin orchestrator that does auth + writes templates + installs hooks + calls `da pull` once. No data-path code lives in `init`.
Single source of truth for data path: `agnes pull`. `agnes init` is a thin orchestrator that does auth + writes templates + installs hooks + calls `agnes pull` once. No data-path code lives in `init`.
Single source of truth for the install prompt: `app/web/setup_instructions.py`. New `role: Literal["analyst", "admin"]` parameter branches the step list. TLS trust block is the only piece shared between the two roles.
Single source of truth for `CLAUDE.md` content: server-side `/api/welcome`. `da init` fetches the rendered text rather than rendering from a client-side template. This means admin-published overrides (DB-stored at `claude_md_template` table, exposed via `/api/admin/workspace-prompt-template`) automatically flow to all analysts. Server-side default template (`config/claude_md_template.txt` or equivalent rendering source) and any DB override **both** need their path strings updated as part of this PR — see "Migration of admin override" in Components.
Single source of truth for `CLAUDE.md` content: server-side `/api/welcome`. `agnes init` fetches the rendered text rather than rendering from a client-side template. This means admin-published overrides (DB-stored at `claude_md_template` table, exposed via `/api/admin/workspace-prompt-template`) automatically flow to all analysts. Server-side default template (`config/claude_md_template.txt` or equivalent rendering source) and any DB override **both** need their path strings updated as part of this PR — see "Migration of admin override" in Components.
Config and PAT live globally per user at `~/.config/da/{config.yaml,token.json}`. There is no per-workspace config in this design.
## New CLI surface
The CLI is rewritten with mnemonic, non-overlapping verbs. There are no backward-compat aliases. Today's `da analyst *`, `da sync`, `da sync --upload-only`, `da fetch` are removed; `da metrics list/show` folds into `da catalog --metrics`; `da metrics import/export/validate` move under `da admin`. `da skills list/show` survive as analyst discovery commands; bulk-install variants (none today, but spec refuses to add them) stay out.
The CLI is rewritten with mnemonic, non-overlapping verbs. There are no backward-compat aliases. Today's `da analyst *`, `da sync`, `da sync --upload-only`, `da fetch` are removed; `da metrics list/show` folds into `agnes catalog --metrics`; `da metrics import/export/validate` move under `agnes admin`. `agnes skills list/show` survive as analyst discovery commands; bulk-install variants (none today, but spec refuses to add them) stay out.
```
WORKSPACE LIFECYCLE
da init one-time workspace bootstrap (--server-url, --token, --force, --workspace)
da pull refresh registered data (server → workspace) [--quiet, --json, --dry-run]
da push upload sessions + notes (workspace → server) [--quiet, --json, --dry-run]
da status what's in this workspace, when last synced
agnes init one-time workspace bootstrap (--server-url, --token, --force, --workspace)
agnes pull refresh registered data (server → workspace) [--quiet, --json, --dry-run]
agnes push upload sessions + notes (workspace → server) [--quiet, --json, --dry-run]
agnes status what's in this workspace, when last synced
DATA QUERY
da query "SELECT ..." local DuckDB SQL (over server/parquet/* + user/snapshots/*)
da query --remote "SELECT ..." server-side BQ passthrough
da explore <view> interactive REPL over a single view
da disk-info snapshot disk usage summary
agnes query "SELECT ..." local DuckDB SQL (over server/parquet/* + user/snapshots/*)
agnes query --remote "SELECT ..." server-side BQ passthrough
agnes explore <view> interactive REPL over a single view
agnes disk-info snapshot disk usage summary
DISCOVERY
da catalog tables I have access to (RBAC-filtered)
da catalog --metrics list metric definitions (replaces da metrics list)
da catalog --metrics --show <id> show one metric definition (replaces da metrics show)
da schema <table> columns + types
da describe <table> sample rows
da skills list list bundled CLI skill markdown documents
da skills show <name> print one skill's content
agnes catalog tables I have access to (RBAC-filtered)
agnes catalog --metrics list metric definitions (replaces da metrics list)
agnes catalog --metrics --show <id> show one metric definition (replaces da metrics show)
agnes schema <table> columns + types
agnes describe <table> sample rows
agnes skills list list bundled CLI skill markdown documents
agnes skills show <name> print one skill's content
SNAPSHOTS (ad-hoc remote materialization)
da snapshot create <table> --as <name> [--select ... --where ... --limit ... --order-by ... --estimate / --no-estimate --force]
da snapshot list
da snapshot drop <name>
da snapshot refresh <name> re-run the snapshot's saved query
da snapshot prune drop snapshots older than --older-than
agnes snapshot create <table> --as <name> [--select ... --where ... --limit ... --order-by ... --estimate / --no-estimate --force]
agnes snapshot list
agnes snapshot drop <name>
agnes snapshot refresh <name> re-run the snapshot's saved query
agnes snapshot prune drop snapshots older than --older-than
AUTH + IDENTITY
da auth login interactive login (browser flow). NOT called by da init.
da auth import-token <PAT> non-interactive
da auth whoami
da auth logout
da auth token create / list / revoke (today's location; unchanged by this PR)
agnes auth login interactive login (browser flow). NOT called by agnes init.
agnes auth import-token <PAT> non-interactive
agnes auth whoami
agnes auth logout
agnes auth token create / list / revoke (today's location; unchanged by this PR)
HEALTH
da diagnose health check (server + local)
agnes diagnose health check (server + local)
ADMIN-ADJACENT (kept; not part of analyst flow)
da admin metrics import starter-pack import of metric definitions
da admin metrics export dump metric definitions to YAML
da admin metrics validate validate metric definitions
da admin <other> existing admin verbs continue unchanged
agnes admin metrics import starter-pack import of metric definitions
agnes admin metrics export dump metric definitions to YAML
agnes admin metrics validate validate metric definitions
agnes admin <other> existing admin verbs continue unchanged
```
Removed:
- `da analyst setup`, `da analyst status``analyst` namespace had only one user role; replaced by top-level `da init` + `da status`.
- `da sync` (and `--upload-only`) — split into `da pull` + `da push`. Hook commands rename accordingly.
- `da fetch` — folded into `da snapshot create` with all flags carried over (`--select`, `--where`, `--limit`, `--order-by`, `--as`, `--estimate`, `--no-estimate`, `--force`).
- `da metrics list/show` — folded into `da catalog --metrics`.
- `da metrics import`, `da metrics export`, `da metrics validate` — relocated to `da admin metrics {import,export,validate}` (admin-only operations).
- `da analyst setup`, `da analyst status``analyst` namespace had only one user role; replaced by top-level `agnes init` + `agnes status`.
- `da sync` (and `--upload-only`) — split into `agnes pull` + `agnes push`. Hook commands rename accordingly.
- `da fetch` — folded into `agnes snapshot create` with all flags carried over (`--select`, `--where`, `--limit`, `--order-by`, `--as`, `--estimate`, `--no-estimate`, `--force`).
- `da metrics list/show` — folded into `agnes catalog --metrics`.
- `da metrics import`, `da metrics export`, `da metrics validate` — relocated to `agnes admin metrics {import,export,validate}` (admin-only operations).
Surface decisions vs. earlier draft:
- `da skills list / show` retained for analyst-side discovery. Skills bundled under `cli/skills/*.md` (e.g., `agnes-data-querying.md`, `agnes-table-registration.md`) carry rails that the rebased main expanded as part of #160 (cost guardrail, registry-gating). Removing them would cost the analyst documentation that the project actively invests in. Bulk install/copy verbs are not added.
- `da auth token …` keeps its current location under `auth_app` (today's `cli/commands/auth.py:200-201` registers the sub-Typer there). No move to top-level `da token`. Surface listing reflects that.
- `agnes skills list / show` retained for analyst-side discovery. Skills bundled under `cli/skills/*.md` (e.g., `agnes-data-querying.md`, `agnes-table-registration.md`) carry rails that the rebased main expanded as part of #160 (cost guardrail, registry-gating). Removing them would cost the analyst documentation that the project actively invests in. Bulk install/copy verbs are not added.
- `agnes auth token …` keeps its current location under `auth_app` (today's `cli/commands/auth.py:200-201` registers the sub-Typer there). No move to top-level `da token`. Surface listing reflects that.
Reader commands explicitly listed (`da explore`, `da disk-info`, `da snapshot refresh`, `da snapshot prune`, `da skills list/show`) survive unchanged.
Reader commands explicitly listed (`agnes explore`, `agnes disk-info`, `agnes snapshot refresh`, `agnes snapshot prune`, `agnes skills list/show`) survive unchanged.
## Components
@ -169,31 +171,31 @@ Reader commands explicitly listed (`da explore`, `da disk-info`, `da snapshot re
| Component | File | Change |
|---|---|---|
| Analyst install-prompt branch | `app/web/setup_instructions.py` | Add `role: Literal["analyst","admin"]="admin"` to `resolve_lines()` and `render_setup_instructions()`. Analyst layout: TLS trust block (reused, when `ca_pem` supplied) → install `da` (reused) → `da init --server-url X --token Y --workspace .` → `da catalog` smoke verify → confirm. Drop for analyst: marketplace, plugins, skills, diagnose, login, whoami (all subsumed by `da init`). |
| Analyst install-prompt branch | `app/web/setup_instructions.py` | Add `role: Literal["analyst","admin"]="admin"` to `resolve_lines()` and `render_setup_instructions()`. Analyst layout: TLS trust block (reused, when `ca_pem` supplied) → install `agnes` (reused) → `agnes init --server-url X --token Y --workspace .` → `agnes catalog` smoke verify → confirm. Drop for analyst: marketplace, plugins, skills, diagnose, login, whoami (all subsumed by `agnes init`). |
| `/setup?role=...` query branching | `app/web/router.py` `setup_page` (line 717) | Read `role` query param, default `"admin"`. Pass to `render_setup_instructions(role=...)`. Existing `/install` 302 redirect to `/setup` is preserved (legacy bookmarks keep working). |
| `setup.html` UI | `app/web/templates/setup.html` (or wherever `setup_page` renders) | Two role tiles: "Analyst workspace" / "Admin CLI". PAT mint button per tile, posts to `/auth/tokens` with `scope` matching the tile. Renders the prompt for the selected role. |
| PAT scope + TTL clamp | `app/api/tokens.py` (`CreateTokenRequest` Pydantic model + `create_token` route) | Add two fields: `scope: str = "general"` and `ttl_seconds: int \| None = None` (alongside the existing `expires_in_days: Optional[int] = 90` at lines 23-25). Resolution: when `ttl_seconds` is set, it wins; otherwise fall back to `expires_in_days`. **Upper bound:** mirror the existing `expires_in_days <= 3650` cap at line 100 with `ttl_seconds <= 315_360_000` (3650 days × 86400 s) so a hostile client can't bypass the cap by switching field names. For `scope == "bootstrap-analyst"`, server force-clamps the resolved TTL to ≤ 3600 s regardless of request. Audit-log entry includes the scope. The audit log is the only consumer of `scope` in this PR — per-endpoint enforcement is an explicit follow-up. |
| Server-side template rewrite | `config/claude_md_template.txt` (or wherever `render_claude_md` reads its default from) | Update path strings: `data/parquet/``server/parquet/`, `data/duckdb/...``user/duckdb/analytics.duckdb`. Replace `da sync``da pull`, `da fetch``da snapshot create`, `da metrics list``da catalog --metrics`. |
| Server-side template rewrite | `config/claude_md_template.txt` (or wherever `render_claude_md` reads its default from) | Update path strings: `data/parquet/``server/parquet/`, `data/duckdb/...``user/duckdb/analytics.duckdb`. Replace `da sync``agnes pull`, `da fetch``agnes snapshot create`, `da metrics list``agnes catalog --metrics`. |
| Admin override migration | `claude_md_template` DB table (schema v23, exposed via `/admin/workspace-prompt` UI and `app/api/claude_md.py` admin CRUD) | Add a module-level constant `_LEGACY_STRINGS = ("data/parquet", "da sync", "da fetch", "da analyst setup", "da metrics list", "da metrics show")` and a helper `def _scan_legacy_strings(text: str) -> list[str]` inside `app/api/claude_md.py`. Add a `legacy_strings_detected: list[str] = []` field to `TemplateGetResponse` (today defined at `app/api/claude_md.py:72-76`); `admin_get_workspace_template` populates it via `_scan_legacy_strings(override.content)`. UI in `app/web/templates/admin_workspace_prompt.html` (file confirmed to exist) renders a yellow banner above the editor when the list is non-empty: "This override references CLI verbs / paths that were renamed in this release. Re-author and Save to clear the warning. See CHANGELOG for the rename list." Migration stays manual — admin re-authors and saves. |
| `/api/welcome` content unchanged | `app/api/claude_md.py:91` (`get_welcome`) | No code change — endpoint already serves rendered CLAUDE.md. Spec calls it out so implementer knows `da init`'s producer is here, not in the client. |
| Adopt `cli/error_render.py` (added in #160) for client-side errors | server: nothing — client-side only | `cli/error_render.py:render_error(status_code, body)` was introduced in 0.32.0 for typed BQ errors served by `da query --remote` (recognizes `detail.kind` / `detail.reason` shapes; falls back to plain HTTP `{code}: {text}`). The renderer is structurally generic — no BQ-specific code. `da init` and `da pull` are **first-time adopters in the bootstrap path** (today's `sync.py`, `auth.py`, `fetch.py` don't import it). Pattern: synthesize a `{"detail": {"kind": "...", "hint": "...", "message": "..."}}` dict client-side and pass with a chosen `status_code` (0 or `-1` for purely client-side errors with no HTTP origin), exactly as `cli/commands/query.py:152, 165` already does for `RemoteQueryError` translation. New typed kinds added in this PR: `auth_failed`, `server_unreachable`, `manifest_unauthorized`, `disk_full`, `partial_state` — the renderer doesn't gate on a kind allowlist, so no renderer change is needed. No server work; client-side only. |
| `/api/welcome` content unchanged | `app/api/claude_md.py:91` (`get_welcome`) | No code change — endpoint already serves rendered CLAUDE.md. Spec calls it out so implementer knows `agnes init`'s producer is here, not in the client. |
| Adopt `cli/error_render.py` (added in #160) for client-side errors | server: nothing — client-side only | `cli/error_render.py:render_error(status_code, body)` was introduced in 0.32.0 for typed BQ errors served by `agnes query --remote` (recognizes `detail.kind` / `detail.reason` shapes; falls back to plain HTTP `{code}: {text}`). The renderer is structurally generic — no BQ-specific code. `agnes init` and `agnes pull` are **first-time adopters in the bootstrap path** (today's `sync.py`, `auth.py`, `fetch.py` don't import it). Pattern: synthesize a `{"detail": {"kind": "...", "hint": "...", "message": "..."}}` dict client-side and pass with a chosen `status_code` (0 or `-1` for purely client-side errors with no HTTP origin), exactly as `cli/commands/query.py:152, 165` already does for `RemoteQueryError` translation. New typed kinds added in this PR: `auth_failed`, `server_unreachable`, `manifest_unauthorized`, `disk_full`, `partial_state` — the renderer doesn't gate on a kind allowlist, so no renderer change is needed. No server work; client-side only. |
### Client-side (CLI, Python)
| Component | File | Change |
|---|---|---|
| `da init` (new) | `cli/commands/init.py` (new) | Required args: `--server-url`, `--token`. Optional: `--force`, `--workspace` (default `cwd`). Steps: (1) verify server reachability + PAT validity via `GET /api/catalog/tables` with `Authorization: Bearer <PAT>` — same endpoint `da auth import-token` already uses for this purpose (`cli/commands/auth.py:154`); exercises full PAT validation chain (revocation, expiry, hash) and 401s on bad PAT, unlike `/api/health` which is unauthenticated; (2) save server URL + PAT to `~/.config/da/{config.yaml,token.json}`; (3) `GET /api/welcome` and write its body to `<workspace>/CLAUDE.md`; (4) write `.claude/settings.json` (model, permissions, hooks pointing at `da pull` and `da push`) — delegate hook installation to `cli/lib/hooks.py:install_claude_hooks` (see new module row below); (5) write `.claude/CLAUDE.local.md` (stub, only if absent); (6) call `cli/lib/pull.py:run_pull(server_url, token, workspace)` programmatically (no Typer round-trip); (7) write `AGNES_WORKSPACE.md` from a static client-side template with `{created_at}`, `{server_url}`, `{workspace_path}` substituted. `da init` does NOT call `da auth login`; the PAT from the paste-prompt is the only auth path during bootstrap. Errors are rendered by `cli/error_render.py:render_error()``da init` synthesizes `{"detail": {"kind": "...", "hint": "..."}}` dicts client-side (pattern: `cli/commands/query.py:152, 165`); typed kinds: `auth_failed`, `server_unreachable`, `partial_state`, `disk_full`. |
| `cli/lib/pull.py` (new module) | `cli/lib/pull.py` + `cli/lib/__init__.py` (new) — establish `cli/lib/` as the shared-library tree | Pure-function refactor of today's `cli/commands/sync.py:sync()` body, minus Typer decorators and stdout. Signature: `def run_pull(server_url: str, token: str, workspace: Path, *, dry_run: bool = False) -> PullResult`. Returns a structured `PullResult` (tables_updated, parquets_total, rules_count, duration_s, errors). Caller decides what to print (`da init` summarizes; `da pull` Typer wrapper prints per `--quiet`/`--json` flags). Tested directly without subprocess. **Packaging:** `cli/lib/__init__.py` (empty file) is required for Hatchling to include the dir in the wheel — `pyproject.toml:packages` already lists `cli`, sub-packages with `__init__.py` are picked up automatically. |
| `cli/lib/hooks.py` (new module) | `cli/lib/hooks.py` (new) — replaces `cli/commands/analyst.py:_install_claude_hooks` | `def install_claude_hooks(workspace: Path) -> None`. Idempotent. Reads `<workspace>/.claude/settings.json`, drops any prior entry whose every command is a `da pull`/`da sync`/`da push` invocation (covers both today's hook commands and the new ones during a transition window if anyone runs the new init in a folder that had old hooks), appends fresh entries: `SessionStart → da pull --quiet 2>/dev/null \|\| true`, `SessionEnd → da push --quiet 2>/dev/null \|\| true`. Workspace-level scope (`<workspace>/.claude/settings.json`, not user-home), preserves third-party hooks. Lives next to `cli/lib/pull.py` under the new `cli/lib/__init__.py` package. |
| `da pull` (renamed from `da sync`) | `cli/commands/pull.py` (renamed from `cli/commands/sync.py`) | Behavior is today's `da sync` minus the `--upload-only` branch. Lazy-mkdir fixes (see below). Calls `cli/lib/pull.py:run_pull` and prints the result. Flags: `--quiet` (suppress success stdout, used by hook), `--json` (machine output of `PullResult`), `--dry-run` (compute deltas without writing — uses `dry_run=True`). Errors render via `cli/error_render.py`. |
| `da push` (extracted from `da sync --upload-only`) | `cli/commands/push.py` (new) | Uploads `user/sessions/*.jsonl` and `.claude/CLAUDE.local.md`. Lazy: skip when nothing to upload (no `user/sessions/` mkdir if no sessions). Same auth as `da pull`. Flags: `--quiet`, `--json`, `--dry-run`. Errors render via `cli/error_render.py`. |
| `da snapshot create` (renamed from `da fetch`) | `cli/commands/snapshot.py` | Move logic from `cli/commands/fetch.py` into a `create` subcommand of the existing `snapshot` group. Remove `cli/commands/fetch.py`. Carry over all flags: `--select`, `--where`, `--limit`, `--order-by`, `--as`, `--estimate`, `--no-estimate`, `--force`. Add existence check before opening DuckDB to avoid creating an empty DB file when no `da pull` has run yet (guard: `if not db_path.exists(): typer.echo("Local DuckDB not found. Run: da pull"); raise typer.Exit(1)`). Existing `da snapshot {refresh, prune, list, drop}` are unchanged. |
| `da status` (renamed from `da analyst status`) | `cli/commands/status.py` (renamed from analyst.py status fn) | Path refs updated to new layout: `server/parquet/`, `user/duckdb/analytics.duckdb`. Drop `data/metadata/last_sync.json`; use mtime on `user/duckdb/analytics.duckdb` as freshness proxy. |
| `agnes init` (new) | `cli/commands/init.py` (new) | Required args: `--server-url`, `--token`. Optional: `--force`, `--workspace` (default `cwd`). Steps: (1) verify server reachability + PAT validity via `GET /api/catalog/tables` with `Authorization: Bearer <PAT>` — same endpoint `agnes auth import-token` already uses for this purpose (`cli/commands/auth.py:154`); exercises full PAT validation chain (revocation, expiry, hash) and 401s on bad PAT, unlike `/api/health` which is unauthenticated; (2) save server URL + PAT to `~/.config/da/{config.yaml,token.json}`; (3) `GET /api/welcome` and write its body to `<workspace>/CLAUDE.md`; (4) write `.claude/settings.json` (model, permissions, hooks pointing at `agnes pull` and `agnes push`) — delegate hook installation to `cli/lib/hooks.py:install_claude_hooks` (see new module row below); (5) write `.claude/CLAUDE.local.md` (stub, only if absent); (6) call `cli/lib/pull.py:run_pull(server_url, token, workspace)` programmatically (no Typer round-trip); (7) write `AGNES_WORKSPACE.md` from a static client-side template with `{created_at}`, `{server_url}`, `{workspace_path}` substituted. `agnes init` does NOT call `agnes auth login`; the PAT from the paste-prompt is the only auth path during bootstrap. Errors are rendered by `cli/error_render.py:render_error()``agnes init` synthesizes `{"detail": {"kind": "...", "hint": "..."}}` dicts client-side (pattern: `cli/commands/query.py:152, 165`); typed kinds: `auth_failed`, `server_unreachable`, `partial_state`, `disk_full`. |
| `cli/lib/pull.py` (new module) | `cli/lib/pull.py` + `cli/lib/__init__.py` (new) — establish `cli/lib/` as the shared-library tree | Pure-function refactor of today's `cli/commands/sync.py:sync()` body, minus Typer decorators and stdout. Signature: `def run_pull(server_url: str, token: str, workspace: Path, *, dry_run: bool = False) -> PullResult`. Returns a structured `PullResult` (tables_updated, parquets_total, rules_count, duration_s, errors). Caller decides what to print (`agnes init` summarizes; `agnes pull` Typer wrapper prints per `--quiet`/`--json` flags). Tested directly without subprocess. **Packaging:** `cli/lib/__init__.py` (empty file) is required for Hatchling to include the dir in the wheel — `pyproject.toml:packages` already lists `cli`, sub-packages with `__init__.py` are picked up automatically. |
| `cli/lib/hooks.py` (new module) | `cli/lib/hooks.py` (new) — replaces `cli/commands/analyst.py:_install_claude_hooks` | `def install_claude_hooks(workspace: Path) -> None`. Idempotent. Reads `<workspace>/.claude/settings.json`, drops any prior entry whose every command is a `agnes pull`/`da sync`/`agnes push` invocation (covers both today's hook commands and the new ones during a transition window if anyone runs the new init in a folder that had old hooks), appends fresh entries: `SessionStart → agnes pull --quiet 2>/dev/null \|\| true`, `SessionEnd → agnes push --quiet 2>/dev/null \|\| true`. Workspace-level scope (`<workspace>/.claude/settings.json`, not user-home), preserves third-party hooks. Lives next to `cli/lib/pull.py` under the new `cli/lib/__init__.py` package. |
| `agnes pull` (renamed from `da sync`) | `cli/commands/pull.py` (renamed from `cli/commands/sync.py`) | Behavior is today's `da sync` minus the `--upload-only` branch. Lazy-mkdir fixes (see below). Calls `cli/lib/pull.py:run_pull` and prints the result. Flags: `--quiet` (suppress success stdout, used by hook), `--json` (machine output of `PullResult`), `--dry-run` (compute deltas without writing — uses `dry_run=True`). Errors render via `cli/error_render.py`. |
| `agnes push` (extracted from `da sync --upload-only`) | `cli/commands/push.py` (new) | Uploads `user/sessions/*.jsonl` and `.claude/CLAUDE.local.md`. Lazy: skip when nothing to upload (no `user/sessions/` mkdir if no sessions). Same auth as `agnes pull`. Flags: `--quiet`, `--json`, `--dry-run`. Errors render via `cli/error_render.py`. |
| `agnes snapshot create` (renamed from `da fetch`) | `cli/commands/snapshot.py` | Move logic from `cli/commands/fetch.py` into a `create` subcommand of the existing `snapshot` group. Remove `cli/commands/fetch.py`. Carry over all flags: `--select`, `--where`, `--limit`, `--order-by`, `--as`, `--estimate`, `--no-estimate`, `--force`. Add existence check before opening DuckDB to avoid creating an empty DB file when no `agnes pull` has run yet (guard: `if not db_path.exists(): typer.echo("Local DuckDB not found. Run: agnes pull"); raise typer.Exit(1)`). Existing `agnes snapshot {refresh, prune, list, drop}` are unchanged. |
| `agnes status` (renamed from `da analyst status`) | `cli/commands/status.py` (renamed from analyst.py status fn) | Path refs updated to new layout: `server/parquet/`, `user/duckdb/analytics.duckdb`. Drop `data/metadata/last_sync.json`; use mtime on `user/duckdb/analytics.duckdb` as freshness proxy. |
| Lazy-mkdir contract | `cli/commands/pull.py`, `cli/lib/pull.py`, `cli/commands/push.py` | No `mkdir(parents=True, exist_ok=True)` before a conditional write loop. Mkdir only immediately before the first file write. Concretely: `_fetch_and_write_rules` mkdirs `.claude/rules/` only when `mandatory approved` is non-empty; `parquet_dir` mkdir is inlined into the per-table download loop. |
| `da catalog --metrics` flag | `cli/commands/catalog.py` | Add `--metrics` flag (replaces `da metrics list`) and `--metrics --show <id>` (replaces `da metrics show`). Decided shape, not unresolved — implementation should not negotiate. |
| `da admin metrics {import,export,validate}` (relocated) | `cli/commands/admin.py` | Add a `metrics` sub-Typer to the existing `admin_app` (which already nests sub-Typers `memory`, `group`, `grant`, `break-glass` per `cli/commands/admin.py:10`). Move `import`, `export`, `validate` from `cli/commands/metrics.py`. Admin-only; not part of analyst flow. |
| `agnes catalog --metrics` flag | `cli/commands/catalog.py` | Add `--metrics` flag (replaces `da metrics list`) and `--metrics --show <id>` (replaces `da metrics show`). Decided shape, not unresolved — implementation should not negotiate. |
| `agnes admin metrics {import,export,validate}` (relocated) | `cli/commands/admin.py` | Add a `metrics` sub-Typer to the existing `admin_app` (which already nests sub-Typers `memory`, `group`, `grant`, `break-glass` per `cli/commands/admin.py:10`). Move `import`, `export`, `validate` from `cli/commands/metrics.py`. Admin-only; not part of analyst flow. |
| Removed (full delete) | `cli/commands/{metrics.py, fetch.py, analyst.py, sync.py}` | Deleted entirely (greenfield). |
| Retained | `cli/commands/skills.py` | Kept. `da skills list` and `da skills show` are analyst-side discovery commands. No code change in this PR. |
| Retained | `cli/commands/skills.py` | Kept. `agnes skills list` and `agnes skills show` are analyst-side discovery commands. No code change in this PR. |
### Templates and docs
@ -201,7 +203,7 @@ Reader commands explicitly listed (`da explore`, `da disk-info`, `da snapshot re
|---|---|---|
| Server-side `CLAUDE.md` template | `config/claude_md_template.txt` (and any DB override flagged in admin migration) | Path strings + verb names updated as listed in Server-side table. |
| `AGNES_WORKSPACE.md` template (new) | `config/agnes_workspace_template.txt` (new, client-side static asset bundled with the wheel) | Three placeholders: `{created_at}`, `{server_url}`, `{workspace_path}`. Header line uses all three; remaining content is static. Content described in dedicated section below. |
| Repo-root `CLAUDE.md` rewrite | `CLAUDE.md` (project root) | Update all references: `da sync``da pull`, `da analyst setup``da init`, `da metrics list/show``da catalog --metrics`, `da fetch``da snapshot create`, `data/parquet/``server/parquet/`. The "Local sync & Claude Code hooks" subsection and the "Querying Agnes data — agent rails" subsection both need full walk-throughs. The latter was expanded by 0.32.0 (#160) with cost-guardrail / registry-gating prose — those sections stay verbatim, just verb-renamed. The "Business Metrics" subsection's `da metrics import` / `da metrics list` / `da metrics show` examples become `da admin metrics import` and `da catalog --metrics` respectively. |
| Repo-root `CLAUDE.md` rewrite | `CLAUDE.md` (project root) | Update all references: `da sync``agnes pull`, `da analyst setup``agnes init`, `da metrics list/show``agnes catalog --metrics`, `da fetch``agnes snapshot create`, `data/parquet/``server/parquet/`. The "Local sync & Claude Code hooks" subsection and the "Querying Agnes data — agent rails" subsection both need full walk-throughs. The latter was expanded by 0.32.0 (#160) with cost-guardrail / registry-gating prose — those sections stay verbatim, just verb-renamed. The "Business Metrics" subsection's `da metrics import` / `da metrics list` / `da metrics show` examples become `agnes admin metrics import` and `agnes catalog --metrics` respectively. |
## Web UI flow
@ -232,7 +234,7 @@ Legacy `/install` URL: kept as a 302 redirect to `/setup`. All new references in
## Workspace layout
Post-`da init`, workspace contains exactly:
Post-`agnes init`, workspace contains exactly:
```
<cwd>/
@ -250,8 +252,8 @@ Conditional additions:
- `./.claude/rules/km_*.md` — only when `/api/memory/bundle` returns ≥ 1 mandatory or approved item.
- `./server/parquet/<table>.parquet` — only when `/api/sync/manifest` returns ≥ 1 table the user has grants on.
- `./user/snapshots/<name>.parquet` — only after the user runs `da snapshot create <table> --as <name>`.
- `./user/sessions/<id>.jsonl` — only after the SessionEnd hook runs `da push` against captured Claude Code sessions.
- `./user/snapshots/<name>.parquet` — only after the user runs `agnes snapshot create <table> --as <name>`.
- `./user/sessions/<id>.jsonl` — only after the SessionEnd hook runs `agnes push` against captured Claude Code sessions.
Forbidden under any circumstances (these are the dead paths today's setup creates):
@ -266,10 +268,10 @@ Empty folder + Claude Code with paste prompt
├─ Step 0 (TLS trust block) — only when server uses private CA
│ writes ~/.agnes/{ca.pem, ca-bundle.pem}, appends shell rc block
├─ Step 1 — uv tool install da
├─ Step 1 — uv tool install <wheel> # binary: agnes
│ writes ~/.local/bin/da
├─ Step 2 — da init --server-url URL --token PAT --workspace .
├─ Step 2 — agnes init --server-url URL --token PAT --workspace .
│ ├─ verify: GET /api/catalog/tables with Bearer PAT → 200 (PAT-validating endpoint)
│ ├─ save: ~/.config/da/{config.yaml, token.json}
│ ├─ fetch: GET /api/welcome?server_url=<URL> → write ./CLAUDE.md
@ -277,9 +279,9 @@ Empty folder + Claude Code with paste prompt
│ │ installs render the operator-visible URL, not the FastAPI
│ │ internal hostname; endpoint default falls back to
│ │ request.base_url which equals --server-url in practice)
│ ├─ write: ./.claude/settings.json (with hooks SessionStart→`da pull`, SessionEnd→`da push`)
│ ├─ write: ./.claude/settings.json (with hooks SessionStart→`agnes pull`, SessionEnd→`agnes push`)
│ ├─ write: ./.claude/CLAUDE.local.md (stub, if absent)
│ ├─ call: da pull (programmatic — calls cli/lib/pull.py:run_pull)
│ ├─ call: agnes pull (programmatic — calls cli/lib/pull.py:run_pull)
│ │ ├─ GET /api/sync/manifest → {tables, hashes}
│ │ ├─ for each table where local md5 ≠ remote md5:
│ │ │ GET /api/data/<id>/download (stream)
@ -292,16 +294,16 @@ Empty folder + Claude Code with paste prompt
│ │ if empty: skip mkdir
│ └─ write: ./AGNES_WORKSPACE.md (from client-side static template)
├─ Step 3 — da catalog (smoke verify)
├─ Step 3 — agnes catalog (smoke verify)
│ confirms end-to-end works; prints table count
└─ Step 4 — confirm
Claude reports: tables synced, files created, hooks active.
Subsequent sessions:
├─ SessionStart hook fires: da pull --quiet 2>/dev/null || true
├─ SessionStart hook fires: agnes pull --quiet 2>/dev/null || true
├─ user works
└─ SessionEnd hook fires: da push --quiet 2>/dev/null || true
└─ SessionEnd hook fires: agnes push --quiet 2>/dev/null || true
```
## Empty-folder discipline
@ -318,31 +320,31 @@ Concretely:
|---|---|---|---|
| `_fetch_and_write_rules` | `cli/commands/sync.py:222` | `rules_dir.mkdir(parents=True, exist_ok=True)` before iterating | Check `mandatory + approved` first; if empty, return without mkdir. |
| Per-table download loop | `cli/commands/sync.py:120, 529` | `parquet_dir.mkdir(parents=True, exist_ok=True)` before loop | Mkdir inlined into the per-file write block; first table triggers mkdir. |
| `install_claude_hooks` | `cli/lib/hooks.py` (new; replaces `cli/commands/analyst.py:_install_claude_hooks`, today at line 254) | mkdir `.claude/` | unchanged — `.claude/` always has content (settings.json is load-bearing). Function lifted from the deleted `cli/commands/analyst.py` into a shared library so `da init` (and any future caller) can use it without importing the deleted module. |
| `install_claude_hooks` | `cli/lib/hooks.py` (new; replaces `cli/commands/analyst.py:_install_claude_hooks`, today at line 254) | mkdir `.claude/` | unchanged — `.claude/` always has content (settings.json is load-bearing). Function lifted from the deleted `cli/commands/analyst.py` into a shared library so `agnes init` (and any future caller) can use it without importing the deleted module. |
| `_rebuild_duckdb_views` | `cli/commands/sync.py:321` | mkdir `user/duckdb/` | unchanged — DuckDB file is opened unconditionally as part of view rebuild; the file is the load-bearing artifact, not just the directory. |
| `da push` upload | (new) `cli/commands/push.py` | (n/a) | Mkdir `user/sessions/` only inside the per-session-write branch; `da push` with nothing to upload exits 0 without touching disk. |
| `da snapshot create` parquet write | `cli/commands/snapshot.py` | mkdir `user/snapshots/` before write | unchanged (snapshot create is the canonical writer; mkdir on first write is correct). |
| `agnes push` upload | (new) `cli/commands/push.py` | (n/a) | Mkdir `user/sessions/` only inside the per-session-write branch; `agnes push` with nothing to upload exits 0 without touching disk. |
| `agnes snapshot create` parquet write | `cli/commands/snapshot.py` | mkdir `user/snapshots/` before write | unchanged (snapshot create is the canonical writer; mkdir on first write is correct). |
### Reader contract
> Every reader MUST handle missing paths gracefully. "Gracefully" means:
> - **Exit 0 with empty / zero output** when missing paths are a natural empty answer (`da disk-info` shows 0; `da status` shows "initialized: no").
> - **Exit 1 with friendly hint** when the missing path means a workflow precondition isn't met (`da query`: "Local DuckDB not found. Run: da pull").
> - **Exit 0 with empty / zero output** when missing paths are a natural empty answer (`agnes disk-info` shows 0; `agnes status` shows "initialized: no").
> - **Exit 1 with friendly hint** when the missing path means a workflow precondition isn't met (`agnes query`: "Local DuckDB not found. Run: agnes pull").
> - **Never create the path side-effect-ally** unless this command is the canonical writer for it.
Audit of current readers (only commands that touch the filesystem are listed; others are server-API only and unaffected):
| Command | Path it reads | Today's behavior | Change needed |
|---|---|---|---|
| `da query` | `user/duckdb/analytics.duckdb` | `.exists()` check, friendly "Run: da sync" exit 1 | Update hint text → "Run: da pull". |
| `da explore` | same | `.exists()` check, friendly exit | Update hint text. |
| `da snapshot create` (was `da fetch`) | same | unconditional `duckdb.connect()` → creates empty DB | Add `.exists()` check + hint "Run: da pull first". |
| `da snapshot create` (write side) | `user/snapshots/` | unchanged (writer, mkdir at first write) | unchanged. |
| `da disk-info` | `user/snapshots/` | `.exists()` guards around sum/count/free | unchanged. |
| `da snapshot list` | `user/snapshots/` | glob safe on missing | unchanged (glob returns empty iterator on missing dir). |
| `da snapshot refresh` / `prune` | `user/snapshots/` | glob/.exists() guards | unchanged. |
| `da push` | `user/sessions/` | `.exists()` check before iterating | unchanged. |
| `da status` | `server/parquet/`, `user/duckdb/...` | path strings reference legacy `data/parquet/` etc. | Update path strings; `.exists()` checks already in place. |
| `agnes query` | `user/duckdb/analytics.duckdb` | `.exists()` check, friendly "Run: da sync" exit 1 | Update hint text → "Run: agnes pull". |
| `agnes explore` | same | `.exists()` check, friendly exit | Update hint text. |
| `agnes snapshot create` (was `da fetch`) | same | unconditional `duckdb.connect()` → creates empty DB | Add `.exists()` check + hint "Run: agnes pull first". |
| `agnes snapshot create` (write side) | `user/snapshots/` | unchanged (writer, mkdir at first write) | unchanged. |
| `agnes disk-info` | `user/snapshots/` | `.exists()` guards around sum/count/free | unchanged. |
| `agnes snapshot list` | `user/snapshots/` | glob safe on missing | unchanged (glob returns empty iterator on missing dir). |
| `agnes snapshot refresh` / `prune` | `user/snapshots/` | glob/.exists() guards | unchanged. |
| `agnes push` | `user/sessions/` | `.exists()` check before iterating | unchanged. |
| `agnes status` | `server/parquet/`, `user/duckdb/...` | path strings reference legacy `data/parquet/` etc. | Update path strings; `.exists()` checks already in place. |
### Regression guard (test)
@ -366,7 +368,7 @@ This guard runs in every clean-install integration test.
## `AGNES_WORKSPACE.md` content
Generated by `da init` in the workspace root from a static client-side template (`config/agnes_workspace_template.txt`, bundled with the wheel). Not state — pure documentation. Idempotent overwrite on every `da init` (preserves nothing, regenerates everything).
Generated by `agnes init` in the workspace root from a static client-side template (`config/agnes_workspace_template.txt`, bundled with the wheel). Not state — pure documentation. Idempotent overwrite on every `agnes init` (preserves nothing, regenerates everything).
Three placeholders only: `{created_at}`, `{server_url}`, `{workspace_path}`. Used in the header line "Created: {created_at} · Server: {server_url} · Workspace: {workspace_path}". No email, no user identity, no role. Email is not used anywhere in the analyst CLI flow; PAT identifies the user server-side, and decoded JWT email is informational at best — we drop it from this header for clarity.
@ -376,7 +378,7 @@ Sections:
2. **What's installed (global, per-user)** — table of paths in `~/.local/bin/`, `~/.config/da/`, `~/.agnes/`, shell rc block. Each row: `path | what it is | how to remove`.
3. **What's in this folder** — table of paths in workspace. Each row: `path | what it is`. Notes which dirs are conditional ("only when grants/sessions/etc. exist").
4. **How it stays fresh** — explains SessionStart/End hooks: what they run, when, what failure looks like (silent, `|| true`).
5. **Cheat sheet**`da pull`, `da catalog`, `da query`, `da snapshot create`, `da status`, `da init --force` examples.
5. **Cheat sheet**`agnes pull`, `agnes catalog`, `agnes query`, `agnes snapshot create`, `agnes status`, `agnes init --force` examples.
6. **Uninstall** — step-by-step recipe to remove the CLI globally, the config dir, the trust artifacts, the rc block, and the workspace itself.
Approximate size: 3.5 KB, ~100 lines. Disk overhead: nil.
@ -389,14 +391,14 @@ PAT value never appears in `AGNES_WORKSPACE.md` — only its location (`~/.confi
| Failure | Detection | Behavior |
|---|---|---|
| Server unreachable during `da init` | `httpx.ConnectError` on `/api/catalog/tables` | exit 1 via `cli/error_render.render_error()` with kind `server_unreachable`, hint: "Cannot reach `<URL>` — check network or server status". |
| Server unreachable during `agnes init` | `httpx.ConnectError` on `/api/catalog/tables` | exit 1 via `cli/error_render.render_error()` with kind `server_unreachable`, hint: "Cannot reach `<URL>` — check network or server status". |
| PAT expired | `/api/catalog/tables` → 401 | exit 1 via `render_error()` with kind `auth_failed`, hint: "Token expired — get a fresh one at `<URL>/setup?role=analyst`". |
| PAT invalid (mis-paste) | 401, JWT decode failure | exit 1 via `render_error()` with kind `auth_failed`, hint: "Token format invalid — re-copy from `/setup`". |
| TLS trust failure | curl/wheel install fails with `unknown CA` | exit 1, hint refers user back to paste-prompt step 0. |
| Disk full during `da pull` | `OSError(ENOSPC)` on parquet write | atomic rename → partial file deleted; exit 1 with disk-info dump. |
| Concurrent `da init` in same folder | sentinel `<cwd>/.claude/.init.lock` | second invocation: "Setup already running" exit 1. |
| Partial state (previous `da init` crashed mid-way) | `CLAUDE.md` exists but `.claude/settings.json` missing | `da init` (without `--force`): friendly hint "Workspace partially set up — run `da init --force` to redo". |
| `da pull` 401 mid-session (PAT revoked server-side) | response 401 from `/api/sync/manifest` | hook command prints warning, exits 0 (`\|\| true`); session continues with last-known data. Manual `da pull` next time prints actionable hint. |
| Disk full during `agnes pull` | `OSError(ENOSPC)` on parquet write | atomic rename → partial file deleted; exit 1 with disk-info dump. |
| Concurrent `agnes init` in same folder | sentinel `<cwd>/.claude/.init.lock` | second invocation: "Setup already running" exit 1. |
| Partial state (previous `agnes init` crashed mid-way) | `CLAUDE.md` exists but `.claude/settings.json` missing | `agnes init` (without `--force`): friendly hint "Workspace partially set up — run `agnes init --force` to redo". |
| `agnes pull` 401 mid-session (PAT revoked server-side) | response 401 from `/api/sync/manifest` | hook command prints warning, exits 0 (`\|\| true`); session continues with last-known data. Manual `agnes pull` next time prints actionable hint. |
| Empty manifest | `/api/sync/manifest``{"tables": []}` | success, no parquet dir created, no warning (valid state). |
| Empty memory bundle | `/api/memory/bundle``{"mandatory": [], "approved": []}` | success, no `.claude/rules/` dir (valid state). |
| Per-table 5xx mid-pull | per-table 500 from `/api/data/<id>/download` | per-table warn; pull continues; final exit 0 if at least one table succeeded, exit 1 if all failed. |
@ -416,7 +418,7 @@ Verification has three layers: (a) automated reader-smoke matrix that proves no
| `fastapi_test_server` | object with `.url`, `.shutdown()` | Starts the FastAPI app in a background thread/subprocess against a `tmp_path`-rooted DATA_DIR. Clean schema (latest version, currently v23), two seeded users (`admin@example.com`, `analyst@example.com`, both with a known test password seeded into the local password provider), two seeded user groups (`Admin`, `Everyone`), three seeded tables in `table_registry` with one `query_mode='local'`, one `query_mode='materialized'`, one `query_mode='remote'`. Manifest + memory + welcome endpoints serve real (test) data. |
| `test_pat` | string PAT for `analyst@example.com` | Group membership: `Everyone` only. `resource_grants` for the local + materialized tables (so manifest returns 2 rows for them). Two `mandatory` corporate-memory items granted via group. PAT TTL: 1 h. |
| `test_pat_no_grants` | string PAT for `analyst@example.com` | Same user, but `resource_grants` is empty and `corporate_memory` has zero items granted to `Everyone`. Manifest returns `{"tables": []}`; memory bundle returns `{"mandatory": [], "approved": []}`. |
| `zero_grants_workspace` | `tmp_path` after running `da init --token <test_pat_no_grants> --server-url <fastapi_test_server.url>` | A fully-bootstrapped workspace where every conditional dir is absent. Used by the reader smoke matrix. The fixture also exposes a sentinel constant `NONEXISTENT_TABLE = "__nonexistent__"` for tests that need a deliberately-unknown table id; readers must produce a friendly exit-1 (no traceback) when given this id. |
| `zero_grants_workspace` | `tmp_path` after running `agnes init --token <test_pat_no_grants> --server-url <fastapi_test_server.url>` | A fully-bootstrapped workspace where every conditional dir is absent. Used by the reader smoke matrix. The fixture also exposes a sentinel constant `NONEXISTENT_TABLE = "__nonexistent__"` for tests that need a deliberately-unknown table id; readers must produce a friendly exit-1 (no traceback) when given this id. |
| `web_session` | authenticated `httpx.Client` with cookies | Calls `POST /auth/password/login/web` with form fields `email=admin@example.com` and `password=<test_password>` (the test password is seeded into the same `users` row by `fastapi_test_server`). The form-login endpoint sets the session cookie that `POST /auth/tokens` requires (PAT mint route gates on `require_session_token`, see `app/api/tokens.py:88`). Used to mint PATs in PAT-scope tests. Choice rationale: real-endpoint login over dependency-override keeps the auth path under test rather than bypassed. |
| `client` | `TestClient(app)` | Plain FastAPI test client with no auth. Used for endpoint-shape tests. |
@ -477,13 +479,13 @@ def test_clean_install_minimal_grants(fastapi_test_server, tmp_path, test_pat):
assert_no_dead_dirs(tmp_path)
# Hooks installed correctly:
settings = json.loads((tmp_path / ".claude" / "settings.json").read_text())
assert any("da pull" in h["hooks"][0]["command"]
assert any("agnes pull" in h["hooks"][0]["command"]
for h in settings["hooks"]["SessionStart"])
assert any("da push" in h["hooks"][0]["command"]
assert any("agnes push" in h["hooks"][0]["command"]
for h in settings["hooks"]["SessionEnd"])
# CLAUDE.md was fetched from /api/welcome (not local template):
claude_md = (tmp_path / "CLAUDE.md").read_text()
assert "da pull" in claude_md and "da sync" not in claude_md # post-rewrite content
assert "agnes pull" in claude_md and "da sync" not in claude_md # post-rewrite content
# AGNES_WORKSPACE.md content asserts (security + placeholder substitution):
workspace_md = (tmp_path / "AGNES_WORKSPACE.md").read_text()
assert test_pat not in workspace_md, "PAT must not leak into AGNES_WORKSPACE.md"
@ -492,7 +494,7 @@ def test_clean_install_minimal_grants(fastapi_test_server, tmp_path, test_pat):
assert "{workspace_path}" not in workspace_md, "placeholder not substituted"
assert fastapi_test_server.url in workspace_md
assert str(tmp_path) in workspace_md
assert "da pull" in workspace_md # cheat sheet uses new verb
assert "agnes pull" in workspace_md # cheat sheet uses new verb
def test_clean_install_zero_grants(fastapi_test_server, tmp_path, test_pat_no_grants):
@ -512,11 +514,11 @@ def test_clean_install_zero_grants(fastapi_test_server, tmp_path, test_pat_no_gr
def test_setup_force_preserves_user_files(...):
"""`da init --force` regenerates CLAUDE.md and AGNES_WORKSPACE.md
"""`agnes init --force` regenerates CLAUDE.md and AGNES_WORKSPACE.md
but never touches CLAUDE.local.md."""
def test_readers_in_pre_setup_dir(tmp_path, test_pat):
"""User runs reader commands in a folder that never had `da init`.
"""User runs reader commands in a folder that never had `agnes init`.
No crash; friendly hints to run init or pull."""
```
@ -530,15 +532,15 @@ def test_render_setup_instructions_analyst_role():
role="analyst",
)
assert "uv tool install" in text
assert "da init" in text
assert "agnes init" in text
assert "--token" in text and "agnes_pat_TEST" in text
assert "--server-url" in text
assert "da catalog" in text
assert "agnes catalog" in text
# Must not contain (admin-only):
assert "marketplace" not in text
assert "claude plugin install" not in text
assert "da skills" not in text
assert "da diagnose" not in text
assert "agnes skills" not in text
assert "agnes diagnose" not in text
```
### 5.4 PAT scope/TTL test
@ -571,31 +573,31 @@ def test_bootstrap_pat_falls_back_to_expires_in_days(web_session):
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 §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 `da push`; `du -sh /tmp/test-analyst-1/user/sessions/` non-empty).
7. Second `claude` in same folder. Verify SessionStart hook fires (`da pull` request in audit log).
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.
This protocol is documented in `docs/RELEASE_CHECKLIST.md` as a mandatory pre-merge step for changes touching the bootstrap path.
## Out of scope
1. **Admin CLI tooling**`/setup?role=admin` and `da admin *` continue unchanged. The new CLI surface listing in this spec is the *analyst* surface; admin verbs not listed (e.g., `da admin marketplace`, `da admin user`, etc.) are unaffected.
1. **Admin CLI tooling**`/setup?role=admin` and `agnes admin *` continue unchanged. The new CLI surface listing in this spec is the *analyst* surface; admin verbs not listed (e.g., `agnes admin marketplace`, `agnes admin user`, etc.) are unaffected.
2. **Migration of existing analyst workspaces** — greenfield; old `data/parquet/` etc. are dead but harmless.
3. **Backward-compat aliases** — no `da analyst setup``da init` shim, no `da sync``da pull` shim. Hard cutover.
3. **Backward-compat aliases** — no `da analyst setup``agnes init` shim, no `da sync``agnes pull` shim. Hard cutover.
4. **Multi-user / shared workspace**`<cwd>` is single-user.
5. **Offline initial bootstrap**`da init` requires server reachability.
5. **Offline initial bootstrap**`agnes init` requires server reachability.
6. **PAT auto-refresh / refresh tokens** — bootstrap PAT expires after 1 h; user re-clicks "Generate prompt".
7. **Per-endpoint PAT scope enforcement**`bootstrap-analyst` scope is informational at this stage (audit-trail). Per-endpoint enforcement is a follow-up issue.
8. **Web UI redesign**`/setup?role=...` reuses the existing page shell + JS. No visual redesign.
9. **CLI rename adjacent commands** beyond what's listed (e.g., `da auth login` → `da login`) — out of scope.
9. **CLI rename adjacent commands** beyond what's listed (e.g., `agnes auth login` → `da login`) — out of scope.
10. **Layered per-workspace config**`<cwd>/.agnes/{config.yaml,token.json}` overrides considered but dropped from this PR (no defined producer; multi-instance is edge case). Captured in Open questions.
## Open questions / follow-ups
- **Per-endpoint PAT scope enforcement** — should `scope="bootstrap-analyst"` PATs be restricted to `/api/catalog/tables`, `/api/sync/manifest`, `/api/data/*/download`, `/api/memory/bundle`, `/api/welcome` only, and refused on (e.g.) `/api/admin/*`? Today not enforced. New issue.
- **Layered per-workspace config** — supporting multi-instance use cases (one analyst, two Agnes servers) requires a defined producer for `<cwd>/.agnes/`. Options: `da init --per-workspace-config` flag, post-init manual `mkdir`, or `da config init`. Not chosen because no current user has asked for it. New issue if/when needed.
- **`da snapshot create --where` SQL flavor** — keep BigQuery flavor (today's `da fetch`) for parity with `da query --remote`, since BQ is the only remote source. Confirmed in this PR; flagged in case a non-BQ remote source is added later.
- **Hook performance budget**`da pull` on a 1.1 GB workspace (real-world example: today's `tmp_oss/server/parquet/`) with all parquets unchanged should complete the manifest comparison in well under 1 s so SessionStart doesn't perceptibly delay the user. If incremental MD5 comparison is too slow at scale, consider a server-side ETag.
- **Layered per-workspace config** — supporting multi-instance use cases (one analyst, two Agnes servers) requires a defined producer for `<cwd>/.agnes/`. Options: `agnes init --per-workspace-config` flag, post-init manual `mkdir`, or `da config init`. Not chosen because no current user has asked for it. New issue if/when needed.
- **`agnes snapshot create --where` SQL flavor** — keep BigQuery flavor (today's `da fetch`) for parity with `agnes query --remote`, since BQ is the only remote source. Confirmed in this PR; flagged in case a non-BQ remote source is added later.
- **Hook performance budget**`agnes pull` on a 1.1 GB workspace (real-world example: today's `tmp_oss/server/parquet/`) with all parquets unchanged should complete the manifest comparison in well under 1 s so SessionStart doesn't perceptibly delay the user. If incremental MD5 comparison is too slow at scale, consider a server-side ETag.
- **Anti-coupling test** — add a test that imports every `cli/commands/*.py` and `cli/lib/*.py` module and asserts no `cli/commands/*` module imports another `cli.commands.*` module except via dispatch (Typer subcommand registration). `cli/lib/*` modules may be imported by command modules; reverse direction (`cli.lib` importing `cli.commands`) is forbidden. Prevents `init` accidentally re-importing `pull`'s Typer wrapper instead of the library function.
## CHANGELOG entry (preview)
@ -604,29 +606,29 @@ This protocol is documented in `docs/RELEASE_CHECKLIST.md` as a mandatory pre-me
## [Unreleased]
### Changed
- **BREAKING** Analyst bootstrap rewritten end-to-end. `da analyst setup` is removed; replaced by `da init` (non-interactive, requires `--server-url` and `--token`). `da sync` is split into `da pull` (refresh) and `da push` (upload). `da fetch` is folded into `da snapshot create`. `da metrics list/show` is folded into `da catalog --metrics`; `da metrics import/export/validate` move to `da admin metrics {import,export,validate}`. The `da analyst` namespace is removed; the workspace status command is now `da status`.
- **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`.
- **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 `da init` in the workspace root. Documents global install, workspace layout, hooks, cheat sheet, uninstall recipe.
- `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.
- `cli/lib/` shared-library tree, with `cli/lib/pull.py:run_pull` (data-refresh primitive callable from both the Typer wrapper and `da init`) and `cli/lib/hooks.py:install_claude_hooks` (workspace-scoped Claude Code hook installer).
- `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
- `da pull` (formerly `da sync`) no longer creates `.claude/rules/` when the corporate-memory bundle is empty.
- `da pull` no longer creates `server/parquet/` when the manifest is empty.
- `da snapshot create` (formerly `da fetch`) no longer materializes an empty `user/duckdb/analytics.duckdb` when run before any `da pull`.
- Workspace `da status` reads from the canonical `server/parquet/` and `user/duckdb/analytics.duckdb` paths (was reading legacy `data/parquet/`, `data/metadata/last_sync.json`).
- `da init` and `da 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 `da query --remote` already produces.
- `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 `da catalog --metrics` for read-only views and `da admin metrics …` for write operations).
- Legacy workspace directories `data/parquet/`, `data/duckdb/`, `data/metadata/`, `user/artifacts/`. Existing analyst workspaces should be reinitialized with `da init --server-url ... --token ... --force` (a fresh empty folder is recommended).
- `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).
### Kept (clarified)
- `da skills list` and `da skills show` survive as analyst-side discovery commands. Earlier draft proposed removal; the rebased main strengthened the bundled skill content (#160 cost-guardrail and registry-gating rails) and removing the surface would cost analyst documentation that the project actively maintains.
- `da auth token {create,list,revoke}` stays under `da auth` (where it lives today). No top-level `da token` group is added.
- `agnes skills list` and `agnes skills show` survive as analyst-side discovery commands. Earlier draft proposed removal; the rebased main strengthened the bundled skill content (#160 cost-guardrail and registry-gating rails) and removing the surface would cost analyst documentation that the project actively maintains.
- `agnes auth token {create,list,revoke}` stays under `agnes auth` (where it lives today). No top-level `da token` group is added.
```