* fix(cta): fall back to textarea+execCommand when Clipboard API rejects
The "Setup a new Claude Code" CTA fetches /auth/tokens, parses the JSON
response, renders the setup script, THEN calls
`navigator.clipboard.writeText()`. Modern browsers (Safari, Firefox, and
Chrome on stricter configurations) reject `writeText` with
NotAllowedError when transient user activation has been consumed by an
intervening `await` — which is exactly the case here. Users perceived
this as "the browser blocked the copy" and got the manual-paste fallback
modal even though the textarea + `document.execCommand('copy')` path
WOULD have worked synchronously without needing fresh user activation.
`copyToClipboard` now:
- prefers the modern Clipboard API (unchanged for the happy path)
- on writeText rejection, falls back to `copyViaTextarea` instead of
surfacing the rejection to the caller's catch block.
`copyViaTextarea` is the previously-inline textarea fallback factored
out into a named helper, with two small hardening touches:
- `readonly` + `tabindex=-1` so the hidden textarea doesn't steal
focus or pop the virtual keyboard on mobile.
- explicit `setSelectionRange(0, text.length)` to belt-and-braces the
selection on iOS Safari (where `.select()` alone sometimes selects
zero chars on touch-focused textareas).
Only the CTA button needed this — the Step-1 install-command and the
connector-copy buttons all call `writeText` synchronously inside the
click handler (no awaits in between), so they keep their existing
user-gesture context and didn't hit the same rejection. No template
changes there.
* refactor(home): fold Atlassian MCP registration into connectors block
The standalone "Register the Atlassian MCP server" step (was step 6 in
the unified setup script) moves INTO the Atlassian connector's prompt
body so all Atlassian-related setup lives in one logical group. Same
intent that #247 carried for connectors, applied one level deeper:
the hosted Remote MCP registration is part of "set up Atlassian", not
its own ungrouped step.
What changed:
- `app/web/connector_prompts.py` — the Atlassian prompt's step 5
replaces the speculative "Register the on-demand Atlassian MCP under
.claude/mcp/atlassian" line with the actual hosted Remote MCP
registration: `claude mcp add --transport sse atlassian
https://mcp.atlassian.com/v1/sse || true`. The `|| true` keeps re-runs
idempotent and the body explains the OAuth-on-first-use contract.
Both /home's Atlassian tile and the inlined setup-script Atlassian
sub-block emit this line — single source of truth holds.
- `app/web/setup_instructions.py` — `_mcp_servers_block` deleted; the
`mcp_servers` step is removed from `_step_numbers`; resolve_lines no
longer calls it.
- Renumbering: install (1), init (2), catalog (3), preflight (4),
marketplace (5), diagnose (6), connectors (7), confirm (8). Was:
6 = mcp_servers, 7 = diagnose, 8 = connectors, 9 = confirm.
- `tests/test_setup_instructions.py` — Confirm step 9→8, Connect 8→7,
diagnose 7→6, mcp_servers references dropped.
`test_step_numbering_with_connectors_step` now asserts
`"mcp_servers" not in steps`. Stray-Confirm assertion lists shift
by one position.
- `tests/test_setup_page_unified.py` + `tests/test_web_ui.py` — same
step-number shifts in the rendered /setup preview assertions.
The `claude mcp add` line is still the Atlassian Remote-MCP path that
the 2026-05-10 init-report Fix C added — only its position in the
flow changes. /home Atlassian tile copying continues to install the
MCP too (the prompt body the tile pastes contains the same line).
112 tests pass.
* feat(atlassian): operator-overrideable base URL via AGNES_ATLASSIAN_BASE_URL
Adds an env var / YAML key the operator (Terraform module, customer-VM
template, OSS instance.yaml) can set to bake the Atlassian Cloud site
root into the connector prompt — so end users don't have to guess /
paste their org's `https://<myorg>.atlassian.net`.
When set, the Atlassian connector prompt (rendered on both /home tile
and inlined into the setup-script step 7 Atlassian sub-block) replaces
step 1's "Ask me for my Atlassian Cloud site URL and email" with a
one-line note that the URL is already provisioned by the operator and
asks only for the email. Step 4's helper-script body has the
`BASE_URL='<the site URL I gave you>'` placeholder substituted with
the literal value. When unset (empty), the existing "ask the user"
flow remains — no regression for OSS instances.
Resolution + normalization in `get_atlassian_base_url()`:
- env `AGNES_ATLASSIAN_BASE_URL` > yaml `instance.atlassian.base_url` > ""
- strips trailing slash + trailing `/wiki` so the canonical value is
the bare site root. Matches the per-user helper script's
normalization at storage time (atlassian_prompt step 4 guard 2), so
the literal baked in by the operator stays consistent with what the
user's helper script would have computed from their input.
Plumbing:
- `app/instance_config.py`: new `get_atlassian_base_url()` resolver.
- `app/web/connector_prompts.py`:
- `atlassian_prompt(*, base_url: str = "")` — string-replace two
explicit placeholder phrases when base_url is truthy; otherwise
return the prompt unchanged.
- `all_connector_prompts(..., atlassian_base_url: str = "")` —
forwards the kwarg.
- `app/web/router.py` (`_build_context`): reads
`get_atlassian_base_url()` and passes it through to
`all_connector_prompts(...)` so both the /home tile context AND the
inlined-script `resolve_lines(...)` call use the same value.
- `src/welcome_template.py` (`compute_default_agent_prompt`): same
threading via the existing import-on-demand path.
Tests (`tests/test_home_route_resolution.py`):
- `get_atlassian_base_url` resolver: default empty, env override,
trailing-slash strip, trailing-`/wiki` strip.
- `atlassian_prompt(base_url=...)`: literal URL baked in, ask-step
removed, placeholder replaced, operator-baked-in copy appears.
- `atlassian_prompt(base_url="")`: existing ask-the-user flow
unchanged.
- `all_connector_prompts(atlassian_base_url=...)`: kwarg threads
through to the rendered atlassian prompt.
135 tests pass.
* feat(asana): register hosted Asana Remote MCP in connector prompt
The Asana connector prompt only stored a PAT in the OS keychain + ran
a curl verify against /api/1.0/users/me. That set Claude Code up for
direct `curl` calls but didn't actually wire Asana into Claude's tool
list — so the user couldn't ask Claude to "find my open Asana tasks"
and have it work. Symmetric oversight to the Atlassian connector's
original speculative `.claude/mcp/atlassian` line that this branch
already replaced with `claude mcp add --transport sse atlassian
https://mcp.atlassian.com/v1/sse`.
Adds a new step 5 that registers Asana's hosted Remote MCP:
claude mcp add --transport http asana https://mcp.asana.com/mcp || true
This is the V2 endpoint (streamable HTTP transport, launched February
2026). The V1 SSE endpoint at https://mcp.asana.com/sse was deprecated
2026-05-11 (today) and must NOT be used — calling it out explicitly
in the prompt body so a future operator who finds an old reference
doesn't paste the dead URL. OAuth is handled by Claude Code at first
use, same model as the Atlassian MCP step.
The PAT stored in step 3 stays for direct `curl` calls (precheck +
ad-hoc scripts) — the MCP path uses its own OAuth grant, not the PAT.
Old step 5 (revoke instructions) renumbers to step 6 and adds the
`claude mcp remove asana` cleanup hint.
Same single-source-of-truth invariant holds: /home Asana tile + the
inlined Asana sub-block in the setup script (step 7 connectors) both
emit identical text from `asana_prompt()`.
71 tests pass.
* feat(asana): drive MCP OAuth login + end-to-end validation post-register
`claude mcp add --transport http asana ...` only registers the
server in Claude Code's local config — it does NOT trigger OAuth.
The browser tab opens the first time any `mcp__asana__*` tool gets
invoked. So the previous step 5 left a user looking at a "registered"
MCP that, in practice, hadn't authed yet and would fail on first
real use. Same blind spot Atlassian's prompt also has, but Asana was
the one called out in the latest review pass.
Adds a new step 6 between MCP registration (step 5) and the revoke
instructions (now step 7):
a. Tell the user verbatim what's about to happen — a low-impact
read through the MCP will pop the OAuth browser tab; sign in
with the same account whose PAT they stored in step 3 and
approve. Frames the OAuth as one-time so users don't wait
for it on every later call.
b. Drive an actual MCP read. Don't prescribe the exact tool name
because the Asana MCP's exposed surface (`mcp__asana__*`) is
versioned upstream and we don't want to pin to a name that
gets renamed. Instead: tell Claude to pick the lightest read
from its surfaced tool list (users-me / list-workspaces /
equivalent). Document the recovery path when Claude Code
times out waiting for the OAuth tool use: `claude mcp list`
to confirm registration before retrying.
c. Print a single one-line proof that combines wiring + auth:
"Asana MCP connected as <name> — <N> workspace(s) visible."
Explicit anti-echo callout for tokens, task content, comments.
On failure, surface the exact Claude-Code error and stop —
no silent pass.
d. Sanity-check that the MCP OAuth identity and the PAT identity
reference the same Asana account. Easy mistake to make when
the user has multiple Asana accounts — flag only on mismatch,
keep quiet when they match. Recovery: `claude mcp remove asana
&& claude logout asana` then redo step 5.
Step 7 (revoke) absorbs both the keychain delete + the
`claude logout asana` line so users have a single place to undo
everything.
43 tests pass.
* fix(init): clear stale CA env vars on Windows before any TLS handshake
Reported by the 2026-05-11 Windows test pass: after `agnes init` the
gws connector failed with `UnknownIssuer` TLS errors because
`SSL_CERT_FILE` and `REQUESTS_CA_BUNDLE` were still set in Windows
User scope pointing at `C:\Users\localadmin\.config\agnes\ca-bundle.pem`
— a file that did not exist on the test host. Past Agnes installs
(the setup-prompt trust block + older bootstrap helpers) write those
pointers when they materialize a combined Agnes-CA bundle; when the
bundle file later disappears (re-init on a new VM, machine swap, the
~/.agnes dir wiped), the pointers go stale and every native Windows
TLS handshake fails before Agnes itself runs. SSL_CERT_FILE in
particular REPLACES (not appends to) the trust store, so a stale
pointer is silently catastrophic.
`agnes init` now clears stale pointers in two layers before the first
server roundtrip:
1. Current-process env (os.environ) — what the immediately-following
`api_get` to /api/catalog/tables actually reads. Without this, init
itself blows up before it gets to step 2.
2. Windows User-scope env via PowerShell
`[Environment]::SetEnvironmentVariable(name, $null, 'User')` — what
every future shell + every native tool (gws, claude.exe, pip, uv)
inherits. The 2026-05-11 reporter expected this exact cleanup
("init was supposed to clear these but they persisted").
The cleanup is best-effort and conservative:
- Only deletes a var when its value points at a path that does NOT
exist on disk. Intentional operator config (e.g. SSL_CERT_FILE
pointing at a corp certifi bundle) stays put.
- PowerShell missing / restricted execution policy / WSL-without-pwsh:
swallowed silently. The current-process leg still runs, which
unblocks init even on hosts where the User-scope leg cannot fire.
Tests (`tests/test_init_ca_cleanup.py`, 6 cases):
- Stale pointers → removed from process env.
- Real-path pointers → preserved.
- Non-Windows hosts: PowerShell is not invoked.
- Windows hosts: PowerShell IS invoked with a script that checks
all three vars + uses Test-Path + SetEnvironmentVariable.
- PowerShell FileNotFoundError: cleanup swallows it, does not raise.
- `_is_windows_host()` reflects sys.platform.
* refactor(asana): MCP-first flow — drop PAT storage, precheck via `claude mcp list`
The Asana hosted MCP at https://mcp.asana.com/mcp authenticates via
OAuth (Claude Code holds the grant; browser tab pops on first tool
use). The earlier prompt walked the user through creating + keychain-
storing an Asana Personal Access Token AND registering the MCP — two
parallel auth surfaces for one connector. Once the MCP works, the PAT
has no consumer: the precheck/verify steps that used `curl
$BASE/api/1.0/users/me` are just redundant proof that Asana itself is
reachable, which the OAuth handshake already establishes.
Removed:
- Step 0 keychain probe + curl verify against /users/me with PAT.
- Step 1 open developer-console / create PAT.
- Step 2 click "+ New access token", warn shown-ONCE.
- Step 3 helper-script for keychain-storage (per-OS bodies: macOS
`security add-generic-password`, Linux `secret-tool store`, Windows
`cmdkey /generic`).
- Step 4 PAT-side `users/me` verify.
- Step 5's split that kept the PAT around for direct curl scripts.
- Step 6d's "MCP vs PAT identity sanity check" — there is no PAT
anymore, nothing to mismatch against.
New flow (3 steps total):
- Step 0 precheck: `claude mcp list | grep ^asana` — if found, the
server is registered AND Claude Code is holding its OAuth grant
(otherwise prior failure would have removed it); print
"Asana MCP already registered — skipping setup" and stop. Tells the
user the explicit reset command (`claude mcp remove asana && claude
logout asana`) so a re-register stays one paste.
- Step 1: `claude mcp add --transport http asana
https://mcp.asana.com/mcp` — no `|| true` because step 0 should have
caught the "already exists" case. Step explains the V2-vs-V1
endpoint distinction (V1 SSE deprecated 2026-05-11) and the
abort-clean recovery if the precheck somehow missed the existing
server.
- Step 2: same OAuth + low-impact-read validation pattern as before.
- Step 3: revoke instructions (mcp remove + logout + Asana-side app
revoke at app.asana.com/Settings → Apps).
Both surfaces (the /home Asana tile and the inlined Asana sub-block
in the setup script's step 7) emit the new text from the same
asana_prompt() — single-source-of-truth invariant intact.
77 tests pass.
393 lines
18 KiB
Python
393 lines
18 KiB
Python
"""`agnes init` — bootstrap an analyst workspace.
|
|
|
|
Single-paste flow: web user clicks "Generate prompt" on /setup?role=analyst,
|
|
pastes into Claude Code in an empty folder; Claude runs `agnes init` (among
|
|
other steps). Non-interactive: --token + --server-url required.
|
|
|
|
Steps in order:
|
|
1. Detect existing workspace (`CLAUDE.md` containing the init marker) — exit 1
|
|
unless --force, with a typed `partial_state` error.
|
|
2. Verify the PAT via `GET /api/catalog/tables` — typed `auth_failed` on 401,
|
|
`server_unreachable` on network error.
|
|
3. Persist server URL + PAT to `~/.config/agnes/` so subsequent `agnes pull` /
|
|
`agnes push` invocations (including the SessionStart/End hooks installed
|
|
below) inherit the credentials without env vars.
|
|
4. Fetch the rendered CLAUDE.md from `GET /api/welcome` (server-rendered,
|
|
RBAC-filtered, role-aware).
|
|
5. Seed `.claude/settings.json` with default model + permissions, then call
|
|
`cli.lib.hooks.install_claude_hooks` to merge in the SessionStart/End hook
|
|
commands. Then call `cli.lib.commands.install_claude_commands` to drop
|
|
the Agnes-managed slash commands (today: `/update-agnes-plugins`) into
|
|
`<workspace>/.claude/commands/`. Idempotent on re-run.
|
|
6. Write the `.claude/CLAUDE.local.md` stub only when absent — `--force`
|
|
regenerates CLAUDE.md but **never** clobbers the operator-edited
|
|
CLAUDE.local.md.
|
|
7. Run the first `cli.lib.pull.run_pull` so the workspace ships with current
|
|
parquets, DuckDB views, and the corporate-memory bundle.
|
|
8. Render `AGNES_WORKSPACE.md` from `config/agnes_workspace_template.txt` —
|
|
client-side template, three placeholders.
|
|
|
|
Errors render via `cli/error_render.py:render_error` with typed `kind` values
|
|
(`auth_failed`, `server_unreachable`, `partial_state`, `manifest_unauthorized`)
|
|
matching the rest of the CLI surface.
|
|
|
|
Task 18 will register `init_app` on the root Typer app.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import typer
|
|
|
|
from cli.client import api_get
|
|
from cli.config import save_config, save_token
|
|
from cli.error_render import render_error
|
|
from cli.lib.commands import install_claude_commands
|
|
from cli.lib.hooks import install_claude_hooks
|
|
from cli.lib.pull import PullResult, _override_server_env, run_pull
|
|
|
|
|
|
# Substring that flags an already-bootstrapped workspace. The current default
|
|
# CLAUDE.md template renders `# {{ instance.name }} — AI Data Analyst` so this
|
|
# appears in every server-rendered CLAUDE.md. Operators who use a custom admin
|
|
# template can override this via the `--force` flag.
|
|
_INIT_MARKER = "AI Data Analyst"
|
|
|
|
|
|
# Env vars that, when set to a non-existent path, cause every TLS handshake
|
|
# on the host to fail before Agnes itself runs. Past versions of the Agnes
|
|
# setup script's TLS trust block (and older bootstrap helpers) wrote
|
|
# pointers to ``~/.agnes/ca-bundle.pem`` into the user's persistent env
|
|
# (Windows User scope; shell rc files on POSIX). When the file goes away
|
|
# (re-init on a new VM, manual cleanup, machine swap) the pointers go
|
|
# stale — gws auth login, claude plugin marketplace add, even pip/uv,
|
|
# all fail with UnknownIssuer / FileNotFoundError. Reported by the
|
|
# Windows test user 2026-05-11. SSL_CERT_FILE in particular REPLACES
|
|
# (not appends to) the trust store, so a stale pointer is silently
|
|
# catastrophic.
|
|
_CA_ENV_VARS = ("SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "GIT_SSL_CAINFO")
|
|
|
|
|
|
def _is_windows_host() -> bool:
|
|
"""True when the Python interpreter sees Windows underneath.
|
|
|
|
Covers native Python on Windows (``sys.platform == 'win32'``) and
|
|
Git Bash / MSYS launchers (interpreter still reports win32; the
|
|
bash shell wrapper is irrelevant for User-scope env-var management).
|
|
POSIX-only edge cases (WSL with `windows` in /proc/version) stay on
|
|
the POSIX path — User-scope env vars don't exist there in the
|
|
Windows-registry sense, so the cleanup is a no-op.
|
|
"""
|
|
return sys.platform == "win32"
|
|
|
|
|
|
def _cleanup_stale_ca_env_vars() -> None:
|
|
"""Clear stale SSL_CERT_FILE / REQUESTS_CA_BUNDLE / GIT_SSL_CAINFO
|
|
pointers from the current process AND (on Windows) from User scope.
|
|
|
|
Two layers because the failure mode hits both:
|
|
1. Current-process env — what the upcoming `api_get` call to
|
|
/api/catalog/tables actually reads. Without clearing it here, the
|
|
httpx call falls over with a FileNotFoundError before init can
|
|
finish step 2.
|
|
2. Windows User-scope env — what every future shell + every native
|
|
Windows tool (gws, claude.exe, pip, uv) inherits. Without
|
|
clearing it there, the user re-hits the same wall the next time
|
|
they open PowerShell — exactly what the 2026-05-11 Windows test
|
|
user reported ("the init was supposed to clear these but they
|
|
persisted; fixed by removing both vars from User scope").
|
|
|
|
Best-effort. We only delete a var when it points at a path that does
|
|
NOT exist on disk — intentional operator config (e.g. SSL_CERT_FILE
|
|
pointing at a corporate certifi bundle) is preserved. PowerShell
|
|
invocation failures are swallowed silently because the init shouldn't
|
|
abort on a defensive cleanup helper.
|
|
"""
|
|
cleared_process: list[tuple[str, str]] = []
|
|
for var in _CA_ENV_VARS:
|
|
cur = os.environ.get(var)
|
|
if cur and not Path(cur).exists():
|
|
del os.environ[var]
|
|
cleared_process.append((var, cur))
|
|
for name, path in cleared_process:
|
|
typer.echo(
|
|
f"agnes init: cleared stale process env {name}={path} "
|
|
f"(file does not exist)"
|
|
)
|
|
|
|
if not _is_windows_host():
|
|
return
|
|
|
|
# Build a single PowerShell invocation that checks + clears all three
|
|
# User-scope vars in one shot. Quoting strategy: pass the script via
|
|
# -Command with single-quoted strings inside so Python's f-string
|
|
# composition stays simple. We use [Environment]::SetEnvironmentVariable
|
|
# with $null (the documented way to delete a User-scope env var on
|
|
# Windows; setx has no delete verb).
|
|
statements = []
|
|
for var in _CA_ENV_VARS:
|
|
statements.append(
|
|
"$cur = [Environment]::GetEnvironmentVariable('" + var + "', 'User'); "
|
|
"if ($cur -and -not (Test-Path -LiteralPath $cur)) { "
|
|
"[Environment]::SetEnvironmentVariable('" + var + "', $null, 'User'); "
|
|
"Write-Host ('agnes init: cleared stale User-scope " + var + "=' + $cur + ' (file does not exist)') "
|
|
"}"
|
|
)
|
|
ps_script = "; ".join(statements)
|
|
try:
|
|
result = subprocess.run(
|
|
["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", ps_script],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=15,
|
|
)
|
|
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
|
# PowerShell missing (cygwin-only environments), or hung. Skip —
|
|
# the current-process cleanup above already covers the immediate
|
|
# `api_get` failure; persistent state cleanup is best-effort.
|
|
return
|
|
if result.stdout:
|
|
# Forward PowerShell's confirmation lines to the user so the
|
|
# cleanup is auditable. stderr from PowerShell (rare here) is
|
|
# swallowed — the worst it'd add is "execution policy" noise on
|
|
# restricted hosts, which isn't actionable.
|
|
for line in result.stdout.splitlines():
|
|
line = line.strip()
|
|
if line:
|
|
typer.echo(line)
|
|
|
|
|
|
init_app = typer.Typer(help="Bootstrap an analyst workspace in this directory")
|
|
|
|
|
|
@init_app.callback(invoke_without_command=True)
|
|
def init(
|
|
server_url: str = typer.Option(..., "--server-url", help="Agnes server URL"),
|
|
token: str = typer.Option(..., "--token", help="Personal access token"),
|
|
force: bool = typer.Option(False, "--force", help="Re-initialize an existing workspace"),
|
|
workspace_str: Optional[str] = typer.Option(None, "--workspace", help="Target dir (default: cwd)"),
|
|
skip_materialize: bool = typer.Option(
|
|
False, "--skip-materialize",
|
|
help=(
|
|
"Skip materialized-mode tables on the first pull. The first "
|
|
"init can otherwise spend tens of minutes silently downloading "
|
|
"a single multi-GB scheduled-query parquet. Materialized rows "
|
|
"are still discoverable via `agnes catalog`; rerun `agnes pull` "
|
|
"without this flag once you actually need them locally."
|
|
),
|
|
),
|
|
):
|
|
"""Bootstrap workspace: auth, CLAUDE.md, hooks, first pull, AGNES_WORKSPACE.md."""
|
|
workspace = Path(workspace_str).resolve() if workspace_str else Path.cwd()
|
|
server_url = server_url.rstrip("/")
|
|
|
|
# Best-effort cleanup before ANY TLS handshake fires below — stale
|
|
# SSL_CERT_FILE / REQUESTS_CA_BUNDLE / GIT_SSL_CAINFO pointers from a
|
|
# previous Agnes install on this host (or its Windows User-scope
|
|
# registry entries) would otherwise blow up step 2's `api_get` with
|
|
# an opaque "UnknownIssuer" / "FileNotFoundError" before the user
|
|
# has any way to see what's wrong. Reported by the 2026-05-11
|
|
# Windows test pass.
|
|
_cleanup_stale_ca_env_vars()
|
|
|
|
# ------------------------------------------------------------------
|
|
# Step 1: detect an existing workspace.
|
|
# ------------------------------------------------------------------
|
|
claude_md = workspace / "CLAUDE.md"
|
|
if claude_md.exists() and not force:
|
|
try:
|
|
existing = claude_md.read_text(encoding="utf-8")
|
|
except OSError:
|
|
existing = ""
|
|
if _INIT_MARKER in existing:
|
|
typer.echo(render_error(0, {"detail": {
|
|
"kind": "partial_state",
|
|
"hint": "Workspace already initialized. Re-run with --force to redo.",
|
|
}}), err=True)
|
|
raise typer.Exit(1)
|
|
|
|
# On --force, snapshot the existing CLAUDE.md before regenerating it
|
|
# so an operator who edited it can recover their notes (issue #164).
|
|
# Backup name carries an ISO timestamp so multiple `--force` runs in
|
|
# the same workspace don't clobber each other. We write the backup
|
|
# *after* the existing-workspace gate above so the un-forced path is
|
|
# unchanged.
|
|
if claude_md.exists() and force:
|
|
try:
|
|
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
backup_path = workspace / f"CLAUDE.md.bak.{ts}"
|
|
backup_path.write_bytes(claude_md.read_bytes())
|
|
typer.echo(f"Backed up existing CLAUDE.md → {backup_path.name}")
|
|
except OSError as exc:
|
|
# FS error on the backup is annoying but shouldn't abort the
|
|
# init. Surface it so the operator knows their pre-existing
|
|
# CLAUDE.md is about to be overwritten without a recoverable
|
|
# copy on disk, then proceed.
|
|
typer.echo(
|
|
f"Warning: could not write CLAUDE.md backup ({exc}); "
|
|
f"continuing with --force overwrite",
|
|
err=True,
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Step 2: verify the PAT via /api/catalog/tables.
|
|
#
|
|
# `api_get` reads server URL + token from env vars (`AGNES_SERVER`,
|
|
# `AGNES_TOKEN`) via `cli.config`. Wrap the call in
|
|
# `_override_server_env` so the explicit args take effect without
|
|
# mutating the caller's environment permanently. Same mechanism as
|
|
# `cli.lib.pull.run_pull`.
|
|
# ------------------------------------------------------------------
|
|
try:
|
|
with _override_server_env(server_url, token):
|
|
resp = api_get("/api/catalog/tables")
|
|
if resp.status_code == 401:
|
|
typer.echo(render_error(401, {"detail": {
|
|
"kind": "auth_failed",
|
|
"hint": f"Token expired or invalid — get a fresh one at {server_url}/setup",
|
|
}}), err=True)
|
|
raise typer.Exit(1)
|
|
resp.raise_for_status()
|
|
except typer.Exit:
|
|
raise
|
|
except Exception as exc:
|
|
typer.echo(render_error(0, {"detail": {
|
|
"kind": "server_unreachable",
|
|
"hint": f"Cannot reach {server_url} — check network or server status",
|
|
"message": str(exc),
|
|
}}), err=True)
|
|
raise typer.Exit(1)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Step 3: save server URL + token to ~/.config/agnes/ so subsequent
|
|
# invocations (including the SessionStart hook) read them by default.
|
|
# `email=""` because the JWT carries it server-side; we don't decode
|
|
# the token on the client.
|
|
# ------------------------------------------------------------------
|
|
save_config({"server": server_url})
|
|
save_token(token, email="")
|
|
|
|
# ------------------------------------------------------------------
|
|
# Step 4: fetch the rendered CLAUDE.md from /api/welcome.
|
|
# ------------------------------------------------------------------
|
|
workspace.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
with _override_server_env(server_url, token):
|
|
welcome_resp = api_get("/api/welcome", params={"server_url": server_url})
|
|
welcome_resp.raise_for_status()
|
|
except Exception as exc:
|
|
typer.echo(render_error(0, {"detail": {
|
|
"kind": "server_unreachable",
|
|
"hint": "Failed to fetch CLAUDE.md from /api/welcome",
|
|
"message": str(exc),
|
|
}}), err=True)
|
|
raise typer.Exit(1)
|
|
welcome_content = welcome_resp.json().get("content", "")
|
|
claude_md.write_text(welcome_content, encoding="utf-8")
|
|
|
|
# ------------------------------------------------------------------
|
|
# Step 5: default settings.json + install hooks.
|
|
#
|
|
# Seed first-run model + permissions only when the file is absent;
|
|
# `install_claude_hooks` then merges SessionStart/End on top, leaving
|
|
# any third-party keys/hooks intact. Re-running init (with or without
|
|
# --force) is idempotent on settings.json.
|
|
# ------------------------------------------------------------------
|
|
settings_path = workspace / ".claude" / "settings.json"
|
|
if not settings_path.exists():
|
|
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
|
settings_path.write_text(json.dumps(
|
|
{"model": "sonnet", "permissions": {"allow": ["Read", "Bash", "Grep", "Glob"]}},
|
|
indent=2,
|
|
), encoding="utf-8")
|
|
install_claude_hooks(workspace)
|
|
install_claude_commands(workspace)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Step 6: CLAUDE.local.md stub — only when absent. `--force` does NOT
|
|
# overwrite; the operator's notes survive a re-init.
|
|
# ------------------------------------------------------------------
|
|
local_md = workspace / ".claude" / "CLAUDE.local.md"
|
|
if not local_md.exists():
|
|
local_md.parent.mkdir(parents=True, exist_ok=True)
|
|
local_md.write_text(
|
|
"# My Notes\n\nPersonal notes for this workspace. Uploaded on `agnes push`.\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Step 7: first pull. `run_pull` records per-stage failures inside
|
|
# `result.errors` rather than raising for transient issues, so any
|
|
# exception escaping here is a programming error worth surfacing.
|
|
# ------------------------------------------------------------------
|
|
try:
|
|
# `agnes init` always runs interactively (analyst typing the
|
|
# command), so progress is on by default — Pavel's #185 Phase 1
|
|
# was a 44-minute silent download on the very first install.
|
|
# Pass it through to run_pull.
|
|
result: PullResult = run_pull(
|
|
server_url, token, workspace,
|
|
skip_materialize=skip_materialize,
|
|
show_progress=True,
|
|
)
|
|
except Exception as exc:
|
|
typer.echo(render_error(0, {"detail": {
|
|
"kind": "manifest_unauthorized",
|
|
"hint": "Initial pull failed — workspace partially set up",
|
|
"message": str(exc),
|
|
}}), err=True)
|
|
raise typer.Exit(1)
|
|
|
|
# `run_pull` records per-stage failures into `result.errors` and only
|
|
# raises for programming errors. A manifest-stage failure here means
|
|
# the analyst has a saved token + saved server URL but no parquets,
|
|
# no DuckDB views — surface a typed error so the operator knows the
|
|
# workspace is not actually queryable. Common cause: PAT validates
|
|
# against /api/catalog/tables but lacks resource_grants for any tables.
|
|
manifest_err = next((e for e in result.errors if e.get("stage") == "manifest"), None)
|
|
if manifest_err:
|
|
typer.echo(render_error(0, {"detail": {
|
|
"kind": "manifest_unauthorized",
|
|
"hint": "Manifest fetch failed — workspace partially set up. "
|
|
"Check that the PAT has resource_grants for at least one table.",
|
|
"message": manifest_err.get("error", ""),
|
|
}}), err=True)
|
|
raise typer.Exit(1)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Step 8: render AGNES_WORKSPACE.md from the static client-side
|
|
# template. Three placeholders: created_at, server_url, workspace_path.
|
|
# ------------------------------------------------------------------
|
|
here = Path(__file__).parent
|
|
template_path = here.parent.parent / "config" / "agnes_workspace_template.txt"
|
|
if template_path.exists():
|
|
template = template_path.read_text(encoding="utf-8")
|
|
else:
|
|
# Defensive fallback — the template ships with the repo so this
|
|
# branch only fires on a broken install. Better than crashing.
|
|
template = "# Agnes workspace\n\nCreated: {created_at}\nServer: {server_url}\n"
|
|
workspace_md = (
|
|
template
|
|
.replace("{created_at}", datetime.now(timezone.utc).isoformat())
|
|
.replace("{server_url}", server_url)
|
|
.replace("{workspace_path}", str(workspace))
|
|
)
|
|
(workspace / "AGNES_WORKSPACE.md").write_text(workspace_md, encoding="utf-8")
|
|
|
|
# ------------------------------------------------------------------
|
|
# Final: human-readable summary.
|
|
# ------------------------------------------------------------------
|
|
typer.echo("Workspace ready.")
|
|
typer.echo(f" Server : {server_url}")
|
|
typer.echo(f" Tables : {result.tables_updated} synced ({result.parquets_total} total)")
|
|
typer.echo(f" Rules : {result.rules_count}")
|
|
typer.echo(f" Workspace: {workspace}")
|
|
typer.echo("")
|
|
typer.echo("Try: agnes catalog")
|