* Capture session paths via SessionStart hook + lock parallel pushes Replace the encoding-based scan of ~/.claude/projects/<encoded-cwd>/ with a queue file populated by a new `agnes capture-session` SessionStart hook. The hook reads the documented `transcript_path` field from Claude Code's hook stdin JSON, sidestepping the cwd-to-folder encoding (which is an internal implementation detail and varies by Claude Code version). - New `agnes capture-session` subcommand appends transcript_path to <workspace>/.claude/agnes-sessions.txt. Silent on all malformed input so a hook chain failure doesn't clutter Claude Code startup. - `agnes push` now consumes the queue: atomic snapshot rename guards against hooks writing during the push window, successful uploads land in agnes-sessions-uploaded.txt (TSV: timestamp + path), failed paths are requeued. - Cross-platform single-instance lock via the filelock package (fcntl on POSIX, msvcrt on Windows). Concurrent SessionEnd hooks — common when the user closes several sessions at once — silent-exit on the losing side instead of all racing the upload. - Recovery: pre-existing snapshot files from a crashed push are picked up and processed before the live queue. - The SessionStart `agnes push` self-heal entry is dropped — it became redundant once the queue persists across runs (orphans from headless / crashed sessions ship out on the next interactive SessionEnd push). Existing workspaces auto-migrate via the marker-based replace logic. - Legacy encoding scan stays available behind `--legacy-scan` for one- off backfills of sessions predating the queue. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Add /agnes-private + statusLine indicator for private sessions Users handling sensitive data inside Claude Code can now opt a session out of the Agnes upload pipeline, either proactively (right after session start) or reactively (mid-session). The `/agnes-private` slash command runs `agnes mark-private` deterministically via `!`-prefix direct bash — no AI in the loop. A workspace-installed statusLine surfaces a `🔒 agnes-private` indicator in Claude Code's status bar so the user sees the state at a glance. Authoritative source of "do not upload" is a separate file `<workspace>/.claude/agnes-sessions-private.txt` (one session_id per line). Both `capture-session` (queue writer) and `push` (queue reader) consult the list. This makes the slash-command / SessionStart-hook race impossible by construction: whichever runs first, the session is correctly filtered out. - `agnes mark-private` reads `CLAUDE_CODE_SESSION_ID` from env (set by Claude Code in every bash subprocess it spawns — stable documented API) and appends to the private list. - `agnes statusline` reads the session JSON Claude Code pipes on stdin, checks the private list, and emits the indicator or nothing. Optimized for the high call frequency of statusLine renders. - `capture-session` extracts session_id from hook stdin and skips queue write when the ID is already on the private list (race protection). - `push` filters snapshot entries by the private list and appends to a per-workspace audit log `agnes-sessions-private-skipped.txt`. - Queue format migrated from `<path>` to `<session_id>\t<path>`; legacy one-column lines still parse (empty session_id, still upload, can't be marked private retroactively — fine, they pre-date the feature). - `install_claude_hooks` writes a workspace statusLine unless the user already has a custom one (warn + preserve). Idempotent re-init. - `install_claude_commands` ships `agnes-private.md` alongside `update-agnes-plugins.md`. Per-template fallback so a missing template doesn't get clobbered with the wrong content. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Fix setup-prompt + CLAUDE.md marketplace copy + drop skills step Three issues against the post-PR-#240 / post-PR-#237 state: 1. Setup prompt's marketplace block trailer (both has-stack and empty-stack variants) claimed the SessionStart hook keeps the marketplace clone in sync via `agnes refresh-marketplace --quiet` on every session and that admin grants land automatically — both false since PR #237 (0.47.x) moved the install/update path out of the hook into the `/update-agnes-plugins` slash command. The hook is `--check`-only: detects server-side changes, prompts the user to run the slash command, which does the full reconcile interactively with output visible in the transcript. 2. The empty-stack variant framed composition as "admin grants only", missing the actual three-source served stack: (admin RBAC ∩ /marketplace subscriptions) ∪ system-mandatory plugins (admin-pinned, auto-applied) ∪ Flea market installs (skills/agents bundled, plugins standalone) Updated copy spells out all three sources so analysts know where their stack picks live, and what the SessionStart hook actually does on change detection. 3. CLAUDE.md template's "Agnes Marketplace" section conflated eligibility (`resolve_allowed_plugins` — what's listed) with served stack (`resolve_user_marketplace` — what actually reaches Claude Code). The two are different: a user can be RBAC-eligible for a plugin without having subscribed to it on /marketplace. Rewrote the section to distinguish the eligibility set from the served stack and to describe the `--check`-only hook accurately. Plus: deleted the setup prompt's interactive Skills step (final step before Confirm). The named-opinion question — "do you want me to bulk-copy every skill into ~/.claude/skills/agnes/ or pull on-demand via `agnes skills show <name>`?" — had no obvious right answer for new users at the tail end of a wall of technical steps. On-demand lookup is the one-size-fits-all default; `agnes skills list/show` remain discoverable and the CLAUDE.md template references specific skills inline (e.g. agnes-data-querying in the BigQuery section) where they're relevant. Layout: Confirm shifts from step 9 to step 8. Tests updated, full setup/marketplace/welcome surface green (115 passed). Remaining full-suite failures are pre-existing (BQ/Keboola fixtures, Windows charmap collection error in test_v26_keboola_e2e) — verified against a clean stash, unrelated to this diff. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Fix session-queue race + snapshot PID-reuse data loss Two blocker fixes from the PR #242 review: 1. Concurrent SessionStart hooks could corrupt the queue file on Windows. Python's `open(path, "a")` is not atomic there — the CRT does not pass FILE_APPEND_DATA to CreateFile, so concurrent appenders (user opening several Claude Code windows simultaneously) could interleave bytes mid-line. The malformed lines then silently fail the parser and the entries are dropped. Fix: wrap append_to_queue, requeue_failed, and snapshot_queue in a short-lived FileLock on a dedicated `agnes-queue.lock`. Separate from `agnes-push.lock` so capture-session hooks don't block on the push command. New test_append_concurrent_threads_no_corruption reproduces the race with 4 threads x 50 appends. 2. Snapshot filenames embedded only the PID (`agnes-sessions.snapshot. <PID>.txt`). After a crashed push left a snapshot on disk and the OS recycled the PID for a new push, `os.rename` would atomically overwrite the recovery snapshot — every entry in it lost, silently. Fix: append a uuid8 hex tail (`agnes-sessions.snapshot.<PID>. <uuid8>.txt`). find_recovery_snapshots already globs the prefix so it picks up both old and new format. New test_snapshot_filename_is_unique_per_call asserts two consecutive snapshots under the same PID don't collide. Targeted tests green (47/47 in session_queue/capture_session/cli_push). Full suite failures unchanged from baseline (pre-existing BQ/Keboola fixture issues per CLAUDE.md). * Auto-refresh workspace hooks + bash-wrap all hook entries (Windows) Fixes from PR #242 second review (ZdenekSrotyr): 1. `uv.lock` regenerated to include `filelock 3.29.0` (declared in pyproject.toml but missing from the lock file — CI's lockfile-consistency check would fail; `uv pip install` on a clean cache would silently miss the dep). 2. `agnes self-upgrade` now auto-refreshes the workspace Claude Code hooks via the new `cli.lib.hooks.maybe_refresh_claude_hooks`. Closes the silent-stop migration gap: a v0.48 workspace would auto-upgrade the CLI from its existing SessionStart self-upgrade entry but never pick up the new `agnes capture-session` SessionStart hook, leaving the queue empty and `agnes push` uploading nothing. The refresh fires on both the "info is None" fast path (CLI already current — catches the second SessionStart after a prior upgrade) and the install-success path. Guarded by `workspace_has_agnes_hooks` so it never writes `.claude/settings.json` into directories that aren't Agnes workspaces (e.g. `agnes self-upgrade` invoked from `~/`). Errors are surfaced on stderr but never flip the upgrade exit code. 3. All Agnes-managed hooks are now wrapped in `bash -c "..."`. The self-upgrade+pull chained SessionStart entry was the only one still shipping unwrapped — Claude Code on Windows runs hook commands directly without a shell, so the `;` chain + `2>/dev/null` + `|| true` shell syntax silently no-op'd on native Windows installs without Git Bash on PATH. Workspaces still on the old form auto-upgrade via the refresh path above. Tests: +12 in test_lib_hooks.py (guard semantics, v0.48→v0.49 migration end-to-end, third-party-hook preservation, bash-wrap invariant). +5 in test_self_upgrade.py (refresh fires on info=None, fires on install success, skipped on failure, skipped on --check-only, refresh failure never flips exit code). 130 targeted tests green. The 2 pre-existing Windows path-separator failures in `test_smoke_test_detects_version_mismatch[uv|pip]` are unrelated (path mismatch `\fake\uv\bin\agnes` vs `/fake/uv/bin/agnes` in test asserts, pre-PR baseline). * CHANGELOG: document PR-242 main features Closes ZdenekSrotyr #4: the [Unreleased] block was missing entries for the PR's primary surface — only the post-merge fix bullets and the unrelated setup-prompt copy change were captured. Adds: - ### Added: 6 bullets covering the session capture queue + new `agnes capture-session` subcommand, `/agnes-private` slash + `agnes mark-private`, `agnes statusline` + statusLine wiring, `--legacy-scan` opt-in fallback, single-instance push lock, and the new `filelock` runtime dep. - ### Changed: BREAKING bullet on the SessionStart / SessionEnd hook wire format change (capture-session as first SessionStart entry, push self-heal removed, SessionEnd push detached via nohup, all entries bash-wrapped). Folds the prior standalone bash-wrap bullet into this consolidated entry — Z's review flagged the layout shift as BREAKING, and grouping the related sub-changes makes the migration story readable in one place. - Operator migration is auto-handled by `maybe_refresh_claude_hooks` invoked from `agnes self-upgrade` (separate Changed entry below). No `agnes init` re-run required. Pre-queue session jsonls on upgrading workspaces still need a one-off `agnes push --legacy-scan` — flagged in the BREAKING bullet. No code change; doc only. * Drop permanent 4xx uploads instead of requeueing forever Closes ZdenekSrotyr #5. Previously the push retry path requeued any non-200 response except the literal "file not found on disk", so 401 (token expired), 403 (RBAC denial), 413 (payload too large), 400 (server-side validation) cycled through every push run forever — the queue grew without bound and each run re-bombarded the server with the same deterministically-failing upload. Now 4xx (except 408 Request Timeout + 429 Too Many Requests, which the HTTP spec marks as transient) is dropped and audit-logged to `<workspace>/.claude/agnes-sessions-failed.txt`: <iso_ts>\t<session_id>\t<status>\t<transcript_path> 5xx and network errors continue to requeue — those reflect server / transport state that can change between runs, so retry is the right behavior. The audit log piggybacks on the push single-instance lock (agnes-push.lock) — push is the only writer to this file, same as the existing `mark_uploaded` and `mark_private_skipped` paths, so no separate filelock is needed. `agnes push --json` surfaces a new `dropped_permanent` counter; non- quiet stdout mentions the audit-log path so operators tailing the output have a pointer to the forensic trail. Tests: +7 in test_cli_push.py (401/400/403/413 → drop; 408/429 → requeue; 500/502/503 → requeue; network exception → requeue; --json `dropped_permanent` counter; stdout audit-log pointer). +1 in test_session_queue.py (mark_failed_permanent TSV format). 127/129 targeted tests green. The 2 pre-existing Windows path-separator failures in `test_smoke_test_detects_version_mismatch [uv|pip]` are unrelated (path mismatch `\fake\uv\bin\agnes` vs `/fake/uv/bin/agnes` in test asserts, pre-PR baseline). * Catch OSError in push lock acquisition Closes ZdenekSrotyr #8. `acquire_or_skip` in `cli/lib/push_lock.py` previously caught only `filelock.Timeout`. Any `OSError` from `FileLock.acquire` — read-only filesystem, permission denied on `.claude/`, disk full, hardware I/O failure — propagated as an unhandled traceback. Two visible failure modes: - SessionEnd hook: `|| true` in the wrapper swallowed the error, so daily pushes silently never ran. Operator had no signal. - Manual `agnes push`: ugly Python traceback dumped to the terminal instead of a clean exit. Now `OSError` is treated the same as `Timeout` — yield `None`, caller returns cleanly with rc=0. The operator's environment in these scenarios has bigger problems than missing session uploads, so we swallow rather than retry-loop or surface a noisy warning. Test: `test_push_silent_exit_when_filelock_raises_oserror` patches the `FileLock` used inside `push_lock` to raise OSError on acquire, verifies push exits 0 with no traceback and the queue is preserved for the next attempt. * Address remaining S2 items from PR-242 review Four items from ZdenekSrotyr's S2 list: S2.10 — `_install_statusline` truthy check (cli/lib/hooks.py): replace `if existing:` with explicit `if existing is None or existing == "":`. Documents and tests the behavior for both edge cases (explicit-null and empty-string `statusLine`) — both treated as "not configured" rather than "explicit user opt-out", so we install ours. Two new tests in test_lib_hooks.py pin the contract. S2.6 — onboarding docs for /agnes-private. New "Private sessions" subsection in `config/claude_md_template.txt` (next to Data Sync) covering the slash command, statusbar indicator, and audit-log location. One-line tip in `app/web/setup_instructions.py` so the feature is discoverable at onboarding. S2.9 — e2e privacy test (tests/test_e2e_privacy.py). Wires capture_session → mark_private → push against a recording fake api_post and asserts zero session uploads for the marked one. Three cases: mark-before-capture (queue write skipped), mark-after-capture (push-side filter catches it + audit-logs), control (unmarked sessions upload normally). David #8 — `--legacy-scan` help text now documents the private-list gap (legacy entries carry empty session_id, so the filter is not consulted). The practical impact is bounded — pre-queue sessions cannot have been marked private since the private list is a queue-era feature — but the disclaimer in the help text means an operator running a backfill is not surprised. 68 targeted tests green (3 new e2e + 2 new truthy edge tests + existing). 2 pre-existing Windows path-separator failures in test_smoke_test_detects_version_mismatch[uv|pip] unchanged. Remaining S2 items (statusline mkdir push-back, capture-session silent-fail follow-up) handled in PR comment + follow-up issue respectively. * Address remaining S2 follow-ups (David #8, S2.7, David #11) Three items left over from Mina's bbf63472 batch — that commit addressed S2.6/S2.9/S2.10 + documented David #8 in help text but deferred the actual implementations of S2.7, David #11, and the real David #8 fix to follow-ups. This commit closes them. David #8 — `agnes push --legacy-scan` now consults the private list. Claude Code names jsonls `<session-id>.jsonl`, so the file stem IS the session id; the legacy-scan path can apply the same private filter the queue path uses. Both the dry-run and live-upload code paths fixed. Help text updated (no longer warns the filter is bypassed). Two new tests in test_cli_push.py cover the upload-skip path + the dry-run `would_skip_private` segregation. S2.7 — `statusline`/`is_private` no longer mkdir-pollutes arbitrary workdirs. Split `_claude_dir` into `_claude_dir_writable` (used only from `add_private`) and `_claude_dir_readonly` (no mkdir). The read-only public helpers (`private_list_path`, `read_all_private`, `is_private`) compose the no-mkdir variant by default; `add_private` opts in via `writable=True`. Added a process-local mtime-keyed cache around `read_all_private` so in-process callers (push doing one stat per upload candidate, future `agnes diagnose`) don't re-parse the file on every check. Cache eviction on `add_private` so a sub-second write+read sequence doesn't see stale data even on coarse-mtime filesystems. Two new tests pin the no-mkdir contract + the in-same-second add+read consistency. David #11 — `agnes capture-session` writes a breadcrumb log on every invocation. New `<workspace>/.claude/agnes-capture-session.log` TSV: `<iso_ts>\t<outcome>\t<detail>` where outcome covers every silent- exit path (`ok`, `private_skip`, `empty_stdin`, `bad_json`, `not_object`, `no_transcript_path`, `stdin_read_error`, `write_error`). Gives operators a signal to detect "hook fires but queue stays empty" — without it, an upstream Claude Code stdin- contract change is invisible because the hook always exits 0. Log rolls at 256 KiB so it doesn't grow unbounded on long-lived workspaces. Best-effort: a breadcrumb-write failure is itself swallowed so the hook contract stays "exit 0 always". Skipped in non-Agnes workdirs (no `.claude/` exists) so opening Claude Code in `~/` doesn't pollute it. Five new tests in test_capture_session.py cover the success / bad_json / no_transcript_path / private_skip / no-pollute paths. 115 targeted tests green (test_cli_push, test_capture_session, test_private_list, test_session_queue, test_e2e_privacy, test_lib_hooks, test_statusline, test_mark_private). --------- Co-authored-by: Minas Arustamyan <arustamyan.minas@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: ZdenekSrotyr <zdenek.srotyr@keboola.com>
807 lines
40 KiB
Python
807 lines
40 KiB
Python
"""Single source of truth for the "Setup a new Claude Code" clipboard payload.
|
||
|
||
Both the JS-embedded clipboard renderer (`_claude_setup_instructions.jinja`)
|
||
and the read-only HTML preview on the dashboard and /install pages consume
|
||
these lines. Keep it in Python so there is exactly ONE place that edits.
|
||
|
||
Placeholders `{server_url}`, `{token}`, `{wheel_filename}`, and `{server_host}`
|
||
are substituted at render time. `{wheel_filename}` and `{server_host}` are
|
||
pre-substituted server-side via `resolve_lines()`; `{server_url}` and
|
||
`{token}` survive into the JS template and are filled in at click time.
|
||
|
||
`{wheel_filename}` is server-pre-substituted because `uv tool install`
|
||
validates the PEP 427 filename *in the URL path* before fetching, so a
|
||
stable alias like `agnes.whl` fails with "Must have a version" — we need
|
||
the real versioned filename inlined.
|
||
|
||
`{server_host}` is server-pre-substituted because the `git config` and
|
||
`claude plugin marketplace add` lines need the bare host (no scheme), and
|
||
the click-time JS only knows the full origin (`{server_url}`).
|
||
|
||
## Cross-platform trust strategy (when `ca_pem` is supplied)
|
||
|
||
The trust block (step 0) is the load-bearing piece. Three things bit us in
|
||
practice and the design here exists to dodge each one:
|
||
|
||
1. **rustls rejects the Agnes leaf cert as `CaUsedAsEndEntity`.** The Agnes
|
||
server's self-signed cert is simultaneously its own CA (basicConstraints
|
||
`CA:TRUE`) AND the leaf served on the wire — a setup OpenSSL tolerates
|
||
but webpki/rustls strictly refuses. So `uv tool install <https-url>`
|
||
never works against the Agnes wheel endpoint. We download the wheel via
|
||
curl first (curl uses OpenSSL, accepts the cert), then `uv tool install
|
||
--native-tls --force <local-file>` lets rustls reuse the OS trust store
|
||
for PyPI dependency resolution. No HTTPS hop through rustls touches the
|
||
Agnes host.
|
||
|
||
2. **`SSL_CERT_FILE` REPLACES the trust store, it doesn't append.** Pointing
|
||
it at `~/.agnes/ca.pem` alone breaks every Python tool that needs to
|
||
reach a public host (PyPI, GitHub) — `da` works fine because it only
|
||
talks to Agnes, but `uv run --with <pkg>` immediately fails with
|
||
`UnknownIssuer`. We materialize a combined bundle at
|
||
`~/.agnes/ca-bundle.pem` (system roots + Agnes CA) and point all
|
||
`SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` / `GIT_SSL_CAINFO` at it.
|
||
`NODE_EXTRA_CA_CERTS` keeps pointing at just `ca.pem` because Node's
|
||
semantics is *additive* (appends to bundled roots), so a single-cert
|
||
file is correct there.
|
||
|
||
3. **Bun-compiled `claude` (Windows + macOS distributions) ignores every
|
||
CA env var AND the OS trust store for marketplace HTTPS.** On macOS
|
||
arm64 the binary at `~/.local/bin/claude` is a Mach-O with a `__BUN`
|
||
segment (single-file `bun build --compile`); on Windows claude.exe is
|
||
the same shape. `strings` shows the binary recognizes
|
||
`NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE`,
|
||
`CURL_CA_BUNDLE` (including a "NODE_EXTRA_CA_CERTS detected" log
|
||
string), but in practice the values never reach the TLS context — a
|
||
known limitation of Bun's compiled-binary HTTPS path. Registering the
|
||
cert in the OS trust store (Windows: `certutil -user -addstore Root`;
|
||
macOS: `security add-trusted-cert`; Linux: `update-ca-certificates` /
|
||
`update-ca-trust`) doesn't fix it on Windows or macOS either — the
|
||
binary's bundled CA list isn't refreshable from the OS store.
|
||
|
||
So the marketplace step always uses system `git clone` regardless of
|
||
platform — system git honors `GIT_SSL_CAINFO` from the combined bundle
|
||
in step 0(d). We tried having Linux attempt direct HTTPS first (where
|
||
node-based claude DOES respect `NODE_EXTRA_CA_CERTS`), but `claude
|
||
plugin marketplace add <https-url>` is broken end-to-end on every
|
||
distribution: it does succeed at downloading the marketplace.json, but
|
||
stores it as a single file. The plugin entries' `source: "./plugins/<name>"`
|
||
paths are then resolved as local filesystem paths against that file's
|
||
parent dir — and the plugin tree obviously isn't there. Only the clone
|
||
path produces a real directory tree that `plugin install` can read.
|
||
|
||
The OS trust-store registration in (c) is still done on all three
|
||
platforms because it's needed for *non-claude* native tools — e.g.
|
||
the system git fetch path itself (Schannel on Windows, Security
|
||
framework on macOS) trusts via the OS store, not via env vars.
|
||
|
||
Marketplace refresh: after the initial clone, `agnes refresh-marketplace`
|
||
incrementally `git pull`s against the same clone and runs `claude plugin
|
||
marketplace update agnes`. Credentials are injected per-pull via a
|
||
one-shot git credential helper (PAT from `~/.config/agnes/token.json`)
|
||
so the cloned repo's `origin` URL stays PAT-free at rest. The
|
||
SessionStart hook (installed by `agnes init`) calls refresh-marketplace
|
||
on every Claude Code session so changes server-side propagate
|
||
automatically.
|
||
|
||
## Step ordering
|
||
|
||
The numbered steps are arranged so that:
|
||
- All installation work (CLI, plugins) happens first, in one go.
|
||
- `agnes init` is mandatory — it bundles auth, workspace bootstrap,
|
||
CLAUDE.md fetch, and Claude Code SessionStart/End hooks into one
|
||
non-interactive call. Replaces the old `agnes auth import-token` +
|
||
`agnes auth whoami` pair.
|
||
- `agnes diagnose` runs late so it doubles as a final smoke test after
|
||
plugins are in place, instead of gating them. It is also the last
|
||
step before Confirm — the whole prompt is non-interactive, no
|
||
decision questions for the user.
|
||
|
||
Layout:
|
||
0 TLS trust block (only when ca_pem is supplied)
|
||
1 Install CLI
|
||
2 agnes init (auth + workspace bootstrap)
|
||
3 agnes catalog (smoke verify)
|
||
4 Pre-flight: git + claude
|
||
5 Marketplace (always, even with empty served stack)
|
||
6 MCP servers (Atlassian Remote MCP)
|
||
7 Diagnose
|
||
8 Confirm
|
||
|
||
The combined-bundle source uses a fallback chain so the prompt still works
|
||
on machines without the system Python `certifi`: we try (a) `python3 -c
|
||
'import certifi'`, (b) the platform's curl/openssl bundle path, (c)
|
||
`uv run --with certifi` as a network last-resort. The user explicitly
|
||
permitted that fallback chain — it's not improvising-around-a-TLS-error.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
# Marketplace name as published by app.marketplace_server.packager.
|
||
# Hard-coded here (rather than imported) to keep this module dependency-free
|
||
# and trivially testable. If the value ever drifts, the regression test
|
||
# below catches it.
|
||
_MARKETPLACE_NAME = "agnes"
|
||
|
||
|
||
def _tls_trust_block(ca_pem: str) -> list[str]:
|
||
"""Step 0 — cross-platform TLS trust bootstrap for the Agnes server.
|
||
|
||
Emitted only when the server has a non-publicly-trusted cert. Does four
|
||
things in a single numbered block (see module docstring for the full
|
||
rationale):
|
||
|
||
(a) Detect platform (Windows Git Bash / macOS / Linux) and pick the
|
||
shell rc file that the user's login shell actually reads.
|
||
`$SHELL`-driven, NOT existence-of-rc-driven — old setups put a
|
||
legacy `.bashrc` next to a default zsh shell on macOS, and the
|
||
`[ -f .bashrc ]` heuristic silently writes to the wrong file.
|
||
(b) Write the cert PEM to `~/.agnes/ca.pem` via single-quoted heredoc
|
||
(so `$` / backtick chars in real-world certs never shell-expand).
|
||
(c) Register the cert in the OS trust store (so native binaries that
|
||
bypass our env vars — claude.exe, system git's Schannel backend,
|
||
Python apps using `truststore` — still trust the host).
|
||
Idempotent: re-running just re-affirms the entry.
|
||
(d) Build a *combined* CA bundle (system roots + Agnes CA) at
|
||
`~/.agnes/ca-bundle.pem`, with a fallback chain for the system
|
||
roots source. Persist `SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` /
|
||
`GIT_SSL_CAINFO` pointing at the bundle, plus
|
||
`NODE_EXTRA_CA_CERTS` pointing at just `ca.pem` (Node
|
||
appends-not-replaces). Persistence is idempotent via a grep
|
||
guard for the `AGNES_CA_PEM_TRUST` marker.
|
||
"""
|
||
pem = ca_pem.strip()
|
||
lines: list[str] = [
|
||
"0) Trust the Agnes TLS certificate — cross-platform setup for a self-signed / private-CA host.",
|
||
"",
|
||
" (a) Detect platform + pick the shell rc file your login shell actually reads.",
|
||
" Driven by $SHELL + uname (NOT by which rc files happen to exist on disk).",
|
||
"",
|
||
" case \"$(uname -s)\" in",
|
||
" Darwin) PLATFORM=macos ;;",
|
||
" Linux) PLATFORM=linux ;;",
|
||
" MINGW*|MSYS*|CYGWIN*) PLATFORM=windows ;;",
|
||
" *) echo \"Unsupported OS: $(uname -s)\" >&2; exit 1 ;;",
|
||
" esac",
|
||
" SHELL_NAME=\"$(basename \"${SHELL:-bash}\")\"",
|
||
" case \"${SHELL_NAME}:${PLATFORM}\" in",
|
||
" zsh:*) RC=\"$HOME/.zshrc\" ;;",
|
||
" bash:macos) RC=\"$HOME/.bash_profile\" ;;",
|
||
" bash:windows|bash:linux) RC=\"$HOME/.bashrc\" ;;",
|
||
" *) RC=\"$HOME/.profile\" ;;",
|
||
" esac",
|
||
" echo \"Platform: $PLATFORM, shell: $SHELL_NAME, rc: $RC\"",
|
||
"",
|
||
" (b) Write the cert (single-quoted heredoc so $/backticks in the body don't expand):",
|
||
"",
|
||
" mkdir -p ~/.agnes",
|
||
" cat > ~/.agnes/ca.pem <<'AGNES_CA_PEM'",
|
||
]
|
||
# PEM body is flush-left: `<<'DELIM'` heredocs preserve leading whitespace,
|
||
# and any indent inside the cert breaks `openssl x509` / Python ssl parsers.
|
||
lines.extend(pem.splitlines())
|
||
lines.extend([
|
||
"AGNES_CA_PEM",
|
||
"",
|
||
" (c) Register the cert in the OS trust store. Native binaries (claude.exe,",
|
||
" system git's Schannel/Security.framework backends) read the OS store",
|
||
" and ignore our env vars — without this, the later marketplace `git",
|
||
" clone` (when plugins are configured) and any user-side git/native",
|
||
" tooling against the Agnes host will fail.",
|
||
" No admin rights needed (user-store only). Idempotent.",
|
||
"",
|
||
" case \"$PLATFORM\" in",
|
||
" windows)",
|
||
" WIN_CA=\"$(cygpath -w ~/.agnes/ca.pem)\"",
|
||
" certutil.exe -user -addstore \"Root\" \"$WIN_CA\"",
|
||
" ;;",
|
||
" macos)",
|
||
" # Will prompt once for the keychain password.",
|
||
" security add-trusted-cert -r trustRoot \\",
|
||
" -k \"$HOME/Library/Keychains/login.keychain-db\" \\",
|
||
" ~/.agnes/ca.pem",
|
||
" ;;",
|
||
" linux)",
|
||
" if command -v update-ca-certificates >/dev/null 2>&1; then",
|
||
" sudo cp ~/.agnes/ca.pem /usr/local/share/ca-certificates/agnes.crt",
|
||
" sudo update-ca-certificates",
|
||
" elif command -v update-ca-trust >/dev/null 2>&1; then",
|
||
" sudo cp ~/.agnes/ca.pem /etc/pki/ca-trust/source/anchors/agnes.crt",
|
||
" sudo update-ca-trust",
|
||
" else",
|
||
" echo \"WARN: install ~/.agnes/ca.pem into your distro's trust store manually\" >&2",
|
||
" fi",
|
||
" ;;",
|
||
" esac",
|
||
"",
|
||
" (d) Build a COMBINED CA bundle (system roots + Agnes CA) for Python tools",
|
||
" and curl. SSL_CERT_FILE *replaces* the trust store, so pointing it at",
|
||
" the Agnes CA alone would break public hosts (PyPI etc.). Source the",
|
||
" system roots from a fallback chain — the first source that produces",
|
||
" a non-empty, existing path wins. Don't abort on the first miss; that's",
|
||
" what the chain is for.",
|
||
"",
|
||
" CERTIFI_PATH=\"$(python3 -c 'import certifi; print(certifi.where())' 2>/dev/null || true)\"",
|
||
" [ -z \"$CERTIFI_PATH\" ] && CERTIFI_PATH=\"$(python -c 'import certifi; print(certifi.where())' 2>/dev/null || true)\"",
|
||
" if [ -z \"$CERTIFI_PATH\" ]; then",
|
||
" for p in /mingw64/ssl/certs/ca-bundle.crt /usr/ssl/certs/ca-bundle.crt \\",
|
||
" /etc/ssl/certs/ca-certificates.crt /etc/pki/tls/certs/ca-bundle.crt \\",
|
||
" /etc/ssl/cert.pem; do",
|
||
" [ -f \"$p\" ] && CERTIFI_PATH=\"$p\" && break",
|
||
" done",
|
||
" fi",
|
||
" if [ -z \"$CERTIFI_PATH\" ]; then",
|
||
" CERTIFI_PATH=\"$(uv run --native-tls --with certifi --no-project python -c 'import certifi; print(certifi.where())' 2>/dev/null || true)\"",
|
||
" fi",
|
||
" if [ -z \"$CERTIFI_PATH\" ] || [ ! -f \"$CERTIFI_PATH\" ]; then",
|
||
" echo \"ERROR: locate a system CA bundle. Install Python 3 + certifi and re-run.\" >&2",
|
||
" exit 1",
|
||
" fi",
|
||
" echo \"Base CA bundle: $CERTIFI_PATH\"",
|
||
" cat \"$CERTIFI_PATH\" ~/.agnes/ca.pem > ~/.agnes/ca-bundle.pem",
|
||
"",
|
||
" (e) Persist env vars in the rc file picked in (a). Idempotent — won't",
|
||
" duplicate on re-run thanks to the AGNES_CA_PEM_TRUST grep guard.",
|
||
" Note the asymmetry: SSL_CERT_FILE (and REQUESTS_CA_BUNDLE, GIT_SSL_CAINFO)",
|
||
" point at the COMBINED bundle because those tools REPLACE trust.",
|
||
" NODE_EXTRA_CA_CERTS points at just ca.pem because Node APPENDS to its",
|
||
" bundled roots.",
|
||
"",
|
||
" if ! grep -q 'AGNES_CA_PEM_TRUST' \"$RC\" 2>/dev/null; then",
|
||
" cat >> \"$RC\" <<'AGNES_RC_BLOCK'",
|
||
"# AGNES_CA_PEM_TRUST — added by Agnes setup",
|
||
"# Combined bundle (system roots + Agnes CA) for tools that REPLACE trust:",
|
||
"export SSL_CERT_FILE=\"$HOME/.agnes/ca-bundle.pem\"",
|
||
"export REQUESTS_CA_BUNDLE=\"$HOME/.agnes/ca-bundle.pem\"",
|
||
"export GIT_SSL_CAINFO=\"$HOME/.agnes/ca-bundle.pem\"",
|
||
"# Single-cert file for Node (APPENDS to bundled roots):",
|
||
"export NODE_EXTRA_CA_CERTS=\"$HOME/.agnes/ca.pem\"",
|
||
"export PATH=\"$HOME/.local/bin:$PATH\"",
|
||
"AGNES_RC_BLOCK",
|
||
" fi",
|
||
" # Apply for THIS shell too:",
|
||
" export SSL_CERT_FILE=\"$HOME/.agnes/ca-bundle.pem\"",
|
||
" export REQUESTS_CA_BUNDLE=\"$HOME/.agnes/ca-bundle.pem\"",
|
||
" export GIT_SSL_CAINFO=\"$HOME/.agnes/ca-bundle.pem\"",
|
||
" export NODE_EXTRA_CA_CERTS=\"$HOME/.agnes/ca.pem\"",
|
||
" export PATH=\"$HOME/.local/bin:$PATH\"",
|
||
"",
|
||
" IMPORTANT for the Bash tool: env vars do NOT persist between separate",
|
||
" Bash invocations. Re-export the four lines above (SSL_CERT_FILE,",
|
||
" REQUESTS_CA_BUNDLE, GIT_SSL_CAINFO, NODE_EXTRA_CA_CERTS) plus PATH at",
|
||
" the top of every later step's bash block that talks to Agnes.",
|
||
"",
|
||
])
|
||
return lines
|
||
|
||
|
||
def _install_cli_lines(*, has_ca: bool, server_url_placeholder: str = "{server_url}") -> list[str]:
|
||
"""Step 1 — install the `agnes` CLI.
|
||
|
||
When the trust block was emitted (`has_ca=True`), we MUST avoid
|
||
`uv tool install <https-url>` against the Agnes wheel endpoint:
|
||
rustls rejects the Agnes leaf cert with `CaUsedAsEndEntity`, regardless
|
||
of `--native-tls` (the rejection is at chain validation, not at trust
|
||
lookup — putting the cert in the OS store doesn't fix it). Solution:
|
||
download the wheel with `curl --cacert` (curl uses OpenSSL, no rustls),
|
||
then `uv tool install --native-tls` from the local file. PyPI deps
|
||
still resolve over HTTPS, but `--native-tls` makes uv use the OS trust
|
||
store for that path, which is fine because PyPI's CA chain is public.
|
||
|
||
When `has_ca=False`, we trust the server's cert is publicly valid, so
|
||
the simple direct install works.
|
||
"""
|
||
if has_ca:
|
||
return [
|
||
"1) Install the CLI.",
|
||
" The Agnes server's self-signed cert trips rustls' CaUsedAsEndEntity check,",
|
||
" so direct `uv tool install <https-url>` against the wheel endpoint fails",
|
||
" (even with --native-tls). Workaround: curl-then-local-install.",
|
||
"",
|
||
" If uv is missing first:",
|
||
" curl -LsSf https://astral.sh/uv/install.sh | sh",
|
||
" export PATH=\"$HOME/.local/bin:$PATH\"",
|
||
"",
|
||
" WHEEL=/tmp/{wheel_filename}",
|
||
f" curl -fsSL --cacert ~/.agnes/ca.pem -o \"$WHEEL\" {server_url_placeholder}/cli/wheel/{{wheel_filename}}",
|
||
" uv tool install --native-tls --force \"$WHEEL\"",
|
||
"",
|
||
" If `agnes --version` fails after install because ~/.local/bin is not on PATH:",
|
||
" export PATH=\"$HOME/.local/bin:$PATH\"",
|
||
" # persist: append the same line to your ~/.zshrc or ~/.bashrc",
|
||
" # (the trust block in step 0 already does this for you on first run).",
|
||
]
|
||
return [
|
||
"1) Install the CLI:",
|
||
f" uv tool install --force {server_url_placeholder}/cli/wheel/{{wheel_filename}}",
|
||
"",
|
||
" If uv is not installed yet:",
|
||
" curl -LsSf https://astral.sh/uv/install.sh | sh",
|
||
"",
|
||
" If `agnes --version` fails after install because ~/.local/bin is not on PATH:",
|
||
" export PATH=\"$HOME/.local/bin:$PATH\"",
|
||
" # persist: append the same line to your ~/.zshrc or ~/.bashrc",
|
||
]
|
||
|
||
|
||
def _init_lines(server_url_placeholder: str = "{server_url}") -> list[str]:
|
||
"""Steps 2-3 — `agnes init` (auth + workspace bootstrap) + smoke verify.
|
||
|
||
`agnes init` is the workspace-rails delivery mechanism for everyone:
|
||
it authenticates with the PAT, fetches CLAUDE.md (RBAC-filtered),
|
||
writes AGNES_WORKSPACE.md (human-facing docs), installs Claude Code
|
||
SessionStart/End hooks (auto-refresh), and runs an initial `agnes pull`
|
||
so DuckDB views are ready. Subsumes the legacy `agnes auth import-token`
|
||
+ `agnes auth whoami` pair — `init` already verifies the PAT against
|
||
`/api/catalog/tables` internally, and `agnes catalog` then doubles as
|
||
a smoke verify of the data plane.
|
||
|
||
The PAT minted by `/setup` is `general` scope with a 90 d TTL, so the
|
||
init call will succeed for the operator's whole 90 d window without
|
||
re-clicking "Generate prompt".
|
||
"""
|
||
return [
|
||
"",
|
||
"2) Bootstrap your Agnes workspace in this directory:",
|
||
f" agnes init --server-url \"{server_url_placeholder}\" --token \"{{token}}\" --workspace .",
|
||
"",
|
||
" This authenticates with the PAT, fetches your CLAUDE.md (RBAC-filtered),",
|
||
" writes AGNES_WORKSPACE.md (human-facing docs), installs Claude Code",
|
||
" SessionStart/End hooks (auto-refresh), and runs an initial `agnes pull`",
|
||
" so your DuckDB views are ready.",
|
||
"",
|
||
"3) Verify the data is queryable:",
|
||
" agnes catalog",
|
||
"",
|
||
" This should list the tables your account has grants for. Empty list",
|
||
" means your admin hasn't granted you access yet — contact them.",
|
||
"",
|
||
" Tip: type `/agnes-private` inside any Claude Code session to mark it",
|
||
" private — its transcript is skipped by `agnes push` (audit-logged to",
|
||
" `.claude/agnes-sessions-private-skipped.txt`). The statusbar shows",
|
||
" `🔒 agnes-private` while you're in a private session.",
|
||
]
|
||
|
||
|
||
def _diagnose_lines(*, diagnose_num: str) -> list[str]:
|
||
"""Diagnose step — runs AFTER the marketplace + MCP blocks.
|
||
|
||
Putting it last (instead of right after `whoami`) means it doubles as
|
||
a server-health smoke test that runs once everything else is in place,
|
||
not as a gate before them. It is the last step before Confirm — the
|
||
whole prompt is non-interactive.
|
||
|
||
The bundled `agnes skills` knowledge base (markdown documents listable
|
||
via `agnes skills list` / readable via `agnes skills show <name>`) is
|
||
no longer surfaced from this prompt: discovery happens organically
|
||
when CLAUDE.md or another skill references a specific entry (see the
|
||
`agnes skills show agnes-data-querying` mention in the CLAUDE.md
|
||
template's BigQuery section). Bulk-copying every skill into
|
||
`~/.claude/skills/agnes/` at setup time was an interactive opinion
|
||
question with no obvious right answer; on-demand lookup is the
|
||
one-size-fits-all default.
|
||
"""
|
||
return [
|
||
"",
|
||
f"{diagnose_num}) Run diagnostics:",
|
||
" agnes diagnose",
|
||
"",
|
||
" This should print \"Overall: healthy\". `db_schema: unknown` and",
|
||
" `data: 0 tables` are NORMAL in two cases:",
|
||
" - fresh install (no tables registered yet), and",
|
||
" - non-admin roles (e.g. `analyst`) that don't have grants to read",
|
||
" the system schema even on populated instances.",
|
||
" Only flag actual yellow/red checks (api / duckdb_state / users).",
|
||
]
|
||
|
||
|
||
def _finale_lines(*, confirm_step_num: str, has_ca: bool) -> list[str]:
|
||
"""Final Confirm step. Bullets it asks the assistant to report on must
|
||
only reference earlier steps that were actually emitted, otherwise the
|
||
assistant either hallucinates an answer or asks the user about a
|
||
non-existent step. The CA-bundle-source bullet only makes sense when
|
||
the trust block ran (`has_ca`). The marketplace clone bullet is
|
||
unconditional now — preflight + marketplace are always emitted (Fix B
|
||
in the 2026-05-10 init-report response). Init + catalog + diagnose +
|
||
version always render, so their bullets are unconditional."""
|
||
bullets = [
|
||
" - `agnes --version` output",
|
||
" - First few lines of `agnes catalog` (tables you can see)",
|
||
" - Confirmation that `./CLAUDE.md` and `./AGNES_WORKSPACE.md` exist",
|
||
" - Confirmation that `./.claude/settings.json` contains SessionStart/End hooks",
|
||
" - The `agnes diagnose` overall status",
|
||
" - Confirmation that `~/.agnes/marketplace/.git/` exists "
|
||
"(the marketplace clone) and that any plugins currently in the "
|
||
"served stack installed cleanly",
|
||
" - Reminder to scroll to the connector cards on /home and connect "
|
||
"Asana / Google Workspace / Atlassian (those run separately from this script)",
|
||
]
|
||
if has_ca:
|
||
bullets.append(
|
||
" - Which CA bundle source got picked in step 0(d) "
|
||
"(system Python certifi / system curl bundle / uv-fetched)"
|
||
)
|
||
return [
|
||
f"{confirm_step_num}) Confirm:",
|
||
" Tell me \"Agnes workspace is ready\" and summarize:",
|
||
*bullets,
|
||
]
|
||
|
||
|
||
def _preflight_block(step_num: str) -> list[str]:
|
||
"""Pre-flight check — runs before the marketplace clone.
|
||
|
||
`claude plugin marketplace add` (and our git-clone fallback) shells out
|
||
to `git`, AND the marketplace step calls `claude` itself, so a missing
|
||
binary on either side fails the step with a confusing error. We check
|
||
both here so the user gets a single clear "install X" message instead
|
||
of debugging a downstream error.
|
||
|
||
Cross-platform install commands cover the three supported workstation
|
||
OSes:
|
||
- macOS: Homebrew (`brew install git`). The Xcode CLT bundle also
|
||
ships git; we prefer brew because it's non-interactive.
|
||
- Windows: winget (`winget install --id Git.Git -e ...`). Bundled
|
||
with Windows 10 1809+ and Windows 11; non-interactive with --silent.
|
||
- Linux: apt or dnf, depending on distro family.
|
||
|
||
For `claude` we point at the official platform installer docs rather
|
||
than vendoring an install one-liner — Anthropic ships per-platform
|
||
installers (npm on Linux, native binary on macOS/Windows) and the
|
||
canonical instructions live at https://docs.claude.com/claude-code.
|
||
|
||
`step_num` is parameterized because step ordering shifted between
|
||
layouts (the marketplace block now runs before diagnose/skills, so
|
||
preflight + marketplace are steps 4-5 instead of 6-7).
|
||
"""
|
||
return [
|
||
"",
|
||
f"{step_num}) Make sure git and claude are installed (required for the marketplace clone):",
|
||
" git --version",
|
||
" claude --version",
|
||
"",
|
||
" If `git --version` fails (\"command not found\" or similar), install git:",
|
||
" - macOS: brew install git",
|
||
" - Windows: winget install --id Git.Git -e --source winget --silent",
|
||
" - Linux: sudo apt-get install git OR sudo dnf install git",
|
||
"",
|
||
" If `claude --version` fails, install Claude Code:",
|
||
" - npm (Linux / WSL): npm i -g @anthropic-ai/claude-code",
|
||
" - macOS / Windows native installer: see https://docs.claude.com/claude-code",
|
||
"",
|
||
" Then re-run both `--version` checks to confirm before continuing.",
|
||
]
|
||
|
||
|
||
def _marketplace_block(
|
||
plugin_install_names: list[str],
|
||
step_num: str,
|
||
) -> list[str]:
|
||
"""Build the marketplace + plugin-install block.
|
||
|
||
`plugin_install_names` is the user's current *served stack* as
|
||
computed by `src/marketplace_filter.py:resolve_user_marketplace` —
|
||
i.e.::
|
||
|
||
(admin_RBAC ∩ /marketplace subscriptions)
|
||
∪ system-mandatory plugins (admin-pinned, auto-applied)
|
||
∪ Flea market installs (skills/agents bundled, plugins standalone)
|
||
|
||
May be empty: the served stack is curated by the user on the
|
||
`/marketplace` page (admin grants are eligibility only — the user
|
||
opts in via "Add to stack") plus whatever the admin pinned as
|
||
system-mandatory plus the user's own Flea market picks. A brand-new
|
||
account with no system plugins and no curation has an empty stack
|
||
until something lands in any of those three buckets.
|
||
|
||
Registering the marketplace clone is unconditional regardless —
|
||
Claude Code learns about the `agnes` marketplace at bootstrap, and
|
||
the moment the served stack becomes non-empty, the user's next
|
||
`/update-agnes-plugins` run installs the diff. No need to re-run
|
||
setup when the stack changes server-side.
|
||
|
||
`step_num` is parameterized because step ordering shifted between
|
||
layouts (this block now runs before diagnose/skills, so it's step 5
|
||
instead of the old step 7).
|
||
|
||
The whole block is one CLI invocation: ``agnes refresh-marketplace
|
||
--bootstrap``. The CLI handles clone + PAT-strip + chmod + register-
|
||
with-Claude + auto-install-from-manifest internally. This is what
|
||
used to be a 15-line shell sequence inline; pulling it into the CLI
|
||
bought:
|
||
|
||
1. **Claude Code permission gate friendliness.** The agent-driven
|
||
onboarding flow inside Claude Code denies ``rm -rf`` by default;
|
||
the inline script tripped on it. Wrapping the destructive prep
|
||
inside agnes lets the CLI's already-trusted permission grant
|
||
cover it (Python ``shutil.rmtree`` doesn't pattern-match the
|
||
shell ``rm -rf`` block).
|
||
2. **Idempotence without inline ``rm``.** Re-running the install
|
||
prompt over an existing clone now does fetch+reset under the
|
||
hood (no destructive cleanup needed). The prompt's "safe to
|
||
re-run" promise holds without forcing the operator to delete
|
||
anything by hand.
|
||
3. **One source of truth.** ``agnes refresh-marketplace`` is also
|
||
the SessionStart hook command, so install + refresh share the
|
||
same code path — version-aware reconcile, hook JSON output,
|
||
credential helper PAT injection, all consistent.
|
||
|
||
Why always clone (with the CLI doing it) instead of trying direct
|
||
HTTPS marketplace add first? ``claude plugin marketplace add
|
||
<https-url>`` does succeed against our ``/marketplace.git/`` endpoint
|
||
(returns 200 + JSON), but Claude Code stores the response as a
|
||
single-file marketplace and resolves plugin ``source:
|
||
"./plugins/<name>"`` paths as local filesystem refs — so the
|
||
subsequent ``claude plugin install`` looks for plugin trees at
|
||
``<marketplace-dir>/plugins/<name>/`` and 404s because the dir is a
|
||
file. Only the git-clone path produces a real directory tree with
|
||
plugin contents in place. Broken end-to-end on every Claude Code
|
||
distribution; cloning is the only reliable install path.
|
||
|
||
TLS handling for the in-binary ``git clone`` is fully covered by the
|
||
cross-platform trust block (step 0) when the server's cert needs
|
||
bootstrapping (`ca_pem` non-empty), and by the OS trust store when
|
||
the cert is publicly-trusted. There used to be a legacy fallback
|
||
here that emitted a host-scoped ``git config http.<host>.sslVerify
|
||
false`` line for the ``AGNES_DEBUG_AUTH`` path; that's gone — it
|
||
masked operator misconfigurations (a ``self_signed_tls=True``
|
||
instance without ``/data/state/certs/fullchain.pem`` on disk) and
|
||
its ``sslVerify=false`` shell command tripped Claude Code auto-mode
|
||
classifiers. Operators serving a self-signed or private-CA cert
|
||
must place the fullchain at ``AGNES_TLS_FULLCHAIN_PATH`` (default
|
||
``/data/state/certs/fullchain.pem``) so step 0 can read it via
|
||
``_read_agnes_ca_pem``.
|
||
"""
|
||
has_plugins = bool(plugin_install_names)
|
||
header = (
|
||
"Register the Agnes Claude Code marketplace and install your current stack:"
|
||
if has_plugins
|
||
else "Register the Agnes Claude Code marketplace (your stack is empty for now):"
|
||
)
|
||
bullet_5 = (
|
||
" # 5. install every plugin currently in your served stack"
|
||
if has_plugins
|
||
else " # 5. (your served stack is empty right now — nothing to install yet)"
|
||
)
|
||
if has_plugins:
|
||
trailer = [
|
||
" These run non-interactively. After they finish, tell the user to /exit",
|
||
" and run `claude` again so the new plugins load.",
|
||
"",
|
||
" Stack curation lives on the server — visit /marketplace to add or",
|
||
" remove items (admin-granted opt-ins, system plugins your org pinned,",
|
||
" and uploads from the Flea market tab). The SessionStart hook checks",
|
||
" for server-side changes on every Claude Code session and, when it",
|
||
" detects a diff, prompts you to run `/update-agnes-plugins` inside",
|
||
" Claude Code to apply it. No silent auto-install at session start —",
|
||
" the slash command runs full reconcile with output visible in the",
|
||
" transcript, under your control.",
|
||
]
|
||
else:
|
||
trailer = [
|
||
" Your served stack is empty right now — nothing to install yet.",
|
||
" Registering the marketplace clone anyway pre-wires Claude Code so",
|
||
" future picks land cleanly: visit /marketplace to add plugins to",
|
||
" your stack (admin-granted opt-ins, uploads from the Flea market",
|
||
" tab), or wait for your admin to pin something as system-mandatory.",
|
||
"",
|
||
" When your stack becomes non-empty, the SessionStart hook detects",
|
||
" the change on the next Claude Code session and prompts you to run",
|
||
" `/update-agnes-plugins` inside Claude Code to install the new",
|
||
" items. No need to re-run this setup script.",
|
||
]
|
||
return [
|
||
"",
|
||
f"{step_num}) {header}",
|
||
" # `agnes refresh-marketplace --bootstrap` does:",
|
||
" # 1. clone the per-user marketplace bare repo to ~/.agnes/marketplace",
|
||
" # 2. strip the PAT from the cloned origin URL (refreshes use a",
|
||
" # per-invocation git credential helper, not the URL)",
|
||
" # 3. best-effort chmod 700/600 on POSIX (no-op on Windows NTFS)",
|
||
" # 4. `claude plugin marketplace add ~/.agnes/marketplace`",
|
||
bullet_5,
|
||
" # Idempotent — re-runs over an existing clone do fetch+reset+reconcile",
|
||
" # via the same path the SessionStart hook uses.",
|
||
" agnes refresh-marketplace --bootstrap || {",
|
||
" echo \"ERROR: agnes refresh-marketplace --bootstrap failed\" >&2",
|
||
" exit 1",
|
||
" }",
|
||
"",
|
||
*trailer,
|
||
]
|
||
|
||
|
||
def _mcp_servers_block(step_num: str) -> list[str]:
|
||
"""Register the Atlassian Remote MCP unattended.
|
||
|
||
Why only Atlassian here:
|
||
- Atlassian publishes a hosted SSE MCP at
|
||
https://mcp.atlassian.com/v1/sse with OAuth handled by Claude Code
|
||
automatically on first tool call. No PAT/keychain dance, no
|
||
per-user setup beyond clicking through OAuth once when an
|
||
operator first asks Claude to read a Jira ticket. Safe to
|
||
register unattended in the bootstrap script.
|
||
- Asana and Google Workspace need PAT/keychain flows that don't
|
||
survive non-interactive bootstrap, so those stay on the /home
|
||
connector cards (operator-driven).
|
||
|
||
Idempotent across re-runs: `claude mcp add` returns non-zero when the
|
||
server name already exists, so we soft-fail with `|| true` and a
|
||
one-line note rather than tripping the `set -e` style operators
|
||
sometimes wrap the prompt in. Subsequent runs of the prompt are
|
||
no-ops.
|
||
|
||
Reference: 2026-05-10 init-report — David's `claude mcp list` showed
|
||
only the pre-existing claude.ai Drive connector; Atlassian/Asana/GWS
|
||
weren't registered because the prompt had zero `claude mcp add`
|
||
lines. Fix C in the response plan.
|
||
"""
|
||
return [
|
||
"",
|
||
f"{step_num}) Register the Atlassian MCP server (Jira + Confluence on demand):",
|
||
" # Hosted Remote MCP — Claude Code handles OAuth automatically the",
|
||
" # first time you ask it to read a Jira ticket or Confluence page.",
|
||
" # Idempotent: re-runs are a no-op (the `|| true` swallows the",
|
||
" # \"server already exists\" error from `claude mcp add`).",
|
||
" claude mcp add --transport sse atlassian https://mcp.atlassian.com/v1/sse || true",
|
||
"",
|
||
" Asana and Google Workspace use per-user PAT / CLI flows that don't",
|
||
" fit an unattended bootstrap — connect those from the /home connector",
|
||
" cards after this script finishes.",
|
||
]
|
||
|
||
|
||
def _preamble_lines(*, has_ca: bool) -> list[str]:
|
||
"""Header that opens the prompt before the numbered steps. The
|
||
`step 0(d) fallback chain` reference is only emitted when the trust
|
||
block actually exists (`has_ca`); without it the line points at a
|
||
non-existent step. The "don't disable TLS verification" advice itself
|
||
stays unconditional — it's good guidance regardless of whether the
|
||
server runs with a private CA."""
|
||
lines = [
|
||
"Set up the Agnes CLI on this machine.",
|
||
"",
|
||
"Server: {server_url}",
|
||
"Personal access token: {token}",
|
||
"(Just generated; treat it as a secret.)",
|
||
"",
|
||
"Run these, in order. The script is idempotent — safe to re-run if a step",
|
||
"fails partway through. If a step fails with an unfamiliar error, paste the",
|
||
"exact error back and stop. Do NOT improvise around TLS errors by disabling",
|
||
"verification (`-k`, `NODE_TLS_REJECT_UNAUTHORIZED=0`,",
|
||
"`git -c http.sslVerify=false`, etc.) — those are dead ends that hide the",
|
||
"real problem.",
|
||
]
|
||
if has_ca:
|
||
lines.append(
|
||
"The fallback chain inside step 0(d) is documented and OK to "
|
||
"use; that's what fallback chains are for."
|
||
)
|
||
lines.append("")
|
||
return lines
|
||
|
||
|
||
def _step_numbers() -> dict[str, str]:
|
||
"""Compute the step numbers for the unified layout.
|
||
|
||
Returns a dict keyed by logical step name; values are stringified
|
||
1-based step numbers (preserving the existing string-based helper API
|
||
so call sites stay diff-minimal).
|
||
|
||
Steps (always emitted): install (1), init (2), catalog (3),
|
||
preflight (4), marketplace (5), mcp_servers (6), diagnose (7),
|
||
confirm (8). Preflight + marketplace + mcp_servers are all always-on:
|
||
- Marketplace registration is useful even with an empty served
|
||
stack (future admin grants / system pins / Flea installs land
|
||
cleanly without re-running setup).
|
||
- Atlassian MCP registration is unattended-safe (hosted Remote MCP
|
||
with Claude Code-managed OAuth) and applies to every analyst
|
||
whose work touches Jira/Confluence — high enough hit rate to
|
||
justify default-on.
|
||
|
||
Step-0 (TLS trust block) sits outside this numbering — it is gated by
|
||
has_ca and has its own "0)" header rendered inside the trust block
|
||
helper.
|
||
"""
|
||
n = 4
|
||
preflight = str(n); n += 1
|
||
marketplace = str(n); n += 1
|
||
mcp_servers = str(n); n += 1
|
||
diagnose = str(n); n += 1
|
||
confirm = str(n)
|
||
return {
|
||
"preflight": preflight,
|
||
"marketplace": marketplace,
|
||
"mcp_servers": mcp_servers,
|
||
"diagnose": diagnose,
|
||
"confirm": confirm,
|
||
}
|
||
|
||
|
||
def resolve_lines(
|
||
wheel_filename: str,
|
||
*,
|
||
plugin_install_names: list[str] | None = None,
|
||
server_host: str = "",
|
||
ca_pem: str | None = None,
|
||
) -> list[str]:
|
||
"""Return the template lines with server-side placeholders substituted.
|
||
|
||
Pre-substitutes `{wheel_filename}` and `{server_host}`. Leaves
|
||
`{server_url}` and `{token}` as placeholders for click-time JS
|
||
substitution (or for `render_setup_instructions()` below).
|
||
|
||
The layout is the same regardless of `plugin_install_names`: install
|
||
(1), init (2), catalog (3), preflight (4), marketplace (5),
|
||
mcp_servers (6), diagnose (7), confirm (8). The marketplace block's
|
||
copy adapts to an empty served stack but the step is always emitted
|
||
so future stack changes (admin grants, system pins, Flea installs)
|
||
land cleanly without re-running setup.
|
||
|
||
`ca_pem` (PEM-encoded fullchain of the Agnes server's TLS cert) gates
|
||
the cross-platform step-0 trust-bootstrap block AND switches step 1 to
|
||
the curl-then-local-install pattern AND switches step 5 to the
|
||
platform-aware marketplace strategy. Caller decides whether the cert
|
||
needs the bootstrap (typically: skip for publicly-trusted certs like
|
||
Let's Encrypt, emit for self-signed or private corp CA).
|
||
|
||
Fallback: callers pass `"agnes.whl"` when no wheel is present on disk.
|
||
The resulting URL (`/cli/wheel/agnes.whl`) will 404 at download time, but
|
||
the instruction text still renders so operators can see the snippet shape
|
||
and diagnose the missing wheel on the server.
|
||
"""
|
||
names = list(plugin_install_names or [])
|
||
has_ca = bool(ca_pem and ca_pem.strip())
|
||
|
||
# Step layout — single fixed shape; `_step_numbers` returns the
|
||
# renumbered step labels in one place so the layout is unambiguous
|
||
# and trivially extendable when a future step is added.
|
||
steps = _step_numbers()
|
||
|
||
lines: list[str] = []
|
||
if has_ca:
|
||
lines.extend(_tls_trust_block(ca_pem)) # type: ignore[arg-type]
|
||
lines.extend(_preamble_lines(has_ca=has_ca))
|
||
lines.extend(_install_cli_lines(has_ca=has_ca)) # 1
|
||
lines.extend(_init_lines()) # 2, 3
|
||
lines.extend(_preflight_block(steps["preflight"])) # 4
|
||
lines.extend(_marketplace_block(names, step_num=steps["marketplace"])) # 5
|
||
lines.extend(_mcp_servers_block(steps["mcp_servers"])) # 6
|
||
# Diagnose runs AFTER marketplace + MCP wiring so it doubles as a
|
||
# final smoke test, not a pre-install gate.
|
||
lines.extend(_diagnose_lines(diagnose_num=steps["diagnose"])) # 7
|
||
lines.append("")
|
||
lines.extend(_finale_lines(
|
||
confirm_step_num=steps["confirm"],
|
||
has_ca=has_ca,
|
||
))
|
||
|
||
return [
|
||
line.replace("{wheel_filename}", wheel_filename).replace("{server_host}", server_host)
|
||
for line in lines
|
||
]
|
||
|
||
|
||
def render_setup_instructions(
|
||
server_url: str,
|
||
token: str,
|
||
wheel_filename: str = "agnes.whl",
|
||
*,
|
||
plugin_install_names: list[str] | None = None,
|
||
server_host: str = "",
|
||
ca_pem: str | None = None,
|
||
) -> str:
|
||
"""Render the setup instructions as a single string.
|
||
|
||
Used server-side for tests and any non-JS rendering path. The browser
|
||
clipboard flow uses the JS renderer embedded in the Jinja partial; both
|
||
must produce byte-identical output for a given (server_url, token,
|
||
wheel, plugins, host, ca_pem) tuple.
|
||
"""
|
||
lines = resolve_lines(
|
||
wheel_filename,
|
||
plugin_install_names=plugin_install_names,
|
||
server_host=server_host,
|
||
ca_pem=ca_pem,
|
||
)
|
||
text = "\n".join(lines)
|
||
return text.replace("{server_url}", server_url).replace("{token}", token)
|