agnes-the-ai-analyst/app/web/setup_instructions.py
Vojtech c09c85d13a
fix(cta): clipboard fallback + fold Atlassian MCP into connectors (#249)
* 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.
2026-05-11 21:54:51 +02:00

848 lines
42 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.
The bundled `agnes skills` knowledge base (markdown documents listable
via `agnes skills list` / readable via `agnes skills show <name>`) is
intentionally NOT surfaced as its own setup step (#242 dropped that
interactive 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 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 _connectors_block(
step_num: str,
connector_prompts: dict[str, str],
*,
confirm_step_num: str,
) -> list[str]:
"""Per-connector interactive ask + inline prompt. Last interactive
step before Confirm.
Defaults to install (Y) — the user has to actively type "no" to skip.
Rationale: when the operator provisions a shared GWS OAuth app
(AGNES_GWS_CLIENT_ID + AGNES_GWS_CLIENT_SECRET), the GWS path runs
in ~2 minutes with zero GCP clickops; Asana + Atlassian only need
the user's own API token from their respective developer consoles —
which they need either way to use the tool. Default-install matches
"wire everything up" — the common path.
Each connector's prompt body comes verbatim from
``app/web/connector_prompts.py`` — same source the /home tile cards
render, so editing one place updates both surfaces. The prompts
themselves are idempotent (each has its own step-0 precheck) so
re-runs short-circuit cleanly.
Connectors are presented in the order they appear in the
``connector_prompts`` dict — the caller controls ordering by how
they build the dict. Today that's asana → gws → atlassian, matching
the /home tile order.
"""
lines = [
"",
f"{step_num}) Connect the user's tools (last interactive ask before Confirm):",
"",
" For each tool below, ask the user verbatim: \"Set up <NAME> now? (Y/n)\".",
" Treat empty/Enter as YES — the default is install. Only skip when the",
" user types an explicit \"no\" / \"n\" / \"skip\". Wait for each answer",
" before moving to the next. The prompts below are idempotent and",
" safe to re-run if anything goes sideways.",
"",
]
# Stable ordering — let the dict iteration order win (Python 3.7+ insertion-ordered).
# Caller controls which connectors are present; we just iterate.
sub_letters = "abcdefghij"
for i, (slug, body) in enumerate(connector_prompts.items()):
# Display name is sourced from the registry so the ask phrasing stays
# in lockstep with what /home renders.
from app.web.connector_prompts import CONNECTORS # local import — avoids module-load cycles
display = next((c.display_name for c in CONNECTORS if c.slug == slug), slug)
desc = next((c.description for c in CONNECTORS if c.slug == slug), "")
lines.append(f" {sub_letters[i]}) {display}{desc}")
lines.append(f" Ask: \"Set up {display} now? (Y/n)\"")
lines.append(" If yes (default) — follow this inline prompt verbatim:")
lines.append("")
# Indent the prompt body two spaces so it visually nests under the
# ask. Empty lines stay empty (no trailing whitespace).
for body_line in body.split("\n"):
lines.append(f" {body_line}" if body_line else "")
lines.append("")
lines.extend([
f" After all asks (regardless of answers) continue to step {confirm_step_num}.",
])
return lines
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 +
skills + connectors + 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",
" - Whether skills were copied or left on-demand",
" - Confirmation that `~/.agnes/marketplace/.git/` exists "
"(the marketplace clone) and that any granted plugins installed",
" - Which connectors got set up: Asana, Google Workspace, and "
"Atlassian — installed or declined for each (the per-connector ask "
"in the previous step drives this)",
]
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` may be empty: registering the per-user
marketplace clone with Claude Code is useful even when the operator
has zero plugin grants, because it pre-wires the SessionStart hook
and the grant flow — admin grants land on the next Claude Code
session without re-running setup. The block copy adapts for the
empty case so the comment-bullet doesn't promise plugin installs
that won't happen.
`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 plugins:"
if has_plugins
else "Register the Agnes Claude Code marketplace (no plugins granted yet):"
)
bullet_5 = (
" # 5. install every plugin listed in the served manifest"
if has_plugins
else " # 5. (no plugins to install — your account has zero grants)"
)
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. From then on, the",
" SessionStart hook keeps the marketplace clone in sync via",
" `agnes refresh-marketplace --quiet` on every Claude Code session.",
]
else:
trailer = [
" Your account has no plugin grants right now, but registering the",
" marketplace anyway pre-wires the SessionStart hook. When an admin",
" grants you a plugin later, `agnes refresh-marketplace` (run by the",
" hook on every Claude Code session) will install it automatically —",
" 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 _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(*, has_connectors: bool = True) -> 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), diagnose (6), connectors (7),
confirm (8). Preflight + marketplace + connectors are always-on:
- Marketplace registration is useful even when the operator has
zero plugin grants (SessionStart hook reconciles future grants
automatically).
- Connectors (Asana / GWS / Atlassian) are per-connector default-yes
asks — the user can decline each individually, so always-emitting
the block costs nothing for users who skip everything. The
Atlassian Remote MCP registration (`claude mcp add ...`) used to
be its own step 6 but moved INTO the Atlassian connector's
prompt body (atlassian_prompt() in app.web.connector_prompts) so
everything Atlassian-related lives in one group — both the
/home Atlassian tile and the inlined Atlassian sub-block in the
setup script emit the same `claude mcp add` line as part of the
standard connector setup.
The interactive "Skills" step that previously sat between diagnose
and Confirm was deleted in #242 — on-demand `agnes skills show
<name>` is the one-size-fits-all default; bulk-copying every skill
into ``~/.claude/skills/agnes/`` was an opinion question without an
obvious right answer.
`has_connectors` is kept as a parameter for future flexibility
(default True). When set False, the connectors step is skipped and
Confirm shifts down to step 7.
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
diagnose = str(n); n += 1
connectors = str(n) if has_connectors else ""
if has_connectors:
n += 1
confirm = str(n)
return {
"preflight": preflight,
"marketplace": marketplace,
"diagnose": diagnose,
"connectors": connectors,
"confirm": confirm,
}
def resolve_lines(
wheel_filename: str,
*,
plugin_install_names: list[str] | None = None,
server_host: str = "",
ca_pem: str | None = None,
connector_prompts: dict[str, 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).
`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).
`connector_prompts` is a dict {slug: prompt_text} sourced from
:func:`app.web.connector_prompts.all_connector_prompts`. When empty
or None we fall back to the module's defaults (no operator GWS OAuth
credentials baked in — the unconfigured GCP-walkthrough branch
renders). Both the /home tiles and this setup script consume the
same dict so the prompt text stays in lockstep across surfaces.
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())
# Lazy default for connector_prompts. Imported inline so the
# setup_instructions module stays import-light when callers don't
# actually emit the connectors block (tests that hit a single helper
# don't pay the cost of loading the prompt strings).
if not connector_prompts:
from app.web.connector_prompts import all_connector_prompts
connector_prompts = all_connector_prompts()
# Step layout. Preflight + marketplace + MCP go BEFORE diagnose;
# connectors is the LAST interactive ask before Confirm — once plugins
# + MCP + diagnose are settled, the only remaining work is plugging
# the user's tools (Asana / GWS / Atlassian). The Skills step that
# used to sit between diagnose and Confirm was deleted in #242
# (on-demand `agnes skills show <name>` is the default;
# bulk-copying skills was an opinion question).
steps = _step_numbers(has_connectors=True)
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
# Standalone MCP step used to live here — moved INTO the Atlassian
# connector's prompt body in step 7 so all Atlassian setup is grouped
# together.
lines.extend(_diagnose_lines(diagnose_num=steps["diagnose"])) # 6
# Connectors are the LAST interactive ask before Confirm. Per-connector
# default-yes — empty/Enter is install, explicit "no" skips.
lines.extend(_connectors_block(
steps["connectors"], connector_prompts,
confirm_step_num=steps["confirm"],
))
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,
connector_prompts: dict[str, 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, connector_prompts) tuple.
"""
lines = resolve_lines(
wheel_filename,
plugin_install_names=plugin_install_names,
server_host=server_host,
ca_pem=ca_pem,
connector_prompts=connector_prompts,
)
text = "\n".join(lines)
return text.replace("{server_url}", server_url).replace("{token}", token)