diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ba1c83..41aade4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,19 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C ## [Unreleased] +### Added + +- **Configurable analyst-facing product brand via `instance.brand` (env `AGNES_INSTANCE_BRAND`, default `"Agnes"`).** Replaces the hard-coded "Agnes" / `~/Agnes` strings across the analyst-facing UI (`/home`, `/setup`, `/setup-advanced`, `/login`, `/install`, `/me/debug`) and the clipboard "Setup a new Claude Code" script. Operators rebranding the OSS (e.g. to "Foundry AI") flip a single env var via Terraform — defaults preserve "Agnes" branding for everyone else. The deploying-organization display name (`instance.name`, "AI Data Analyst") stays untouched; it drives page titles and is conceptually distinct from the product brand. +- **`instance.workspace_dir` (env `AGNES_WORKSPACE_DIR_NAME`)** — filesystem-safe folder name shown in `~/` and baked into the setup script's `mkdir`/`cd`. Defaults to `instance.brand` with non-alphanumerics stripped (`"Foundry AI"` → `"FoundryAI"`). Explicit override exists when the auto-derivation isn't what an operator wants. +- **Explicit "create workspace folder" step on `/home`** — visible OS-tabbed block (POSIX `mkdir -p ~/ && cd ~/` / PowerShell `New-Item … ; Set-Location …`) inserted between auto-mode and the install-from-Claude-Code CTA. Same `mkdir`/`cd` lines are baked into the clipboard script as the new step 2. Replaces the prior implicit assumption that `agnes init --workspace .` would land in a sensibly-cd'd shell. Setup-script step numbering shifts by +1 from step 2 onward; client-side test assertions updated. +- **Final "Restart Claude Code" step in the setup script** — unconditional step inserted between the connectors block and the Confirm summary. Marketplace plugins, MCP servers, and the SessionStart hooks installed during setup only load on the next Claude Code session, so every path (with or without plugins) now ends with an explicit cue to `/exit` and re-launch `claude` from the workspace dir. Confirm shifts to step 10 in the always-on layout. +- **Uniform `✅ ready — …` / `❌ setup failed: …` markers** in every connector prompt body (Asana, Google Workspace, Atlassian). The verify step now emits the same shape across connectors so the final Confirm summary can quote them verbatim back to the user, and operators can grep their session transcripts with a single pattern to confirm each connector landed. + +### Changed + +- **Asana connector reverted from hosted Remote MCP back to PAT + raw REST against `app.asana.com/api/1.0`.** The MCP path (introduced in commit `adee8ea`, 2026-05-11) used ~5× the tokens per call because Claude Code reads the entire MCP response envelope; the PAT + REST path lets the agent read only the fields it needs from a flat JSON response. The new Asana prompt stores the PAT in the OS keychain under `agnes-asana-pat`, verifies against `/users/me` before writing, and prints the unified `✅`/`❌` line. Re-running setup on an instance still holding the leftover MCP registration detects it and asks the user to run `claude mcp remove asana` first so the two surfaces don't compete. +- **Atlassian connector instructs picking the longest API-token expiry (today: "1 year").** The Atlassian Cloud token-create dropdown defaults to a short-lived expiry; the prompt now tells Claude to direct the user to choose the longest option in the "Expires" dropdown. There's no public query-parameter hook on `id.atlassian.com/manage-profile/security/api-tokens` to pre-select the expiry (verified — `?expiry=1y` returns identical HTML); the prompt acknowledges that limitation so a future contributor doesn't re-investigate. + ## [0.53.0] — 2026-05-12 Second hygiene round closing the Tier B trackers opened during the diff --git a/app/instance_config.py b/app/instance_config.py index 486ac1b..d5eabcc 100644 --- a/app/instance_config.py +++ b/app/instance_config.py @@ -258,6 +258,49 @@ def get_instance_subtitle() -> str: return get_value("instance", "subtitle", default="") +def get_instance_brand() -> str: + """Product-name brand string surfaced to end users in the analyst-facing + UI (``/home`` hero copy, ``/setup``, ``/login``, the clipboard setup + script, etc.). Defaults to ``"Agnes"`` — operators rebranding this OSS + set it to e.g. ``"Foundry AI"`` without forking. + + Distinct from :func:`get_instance_name` which drives page titles and + represents the deploying organization's display name ("AI Data Analyst"). + Brand is the *product*; name is the *deployment*. + + Resolution: ``AGNES_INSTANCE_BRAND`` env > ``instance.brand`` YAML > ``"Agnes"``. + Mirrors :func:`get_home_route` shape so Terraform env overrides work. + """ + raw = os.environ.get("AGNES_INSTANCE_BRAND") + if raw is None: + raw = get_value("instance", "brand", default="Agnes") + value = (raw or "").strip() + return value or "Agnes" + + +def get_workspace_dir_name() -> str: + """Filesystem-safe folder name for the analyst's local workspace + (``~/``). Defaults to :func:`get_instance_brand` + with every non-alphanumeric character stripped, so ``"Foundry AI"`` + becomes ``"FoundryAI"`` and ``"Agnes"`` stays ``"Agnes"``. + + An explicit override exists for operators who want a folder name that + doesn't follow the strip-whitespace derivation. + + Resolution: ``AGNES_WORKSPACE_DIR_NAME`` env > ``instance.workspace_dir`` + YAML > derived from :func:`get_instance_brand`. + """ + raw = os.environ.get("AGNES_WORKSPACE_DIR_NAME") + if raw is None: + raw = get_value("instance", "workspace_dir", default="") + explicit = (raw or "").strip() + if explicit: + return explicit + import re + derived = re.sub(r"[^A-Za-z0-9]", "", get_instance_brand()) + return derived or "Agnes" + + def get_instance_admin_email() -> str: """Operator-facing contact address shown in user-side prompts that suggest the user reach out to their Agnes admin (e.g. the /home GWS diff --git a/app/web/connector_prompts.py b/app/web/connector_prompts.py index cf1f56c..971388f 100644 --- a/app/web/connector_prompts.py +++ b/app/web/connector_prompts.py @@ -68,6 +68,7 @@ def all_connector_prompts( gws_oauth: dict | None = None, instance_admin_email: str = "", atlassian_base_url: str = "", + instance_brand: str = "Agnes", ) -> dict[str, str]: """Resolve every connector's prompt text with the operator's runtime config baked in. Caller (router._build_context, setup_instructions @@ -92,7 +93,7 @@ def all_connector_prompts( """ gws_oauth = gws_oauth or {} return { - "asana": asana_prompt(), + "asana": asana_prompt(instance_brand=instance_brand), "gws": gws_prompt( gws_oauth_configured=bool(gws_oauth.get("configured")), gws_client_id=str(gws_oauth.get("client_id") or ""), @@ -103,7 +104,10 @@ def all_connector_prompts( ), instance_admin_email=instance_admin_email, ), - "atlassian": atlassian_prompt(base_url=atlassian_base_url), + "atlassian": atlassian_prompt( + base_url=atlassian_base_url, + instance_brand=instance_brand, + ), } @@ -116,23 +120,27 @@ def all_connector_prompts( # inlines them straight into bash heredocs / numbered steps. # --------------------------------------------------------------------------- -def asana_prompt() -> str: - """Asana MCP setup — registers Asana's hosted Remote MCP (V2 - streamable HTTP at https://mcp.asana.com/mcp) so Claude Code can - read tasks, comment, and create updates on demand. +def asana_prompt(*, instance_brand: str = "Agnes") -> str: + """Asana setup — Personal Access Token stored in OS keychain, used + against Asana's flat REST API at https://app.asana.com/api/1.0. - No PAT storage. The hosted Asana MCP handles auth via OAuth (Claude - Code opens a browser tab on first tool use; the user signs in once - with their Asana account; subsequent calls reuse the grant). - Earlier versions of this prompt walked the user through creating + - keychain-storing an Asana Personal Access Token, but the MCP path - has its own OAuth grant — the PAT had no consumer once the MCP - became the actual integration surface, so it's gone. + We tried the hosted Remote MCP (commit ``adee8ea``, 2026-05-11) but + the MCP path returned large JSON envelopes Claude Code reads in full + on every tool call. On real engagements the token cost was untenable + (~5× the REST equivalent), so we reverted. The PAT + curl path lets + the agent read only the fields it needs from flat responses; on + Asana's side both surfaces use the same workspace permissions. - Precheck short-circuits when `claude mcp list` already shows the - `asana` server registered — re-running setup on a connected machine - is a no-op.""" - return _ASANA_PROMPT + Precheck short-circuits when the keychain already holds a working + PAT — re-running setup on a connected machine is a no-op. Also + detects a leftover MCP registration from the previous MCP-based + flow and asks the user to remove it before continuing, so users + don't end up with two competing Asana surfaces. + + ``instance_brand`` is baked into the token's display label so the + user sees "Claude Code — " when reviewing PATs on Asana's + developer-console page.""" + return _ASANA_PROMPT.replace("{instance_brand}", instance_brand) def gws_prompt( @@ -175,7 +183,7 @@ def gws_prompt( ) -def atlassian_prompt(*, base_url: str = "") -> str: +def atlassian_prompt(*, base_url: str = "", instance_brand: str = "Agnes") -> str: """Atlassian (Jira + Confluence) API token setup. Stores token in OS keychain under ``agnes-atlassian-api-token``, plus email + normalized base URL in ``~/.claude/agnes/secrets.env``. Jira-first / Confluence- @@ -191,19 +199,22 @@ def atlassian_prompt(*, base_url: str = "") -> str: Caller is expected to normalize: strip trailing slash + ``/wiki`` (handled by :func:`app.instance_config.get_atlassian_base_url`).""" + body = _ATLASSIAN_PROMPT.replace("{instance_brand}", instance_brand) if base_url: # The {{ }} pair in the f-string escapes a literal `{` — the # `${ATLASSIAN_BASE_URL%/}` shell parameter expansion in step 0 # precheck uses `${...}` which f-strings DON'T touch, so no # additional escaping is needed there. Replace only the two # explicit placeholder strings. - return _ATLASSIAN_PROMPT \ - .replace("", base_url) \ + body = ( + body + .replace("", base_url) .replace( "1. Ask me for my Atlassian Cloud site URL (looks like https://.atlassian.net) and the email I sign in with. Site URL and email are NOT secrets — fine to type into chat. Don't proceed until I've given you both.", - f"1. Ask me only for the email I sign in with — the Atlassian Cloud site URL is already provisioned by the Agnes operator (``{base_url}``) and baked into the helper script. Email is not a secret — fine to type into chat. Don't proceed until I've given you the email.", + f"1. Ask me only for the email I sign in with — the Atlassian Cloud site URL is already provisioned by the {instance_brand} operator (``{base_url}``) and baked into the helper script. Email is not a secret — fine to type into chat. Don't proceed until I've given you the email.", ) - return _ATLASSIAN_PROMPT + ) + return body # --------------------------------------------------------------------------- @@ -211,17 +222,44 @@ def atlassian_prompt(*, base_url: str = "") -> str: # any per-call allocation cost and trivially diffable. # --------------------------------------------------------------------------- -_ASANA_PROMPT = """Set up Asana access for Claude Code via Asana's hosted Remote MCP. Walk me through it step by step. +_ASANA_PROMPT = """Set up an Asana Personal Access Token for Claude Code. Walk me through it step by step. -Ground rules: this is idempotent — safe to re-run, the precheck below short-circuits when the `asana` MCP server is already registered. No Personal Access Token is created or stored; Asana's hosted MCP handles auth via OAuth, with Claude Code holding the grant. If any 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 hide the real problem. +Ground rules: this is idempotent — safe to re-run, the precheck below short-circuits when Asana is already wired up. We hit Asana's flat REST API at https://app.asana.com/api/1.0 directly via `curl` — no MCP server. (We tried the hosted MCP earlier; it consumed ~5× the tokens per call because the agent reads the entire response envelope, so we reverted to PAT + REST where the agent reads only the JSON fields it needs.) If any 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 hide the real problem. -0. Precheck — skip the rest if Asana is already wired up. Run `claude mcp list` and grep for a line starting with `asana` (the server name we register in step 1). If it's there, the MCP is registered AND Claude Code is holding its OAuth grant (otherwise the server would have been removed by a previous failure). Print "Asana MCP already registered — skipping setup. To force re-register: `claude mcp remove asana && claude logout asana`, then re-run." and STOP. Continue to step 1 only when `claude mcp list` shows NO `asana` row. -1. Register Asana's hosted Remote MCP: `claude mcp add --transport http asana https://mcp.asana.com/mcp`. This is Asana's V2 MCP (streamable HTTP, launched February 2026); the V1 SSE endpoint at `https://mcp.asana.com/sse` was deprecated 2026-05-11 and must not be used. If `claude mcp add` errors with "server already exists", the precheck in step 0 missed it — abort cleanly with `claude mcp remove asana && claude mcp add --transport http asana https://mcp.asana.com/mcp` to force a clean state. No PAT is required: Asana's hosted MCP authenticates via OAuth on first tool use. -2. Log the user in through the Asana MCP, then validate end-to-end before declaring success. Claude Code's MCP OAuth opens a browser tab the FIRST time any tool from the `asana` MCP is invoked, not when the server is added — so the registration in step 1 alone proves nothing. Drive a real verification: - a. Tell the user verbatim: "I'm going to make a low-impact read through the Asana MCP. Your browser will open an Asana sign-in page — sign in with your Asana account and approve the consent screen. The approval is one-time; subsequent MCP calls reuse the grant." - b. Invoke the lightest read the Asana MCP exposes (typically a "list workspaces" / "users me" tool — call whatever shows up under the `mcp__asana__*` prefix in your tool list; pick the one that returns the caller's profile or a small list). Wait for the OAuth tab to come back. If Claude Code times out waiting for tool use, run `claude mcp list` to confirm the server registered, then retry the same MCP call. - c. On success, print ONE line that proves both the wiring AND the auth work: "Asana MCP connected as <display name from the MCP response> — <workspace count> workspace(s) visible." Never echo the OAuth token or any task / comment content. On failure, surface the exact error Claude Code returned (NotAuthenticated, NotFound, network) and stop — do not silently move on. -3. Remind me how to disconnect later: `claude mcp remove asana` removes the server from Claude Code's config; `claude logout asana` drops the OAuth grant. To revoke the grant on Asana's side, sign in at https://app.asana.com and revoke the "Claude Code" entry from Settings → Apps.""" +0. Precheck — skip the rest if Asana is already connected. + a. Leftover MCP registration: run `claude mcp list 2>/dev/null | grep -q '^asana'`. If it matches, ASK me verbatim: "I see Asana's hosted MCP server is still registered with Claude Code. {instance_brand} now uses the Asana REST API directly (PAT + curl). Benefits over the MCP path: (1) ~5× fewer tokens per call — the agent reads only the JSON fields it needs from flat REST responses instead of unwrapping MCP envelopes on every tool use; (2) no third-party hop — requests go straight from your machine to api.asana.com, not through mcp.asana.com (also better in airgapped / corporate-proxy setups where mcp.asana.com may not be allowlisted); (3) no OAuth refresh dance — PATs are static, MCP grants need re-auth on token rotation; (4) deterministic cost — curl returns the same bytes every time, whereas MCP envelope shapes can drift across server versions. Remove the MCP registration now? (Y/n)". Treat empty/Enter as YES. On Y: run `claude mcp remove asana` and (best-effort) `claude logout asana 2>/dev/null || true` to drop the OAuth grant. On explicit n / no / skip: leave it; warn that the two surfaces will compete (Claude Code may try the MCP path first for any `mcp__asana__*` tool calls) and continue to step 0b. + b. Detect my OS, then look up an existing keychain entry under the service name `agnes-asana-pat` and verify it against Asana's API. macOS: `t=$(security find-generic-password -s 'agnes-asana-pat' -w 2>/dev/null) && curl -fsS -H "Authorization: Bearer $t" https://app.asana.com/api/1.0/users/me | jq -r '.data | "✅ Asana ready — connected as \\(.name). \\(.workspaces | length) workspace(s) visible."' && exit 0`. Linux: `t=$(secret-tool lookup service agnes-asana-pat username "$USER" 2>/dev/null) && ...same curl...`. Windows PowerShell: `$cred = cmdkey /list:agnes-asana-pat 2>$null; if ($LASTEXITCODE -eq 0) { Write-Host "Asana cred entry found — verify in your real terminal before re-running setup." }` (Windows can't read the password back without a CredentialManager module — print a hint and let me confirm). If the verify call returns 200, print the one-line "✅ Asana ready" message and STOP. Only continue to step 1 when no cred exists OR the cached token returns 401. +1. Open the Asana developer tokens page in my default browser — use your Bash tool: `open https://app.asana.com/0/developer-console/tokens` on macOS, `xdg-open https://app.asana.com/0/developer-console/tokens` on Linux/WSL, or `Start-Process https://app.asana.com/0/developer-console/tokens` on Windows. Detect OS first. If that URL doesn't render the tokens UI (rare), tell me to click my avatar (top right) → Settings → "Apps" tab → "Manage Developer Apps" → Personal access tokens. +2. Tell me to click "+ New access token", name it "Claude Code — {instance_brand}", and click "Create token". Warn me the token is shown ONCE and Asana PATs do not expire — I'd need to revoke it from the same page if it leaks. +3. Prepare a helper script for me to run in my real terminal (so the token never enters the chat): + a. Detect my OS. Use the Write tool (NOT a shell here-doc that echoes the body) to create `~/.claude/agnes/bin/store-asana.sh` on macOS/Linux, or `~/.claude/agnes/bin/store-asana.ps1` on Windows. chmod 700 the file. Body for macOS: + #!/usr/bin/env bash + set -e + read -srp 'Paste Asana token (hidden): ' t; echo + # Verify against the live API BEFORE storing — never write a bad token to the keychain. + tmp=$(mktemp) + status=$(curl -sS -o "$tmp" -w '%{http_code}' -H "Authorization: Bearer $t" https://app.asana.com/api/1.0/users/me || true) + if [ "$status" != "200" ]; then + if [ "$status" = "401" ]; then + echo "❌ Asana setup failed: token rejected (HTTP 401). The PAT is wrong, revoked, or pasted with whitespace — re-mint at https://app.asana.com/0/developer-console/tokens and retry." >&2 + else + echo "❌ Asana setup failed: API verification returned HTTP $status. Aborting without storing." >&2 + fi + rm -f "$tmp"; unset t; exit 1 + fi + display=$(python3 -c 'import sys,json;d=json.load(sys.stdin)["data"];print(d.get("name","?"))' < "$tmp") + wcount=$(python3 -c 'import sys,json;print(len(json.load(sys.stdin)["data"].get("workspaces",[])))' < "$tmp") + rm -f "$tmp" + security add-generic-password -U -s 'agnes-asana-pat' -a "$USER" -w "$t" + unset t + echo "✅ Asana ready — connected as $display. $wcount workspace(s) visible." + + Linux variant: same shape; replace `security add-generic-password ...` with `printf %s "$t" | secret-tool store --label='Agnes Asana PAT' service agnes-asana-pat username "$USER"`. Windows .ps1: `$t = Read-Host 'Paste Asana token' -AsSecureString; $p = [Runtime.InteropServices.Marshal]::PtrToStringAuto([Runtime.InteropServices.Marshal]::SecureStringToBSTR($t))`; verify with `Invoke-RestMethod -Uri 'https://app.asana.com/api/1.0/users/me' -Headers @{Authorization = "Bearer $p"}` inside a try/catch; on success `cmdkey /generic:agnes-asana-pat /user:$env:USERNAME /pass:$p > $null` and emit the same `✅ Asana ready — ...` line; on failure emit `❌ Asana setup failed: ...` and exit 1 without writing. + b. Tell me to open a real terminal (Terminal.app / iTerm / WSL / PowerShell — NOT Claude Code's `!` prefix, which has no TTY) and run `bash ~/.claude/agnes/bin/store-asana.sh` (or `pwsh ~/.claude/agnes/bin/store-asana.ps1` on Windows). The script will wait silently at the hidden prompt. + c. Walk me through clipboard order: copy the launcher first, paste it in my terminal, press Enter (terminal now waiting). Switch to the Asana tab, copy the token from step 2 — use the panel's Copy button, NOT click-and-drag (which can truncate). Switch back to terminal, paste at the silent prompt, press Enter. Token enters via stdin only — not shown on screen, not in shell history, not in clipboard at the moment Claude is involved. +4. After I report the script printed `✅ Asana ready — ...`, surface that exact line back to me in the chat so the final summary can grep for it. If the script printed `❌ Asana setup failed: ...` instead, surface that line and stop — do not silently re-run or move on. +5. End-to-end test through your Bash tool — proves the integration works from inside Claude Code, not just from the user's real terminal where the store script ran. The store script's verify ran in MY shell; you (Claude Code) have not yet exercised the credential from your own sandbox. macOS: `t=$(security find-generic-password -s 'agnes-asana-pat' -w 2>/dev/null) && curl -fsS -H "Authorization: Bearer $t" https://app.asana.com/api/1.0/users/me | jq -r '.data | "✅ Asana integration verified — Claude Code can read as \\(.name). \\(.workspaces | length) workspace(s) visible."'`. Linux: `t=$(secret-tool lookup service agnes-asana-pat username "$USER" 2>/dev/null) && curl -fsS -H "Authorization: Bearer $t" https://app.asana.com/api/1.0/users/me | jq -r '.data | "✅ Asana integration verified — Claude Code can read as \\(.name). \\(.workspaces | length) workspace(s) visible."'`. Windows native: skip this active test — `cmdkey` does not expose the secret to a non-interactive subshell; instead tell me to run the verify from a real PowerShell terminal: `$c = Get-StoredCredential -Target 'agnes-asana-pat'; Invoke-RestMethod -Uri 'https://app.asana.com/api/1.0/users/me' -Headers @{Authorization = "Bearer $($c.GetNetworkCredential().Password)"} | % { $_.data.name }` (requires the CredentialManager PowerShell module — install with `Install-Module CredentialManager` if missing). On 200, print the `✅ Asana integration verified — ...` line verbatim; this is the line the final summary picks up. On any other status, print `❌ Asana integration test failed: HTTP . Token stored but unreadable from Claude Code's bash — likely a keychain-access policy / TCC denial; check macOS Keychain Access app for a denied prompt, or re-grant Claude Code access to the keychain.` and stop. +6. Remind me where the token is stored and how to revoke: in macOS Keychain Access search "agnes-asana-pat" or run `security delete-generic-password -s 'agnes-asana-pat'`; on Asana, revoke from the same developer-console page.""" _GWS_PROMPT_HEAD = """Set up Google Workspace access for Claude Code using the official `gws` CLI from https://github.com/googleworkspace/cli (install steps: README → Installation). The npm path is what we'll use because (a) it's the README's documented convenience path, (b) it works the same on macOS / Linux / WSL / Windows, and (c) it can run with zero admin rights when Node is managed by `nvm` (Unix) or `fnm` (Windows). @@ -230,7 +268,7 @@ Ground rules: this is idempotent — safe to re-run, the precheck below short-ci YOU run every command via your Bash tool. Do NOT print install commands and ask me to type them. Only stop and ask me when I have to (a) approve an OAuth consent screen in a browser, (b) make a product decision (Cloud project name), or (c) paste OAuth client credentials Google shows me. -0. Precheck — skip the rest if Google Workspace is already connected. Run `command -v gws` AND `gws auth status` AND a low-impact verify call: `gws drive files list --params '{"pageSize": 1}' && gws chat spaces list --params '{"pageSize": 1}'`. If both succeed, the gws CLI is installed AND authed AND the Chat scope is present. Print "Already connected as — Drive + Chat scopes verified. Skipping setup." and STOP. If `gws drive` succeeds but `gws chat` fails with 403/PERMISSION_DENIED, the user authed without `--full` previously — skip to step 6 (re-login with widened scopes), do NOT re-install. Only walk steps 1–5 (install + OAuth client setup) when `command -v gws` itself fails. +0. Precheck — skip the rest if Google Workspace is already connected. Run `command -v gws` AND `gws auth status` AND a low-impact verify call: `gws drive files list --params '{"pageSize": 1}' && gws chat spaces list --params '{"pageSize": 1}'`. If both succeed, the gws CLI is installed AND authed AND the Chat scope is present. Print "✅ Google Workspace ready — connected as . Drive + Chat scopes verified." and STOP. If `gws drive` succeeds but `gws chat` fails with 403/PERMISSION_DENIED, the user authed without `--full` previously — skip to step 6 (re-login with widened scopes), do NOT re-install. Only walk steps 1–5 (install + OAuth client setup) when `command -v gws` itself fails. 1. Detect my OS (`uname -s` → Darwin / Linux, or PowerShell `$env:OS` → Windows_NT). On Linux check `grep -qi microsoft /proc/version` and treat WSL as Linux. @@ -305,7 +343,7 @@ _GWS_PROMPT_TAIL_TEMPLATE = """ 7. Find where gws stored my credentials (`gws auth status` should show the path; typically ~/.config/gws/ on Unix, %APPDATA%\\gws\\ on Windows). chmod 600 on Unix; on native Windows, restrict ACLs to my user with `icacls "$creds_path" /inheritance:r /grant:r "$env:USERNAME:F"` — file is already in my user profile so this needs no admin. -8. Verify with two low-impact reads, one per scope group: `gws drive files list --params '{{"pageSize": 1}}'` (Drive scope landed) and `gws chat spaces list --params '{{"pageSize": 1}}'` (Chat scope landed). Print only "Connected as " plus the file + space counts. Never echo tokens, file/message metadata, or scope strings to chat. +8. Verify with two low-impact reads, one per scope group: `gws drive files list --params '{{"pageSize": 1}}'` (Drive scope landed) and `gws chat spaces list --params '{{"pageSize": 1}}'` (Chat scope landed). If both return 200 with valid JSON, print `✅ Google Workspace ready — connected as . drive file(s), chat space(s) visible.` (exact prefix — the final summary grep for it). On any failure, print `❌ Google Workspace setup failed: , HTTP/status . .` and stop. Never echo tokens, file/message metadata, or scope strings to chat. 9. Remind me how to revoke later: `gws auth logout` clears local creds; the OAuth grant also appears at https://myaccount.google.com/permissions for Google-side revocation.""" @@ -314,10 +352,10 @@ _ATLASSIAN_PROMPT = """Set up Atlassian (Jira + Confluence) API access for Claud Ground rules: this is idempotent — safe to re-run, the precheck below short-circuits when Atlassian is already wired up. If any 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 hide the real problem. -0. Precheck — skip the rest if Atlassian is already connected. The setup script stores email + the *normalized* site root URL (no trailing slash, no `/wiki` suffix) in `~/.claude/agnes/secrets.env` and the API token in the OS keychain under `agnes-atlassian-api-token`. Verify all three exist + auth works against the LIVE Atlassian API before reinstalling, and probe BOTH Jira and Confluence — sites can have either product enabled, so Jira's `/rest/api/3/myself` returns 404 on Confluence-only sites and vice-versa. macOS: `[ -r ~/.claude/agnes/secrets.env ] && . ~/.claude/agnes/secrets.env && t=$(security find-generic-password -s 'agnes-atlassian-api-token' -a "$ATLASSIAN_EMAIL" -w 2>/dev/null) && B="${ATLASSIAN_BASE_URL%/}" && B="${B%/wiki}" && tmp=$(mktemp) && code=$(curl -sS -o "$tmp" -w '%{http_code}' -u "$ATLASSIAN_EMAIL:$t" "$B/rest/api/3/myself") && { [ "$code" = "404" ] && code=$(curl -sS -o "$tmp" -w '%{http_code}' -u "$ATLASSIAN_EMAIL:$t" "$B/wiki/rest/api/user/current"); :; } && [ "$code" = "200" ] && jq -r '"Already connected as \\(.displayName) (\\(.emailAddress // "no email scope")) on '"$B"'. Skipping setup."' < "$tmp" && rm -f "$tmp" && exit 0`. Linux: same shape but `t=$(secret-tool lookup service agnes-atlassian-api-token username "$ATLASSIAN_EMAIL")`. Windows: read `secrets.env`, then `cmdkey /list:agnes-atlassian-api-token` — if entry exists, print "Atlassian cred entry found — verify in your real terminal before re-running setup." and let me confirm rather than auto-skipping. If the verify call (either probe) returns 200, STOP with the "Already connected" line. Continue to step 1 only when secrets.env is missing OR keychain lookup fails OR BOTH probes return non-200. Treat 401 from either probe as "real auth failure — token is bad" and skip the second probe. +0. Precheck — skip the rest if Atlassian is already connected. The setup script stores email + the *normalized* site root URL (no trailing slash, no `/wiki` suffix) in `~/.claude/agnes/secrets.env` and the API token in the OS keychain under `agnes-atlassian-api-token`. Verify all three exist + auth works against the LIVE Atlassian API before reinstalling, and probe BOTH Jira and Confluence — sites can have either product enabled, so Jira's `/rest/api/3/myself` returns 404 on Confluence-only sites and vice-versa. macOS: `[ -r ~/.claude/agnes/secrets.env ] && . ~/.claude/agnes/secrets.env && t=$(security find-generic-password -s 'agnes-atlassian-api-token' -a "$ATLASSIAN_EMAIL" -w 2>/dev/null) && B="${ATLASSIAN_BASE_URL%/}" && B="${B%/wiki}" && tmp=$(mktemp) && code=$(curl -sS -o "$tmp" -w '%{http_code}' -u "$ATLASSIAN_EMAIL:$t" "$B/rest/api/3/myself") && { [ "$code" = "404" ] && code=$(curl -sS -o "$tmp" -w '%{http_code}' -u "$ATLASSIAN_EMAIL:$t" "$B/wiki/rest/api/user/current"); :; } && [ "$code" = "200" ] && jq -r '"✅ Atlassian ready — connected as \\(.displayName) on '"$B"'."' < "$tmp" && rm -f "$tmp" && exit 0`. Linux: same shape but `t=$(secret-tool lookup service agnes-atlassian-api-token username "$ATLASSIAN_EMAIL")`. Windows: read `secrets.env`, then `cmdkey /list:agnes-atlassian-api-token` — if entry exists, print "Atlassian cred entry found — verify in your real terminal before re-running setup." and let me confirm rather than auto-skipping. If the verify call (either probe) returns 200, STOP with the "✅ Atlassian ready — ..." line. Continue to step 1 only when secrets.env is missing OR keychain lookup fails OR BOTH probes return non-200. Treat 401 from either probe as "real auth failure — token is bad" and skip the second probe. 1. Ask me for my Atlassian Cloud site URL (looks like https://.atlassian.net) and the email I sign in with. Site URL and email are NOT secrets — fine to type into chat. Don't proceed until I've given you both. 2. Open the Atlassian API tokens page in my default browser — use your Bash tool: `open https://id.atlassian.com/manage-profile/security/api-tokens` on macOS, `xdg-open ...` on Linux/WSL, or `Start-Process ...` on Windows. Detect OS first. If I land on a generic profile page, tell me: avatar (top right) → Manage account → Security → "Create and manage API tokens". -3. Tell me to click "Create API token" (NOT "Create API token with scopes" unless I specifically need fine-grained — one-line trade-off: scoped tokens are limited per project but expire and need rotation; unscoped is simplest for personal use). Label it "Claude Code — Agnes", click Create, copy the token. Warn me it is shown ONCE. +3. Tell me to click "Create API token" (NOT "Create API token with scopes" unless I specifically need fine-grained — one-line trade-off: scoped tokens are limited per project but expire and need rotation; unscoped is simplest for personal use). Label it "Claude Code — {instance_brand}". **In the "Expires" / validity dropdown, pick the longest option Atlassian offers (today that's "1 year") — Atlassian's default sets short-lived expiry and the re-mint friction is the #1 reason this connector goes stale.** There is NO query-parameter hook on `id.atlassian.com/manage-profile/security/api-tokens` to pre-select the expiry, so the user has to click it; just tell them which option to pick. Click Create, copy the token. Warn me it is shown ONCE. 4. Important: do NOT ask me to paste the token into the chat. Prepare a helper script for me to run in my real terminal, with my email and site URL baked in as literals (so they're not re-prompted at runtime): a. Use the Write tool to create ~/.claude/agnes/bin/store-atlassian.sh on macOS/Linux (or .ps1 on Windows). chmod 700. The script must (i) reject obviously-truncated tokens via a length floor, (ii) NORMALIZE the base URL so the verify call hits a real endpoint, and (iii) verify the credentials against the live Atlassian API — trying Jira first, then Confluence on 404 — BEFORE writing anything to the keychain. The length guard exists because Atlassian's "shown ONCE" copy panel commonly truncates if the user click-copies instead of using the panel's Copy button — silently storing a 43-char fragment then discovering it later is the failure mode we're avoiding. The URL-normalization + product-fallback exists because `/rest/api/3/myself` only lives under Jira and returns 404 on Confluence-only sites (and vice-versa for `/wiki/rest/api/user/current`); previously a perfectly valid token paired with a Confluence-only URL or a URL the user pasted with a `/wiki` or trailing slash would 404 here and the prompt would falsely report the token as broken. Body for macOS: #!/usr/bin/env bash @@ -330,7 +368,7 @@ Ground rules: this is idempotent — safe to re-run, the precheck below short-ci # means a truncated copy. Bail before touching the keychain. tlen=$(printf %s "$t" | wc -c | tr -d ' ') if [ "$tlen" -lt 100 ]; then - echo "Token looks too short ($tlen chars) — copy the full value via the Copy button on the Atlassian token page. Aborting." >&2 + echo "❌ Atlassian setup failed: token looks too short ($tlen chars). Use the panel's Copy button on the Atlassian token page (NOT click-and-drag, which can truncate) and re-run." >&2 unset t exit 1 fi @@ -355,11 +393,11 @@ Ground rules: this is idempotent — safe to re-run, the precheck below short-ci fi if [ "$status" != "200" ]; then if [ "$status" = "401" ]; then - echo "API verification failed (HTTP 401 — token rejected by Atlassian). The token is either wrong, revoked, or paired with the wrong email. Aborting without storing." >&2 + echo "❌ Atlassian setup failed: token rejected (HTTP 401). The token is wrong, revoked, or paired with the wrong email — re-mint at https://id.atlassian.com/manage-profile/security/api-tokens and retry. Aborting without storing." >&2 elif [ "$status" = "404" ]; then - echo "API verification failed (HTTP 404 on both Jira and Confluence probes). The site URL '$BASE_URL' is reachable but exposes neither product to this token — double-check the URL (it should be your Atlassian Cloud site root, e.g. https://yourorg.atlassian.net) or that your account has access to Jira or Confluence on this site. Aborting without storing." >&2 + echo "❌ Atlassian setup failed: HTTP 404 on both Jira and Confluence probes. The site URL '$BASE_URL' is reachable but exposes neither product to this token — double-check the URL (Atlassian Cloud site root, e.g. https://yourorg.atlassian.net) or that your account has access to Jira or Confluence on this site. Aborting without storing." >&2 else - echo "API verification failed (HTTP $status). Aborting without storing." >&2 + echo "❌ Atlassian setup failed: API verification returned HTTP $status. Aborting without storing." >&2 fi cat "$tmp" >&2 2>/dev/null || true rm -f "$tmp"; unset t @@ -374,11 +412,11 @@ Ground rules: this is idempotent — safe to re-run, the precheck below short-ci printf 'ATLASSIAN_EMAIL=%s\\nATLASSIAN_BASE_URL=%s\\n' "$EMAIL" "$BASE_URL" > ~/.claude/agnes/secrets.env chmod 600 ~/.claude/agnes/secrets.env unset t - echo "Stored ($product). Verified as $display." + echo "✅ Atlassian ready — connected as $display on $BASE_URL ($product)." Linux variant: replace `security add-generic-password ...` with `printf %s "$t" | secret-tool store --label='Agnes Atlassian token' service agnes-atlassian-api-token username "$EMAIL"`. All three guards (length floor, URL normalization, Jira-then-Confluence verification) stay identical — they run before the storage call. Windows .ps1: same control flow — `Read-Host -AsSecureString`, convert via `Marshal::PtrToStringAuto`, check `$t.Length -lt 100`, then `$BASE_URL = $BASE_URL.TrimEnd('/').TrimEnd('/wiki')` (or `if ($BASE_URL.EndsWith('/wiki')) { $BASE_URL = $BASE_URL.Substring(0, $BASE_URL.Length - 5) }`), `try { Invoke-RestMethod -Uri "$BASE_URL/rest/api/3/myself" -Authentication Basic -Credential (New-Object PSCredential($EMAIL, $secureToken)) } catch { if ($_.Exception.Response.StatusCode.value__ -eq 404) { Invoke-RestMethod -Uri "$BASE_URL/wiki/rest/api/user/current" -Authentication Basic -Credential ... } else { throw } }` — write to `cmdkey` + `secrets.env` only after a 200 lands from either probe. b. Tell me to open a real terminal (not Claude Code's `!`) and run `bash ~/.claude/agnes/bin/store-atlassian.sh` (or `pwsh ~/.claude/agnes/bin/store-atlassian.ps1` on Windows). The script will wait silently at the hidden prompt. - c. Walk me through clipboard order: copy the launcher first, paste in terminal, Enter (terminal waiting). Switch to the Atlassian tab, copy the token from step 3 — use the panel's "Copy" button, NOT click-and-drag (which often truncates). Switch back to terminal, paste at the silent prompt, Enter. The script will print "Stored. Verified as ." on success, or fail loudly with the exact reason (too short / HTTP 401 / etc.) without writing anything. + c. Walk me through clipboard order: copy the launcher first, paste in terminal, Enter (terminal waiting). Switch to the Atlassian tab, copy the token from step 3 — use the panel's "Copy" button, NOT click-and-drag (which often truncates). Switch back to terminal, paste at the silent prompt, Enter. The script will print `✅ Atlassian ready — connected as on ().` on success, or `❌ Atlassian setup failed: ` and exit non-zero without writing anything. Surface that exact line back to me in the chat so the final summary can grep for it. 5. Register the hosted Atlassian Remote MCP so Claude Code can read Jira tickets and Confluence pages on demand: `claude mcp add --transport sse atlassian https://mcp.atlassian.com/v1/sse || true`. Idempotent — the `|| true` swallows the "server already exists" error from re-runs. OAuth is handled by Claude Code the first time it actually queries the MCP (it'll open a browser tab; approve once). The PAT stored in step 4 stays for direct `curl` calls (e.g. the precheck) — the MCP path uses its own OAuth grant, not the PAT. -6. The store script already verified the token end-to-end. If I want a second redacted readback later, hit `GET $BASE_URL/rest/api/3/myself` (Jira) or `GET $BASE_URL/wiki/rest/api/user/current` (Confluence) — try Jira first, fall back to Confluence on 404, same shape as the store script's verify. Print just displayName + accountId — never the token. +6. End-to-end test through your Bash tool — proves the integration works from inside Claude Code, not just from the user's real terminal where the store script ran. The store script's verify ran in MY shell; you (Claude Code) have not yet exercised the credential from your own sandbox. macOS: `. ~/.claude/agnes/secrets.env && t=$(security find-generic-password -s 'agnes-atlassian-api-token' -a "$ATLASSIAN_EMAIL" -w 2>/dev/null) && B="${ATLASSIAN_BASE_URL%/}" && B="${B%/wiki}" && tmp=$(mktemp) && code=$(curl -sS -o "$tmp" -w '%{http_code}' -u "$ATLASSIAN_EMAIL:$t" "$B/rest/api/3/myself") && { [ "$code" = "404" ] && code=$(curl -sS -o "$tmp" -w '%{http_code}' -u "$ATLASSIAN_EMAIL:$t" "$B/wiki/rest/api/user/current"); :; } && [ "$code" = "200" ] && jq -r '"✅ Atlassian integration verified — Claude Code can read as \\(.displayName) on '"$B"'."' < "$tmp" && rm -f "$tmp"`. Linux: same shape but `t=$(secret-tool lookup service agnes-atlassian-api-token username "$ATLASSIAN_EMAIL")`. Windows native: skip this active test — instead tell me to run an equivalent `Invoke-RestMethod` against `$BASE_URL/rest/api/3/myself` (Jira) or `$BASE_URL/wiki/rest/api/user/current` (Confluence on 404) from a real PowerShell terminal where the CredentialManager module can read the stored token back. On 200, print the `✅ Atlassian integration verified — ...` line verbatim; that is the marker the final summary picks up. On any other status, print `❌ Atlassian integration test failed: HTTP . Token stored but unreadable from Claude Code's bash (likely keychain-access policy / TCC denial), or the API endpoint shifted — check macOS Keychain Access for a denied prompt, or that $BASE_URL still resolves to Jira/Confluence.` and stop. 7. Remind me how to revoke: same API tokens page on Atlassian, plus `security delete-generic-password -s 'agnes-atlassian-api-token'` locally (macOS) / `secret-tool clear service agnes-atlassian-api-token` (Linux) / `cmdkey /delete:agnes-atlassian-api-token` (Windows).""" diff --git a/app/web/router.py b/app/web/router.py index 386656f..10c07e0 100644 --- a/app/web/router.py +++ b/app/web/router.py @@ -24,6 +24,7 @@ from app.instance_config import ( get_theme, get_corporate_memory_config, get_home_route, get_gws_oauth_credentials, get_home_automode_visibility, get_instance_admin_email, get_atlassian_base_url, + get_instance_brand, get_workspace_dir_name, ) from app.web.connector_prompts import all_connector_prompts from src.repositories.sync_state import SyncStateRepository @@ -413,6 +414,7 @@ def _build_context( gws_oauth=get_gws_oauth_credentials(), instance_admin_email=get_instance_admin_email(), atlassian_base_url=get_atlassian_base_url(), + instance_brand=get_instance_brand(), ) setup_instructions_lines = resolve_lines( @@ -421,6 +423,8 @@ def _build_context( server_host=server_host, ca_pem=ca_pem, connector_prompts=_connector_prompts, + instance_brand=get_instance_brand(), + workspace_dir=get_workspace_dir_name(), ) ctx = { @@ -448,6 +452,16 @@ def _build_context( # tile's "Email admin" mailto button. Empty string hides the # button — template guards with `{% if instance_admin_email %}`. "instance_admin_email": get_instance_admin_email(), + # Branding: `instance_name` is the deploying org's display name + # (page titles); `instance_brand` is the product name used in body + # copy and CTAs ("Setup {brand}", "{brand} runs SELECT…"); `workspace_dir` + # is the filesystem-safe folder name shown in `~/` and + # baked into the clipboard setup script. All three default to the + # Agnes-flavored values out of the box; Terraform can flip them via + # env vars (AGNES_INSTANCE_BRAND / AGNES_WORKSPACE_DIR_NAME). + "instance_name": get_instance_name(), + "instance_brand": get_instance_brand(), + "workspace_dir": get_workspace_dir_name(), # Resolved connector setup prompts — single source of truth for # both the /home "Copy prompt" tiles and the main setup script # (app/web/setup_instructions.py inlines them in step 9). The @@ -458,6 +472,7 @@ def _build_context( gws_oauth=get_gws_oauth_credentials(), instance_admin_email=get_instance_admin_email(), atlassian_base_url=get_atlassian_base_url(), + instance_brand=get_instance_brand(), ), # Whether /home renders the "Step 3 — turn on auto-accept mode" # install-block. Operator can hide it via AGNES_HOME_SHOW_AUTOMODE=0 diff --git a/app/web/setup_instructions.py b/app/web/setup_instructions.py index 1c8ed62..6862bbf 100644 --- a/app/web/setup_instructions.py +++ b/app/web/setup_instructions.py @@ -324,7 +324,18 @@ def _install_cli_lines(*, has_ca: bool, server_url_placeholder: str = "{server_u def _init_lines(server_url_placeholder: str = "{server_url}") -> list[str]: - """Steps 2-3 — `agnes init` (auth + workspace bootstrap) + smoke verify. + """Steps 2-4 — workspace folder bootstrap, then `agnes init` + smoke verify. + + Step 2 (new) explicitly creates the workspace folder and cd's into it. + Previously the script assumed Claude Code was already cd'd to a sensible + location and ran `agnes init --workspace .` against whatever that + happened to be. With the new explicit mkdir+cd the workspace path is + deterministic — `~/{workspace_dir}` — and the visible /home step block + + this scripted step stay in lockstep. + + `{workspace_dir}` and `{instance_brand}` are placeholders pre-substituted + by :func:`resolve_lines` from the operator-configured brand. Defaults + keep `~/Agnes` behavior for instances that don't set the brand knob. `agnes init` is the workspace-rails delivery mechanism for everyone: it authenticates with the PAT, fetches CLAUDE.md (RBAC-filtered), @@ -341,7 +352,16 @@ def _init_lines(server_url_placeholder: str = "{server_url}") -> list[str]: """ return [ "", - "2) Bootstrap your Agnes workspace in this directory:", + "2) Create the workspace folder in the user's home directory and cd into it:", + " # POSIX (macOS / Linux / WSL):", + " mkdir -p \"$HOME/{workspace_dir}\" && cd \"$HOME/{workspace_dir}\"", + " # Windows (PowerShell) — only run if the user is on Windows:", + " # New-Item -ItemType Directory -Force -Path \"$HOME\\{workspace_dir}\" | Out-Null", + " # Set-Location \"$HOME\\{workspace_dir}\"", + "", + " The remaining steps run inside this directory.", + "", + "3) Bootstrap your {instance_brand} 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),", @@ -349,7 +369,7 @@ def _init_lines(server_url_placeholder: str = "{server_url}") -> list[str]: " SessionStart/End hooks (auto-refresh), and runs an initial `agnes pull`", " so your DuckDB views are ready.", "", - "3) Verify the data is queryable:", + "4) Verify the data is queryable:", " agnes catalog", "", " This should list the tables your account has grants for. Empty list", @@ -457,6 +477,24 @@ def _connectors_block( return lines +def _restart_claude_lines(step_num: str) -> list[str]: + """Final 'restart Claude Code' instruction emitted immediately before + Confirm. Marketplace plugins, MCP server registrations, and the + SessionStart hooks installed during init only load on the next + Claude Code session — without this step the user sits inside the + setup session with stale state and re-discovers the requirement + later. The marketplace step's trailer already mentions /exit + + claude conditionally; this is the unconditional equivalent so + every path (with or without plugins) ends on the same cue. + """ + return [ + "", + f"{step_num}) Restart Claude Code so every plugin, MCP server, and SessionStart hook installed above actually loads:", + " Tell me to type `/exit` (or close the Claude Code session entirely), then run `claude` again from this same `~/{workspace_dir}` directory.", + " The next session boots with all marketplace plugins, every connector's keychain entries / OAuth grants, and the agnes-welcome + refresh-marketplace SessionStart hooks active. This is the last action before the Confirm summary — once I'm back in Claude Code, setup is complete.", + ] + + 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 @@ -466,7 +504,10 @@ def _finale_lines(*, confirm_step_num: str, has_ca: bool) -> list[str]: 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.""" + unconditional. The per-connector ✅/❌ bullet exploits the uniform + output contract every connector prompt emits on its verify step — + the assistant scans its own prior output for those markers instead + of re-running probes.""" bullets = [ " - `agnes --version` output", " - First few lines of `agnes catalog` (tables you can see)", @@ -476,9 +517,11 @@ def _finale_lines(*, confirm_step_num: str, has_ca: bool) -> list[str]: " - 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)", + " - For each connector (Asana, Google Workspace, Atlassian): " + "the verbatim ✅ or ❌ line that the connector's verify step " + "emitted earlier in this session (e.g. `✅ Asana ready — connected " + "as Vojtech Rysanek. 2 workspace(s) visible.` or `❌ Atlassian setup " + "failed: ...`). If the user declined a connector, say declined.", ] if has_ca: bullets.append( @@ -487,7 +530,7 @@ def _finale_lines(*, confirm_step_num: str, has_ca: bool) -> list[str]: ) return [ f"{confirm_step_num}) Confirm:", - " Tell me \"Agnes workspace is ready\" and summarize:", + " Tell me \"{instance_brand} workspace is ready\" and summarize:", *bullets, ] @@ -660,7 +703,7 @@ def _preamble_lines(*, has_ca: bool) -> list[str]: 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.", + "Set up the {instance_brand} CLI on this machine.", "", "Server: {server_url}", "Personal access token: {token}", @@ -689,9 +732,10 @@ def _step_numbers(*, has_connectors: bool = True) -> dict[str, str]: 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: + Steps (always emitted): install (1), mkdir/cd (2), init (3), + catalog (4), preflight (5), marketplace (6), diagnose (7), + connectors (8), restart_claude (9), confirm (10). Preflight + + marketplace + connectors + restart_claude are always-on: - Marketplace registration is useful even when the operator has zero plugin grants (SessionStart hook reconciles future grants automatically). @@ -720,19 +764,21 @@ def _step_numbers(*, has_connectors: bool = True) -> dict[str, str]: has_ca and has its own "0)" header rendered inside the trust block helper. """ - n = 4 + n = 5 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 + restart_claude = str(n); n += 1 confirm = str(n) return { "preflight": preflight, "marketplace": marketplace, "diagnose": diagnose, "connectors": connectors, + "restart_claude": restart_claude, "confirm": confirm, } @@ -744,6 +790,8 @@ def resolve_lines( server_host: str = "", ca_pem: str | None = None, connector_prompts: dict[str, str] | None = None, + instance_brand: str = "Agnes", + workspace_dir: str = "Agnes", ) -> list[str]: """Return the template lines with server-side placeholders substituted. @@ -802,12 +850,18 @@ def resolve_lines( # 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. + # Connectors are the LAST interactive ask before the restart-claude + # cue. Per-connector default-yes — empty/Enter is install, explicit + # "no" skips. lines.extend(_connectors_block( steps["connectors"], connector_prompts, confirm_step_num=steps["confirm"], )) + # Restart-claude lands between connectors and confirm so the user + # picks up freshly-registered plugins / MCP servers / hooks on the + # next session — without this every path silently expected the user + # to know they had to re-launch. + lines.extend(_restart_claude_lines(steps["restart_claude"])) lines.append("") lines.extend(_finale_lines( confirm_step_num=steps["confirm"], @@ -815,7 +869,11 @@ def resolve_lines( )) return [ - line.replace("{wheel_filename}", wheel_filename).replace("{server_host}", server_host) + line + .replace("{wheel_filename}", wheel_filename) + .replace("{server_host}", server_host) + .replace("{workspace_dir}", workspace_dir) + .replace("{instance_brand}", instance_brand) for line in lines ] @@ -829,13 +887,15 @@ def render_setup_instructions( server_host: str = "", ca_pem: str | None = None, connector_prompts: dict[str, str] | None = None, + instance_brand: str = "Agnes", + workspace_dir: str = "Agnes", ) -> 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. + wheel, plugins, host, ca_pem, connector_prompts, brand, workspace_dir) tuple. """ lines = resolve_lines( wheel_filename, @@ -843,6 +903,8 @@ def render_setup_instructions( server_host=server_host, ca_pem=ca_pem, connector_prompts=connector_prompts, + instance_brand=instance_brand, + workspace_dir=workspace_dir, ) text = "\n".join(lines) return text.replace("{server_url}", server_url).replace("{token}", token) diff --git a/app/web/templates/home_not_onboarded.html b/app/web/templates/home_not_onboarded.html index 13099b5..623e0f0 100644 --- a/app/web/templates/home_not_onboarded.html +++ b/app/web/templates/home_not_onboarded.html @@ -1159,20 +1159,20 @@
Welcome back, {{ display_name }} — your workspace is ready

You're set up — keep this page handy

- Your local Agnes install is confirmed. The steps below stay useful for adding another machine, connecting more services, or turning on auto-accept mode. Skip whatever you don't need; nothing here re-runs unless you click it. + Your local {{ instance_brand }} install is confirmed. The steps below stay useful for adding another machine, connecting more services, or turning on auto-accept mode. Skip whatever you don't need; nothing here re-runs unless you click it.

{% else %}
Welcome, {{ display_name }} — let's get you set up

Connect Claude Code on your machine to your team's data

- {{ instance_name or "Agnes" }} gives Claude Code on your computer access to your team's curated data, plugins, and shared knowledge — so you can ask questions and get answers in plain language, right from your terminal. This page walks you through the one-time setup (~10 minutes). Everything it installs lives in your home folder (~/Agnes) and can be removed in one command. + {{ instance_brand }} gives Claude Code on your computer access to your team's curated data, plugins, and shared knowledge — so you can ask questions and get answers in plain language, right from your terminal. This page walks you through the one-time setup (~10 minutes). Everything it installs lives in your home folder (~/{{ workspace_dir }}) and can be removed in one command.

{% endif %} {% if onboarded %}
- Step 1, 2 & 3 done — Claude Code installed, auto-mode set, Agnes ready in ~/Agnes. The full install steps stay one click away under the offboard control below. + Steps 1–4 done — Claude Code installed, auto-mode set, workspace folder created, {{ instance_brand }} ready in ~/{{ workspace_dir }}. The full install steps stay one click away under the offboard control below.
{% endif %} @@ -1218,7 +1218,7 @@ {% if home_automode.show %}
-
Step 2 — turn on auto-mode (recommended before Step 3)
+
Step 2 — turn on auto-mode (recommended before Step 4)
In the Claude Code session you just signed into, press Shift + Tab. Claude cycles modes: default → auto-accept edits → plan mode → default; the footer shows ⏵⏵ when auto-accept is on. On the first cycle to auto-accept, Claude asks whether to make it the default — say yes. Closed the session already? Run claude again, then press Shift + Tab.

@@ -1228,9 +1228,31 @@ {% endif %}
-
Step 3 — install Agnes from inside Claude Code
+
Step 3 — create your workspace folder
+
+ + +
+
+ mkdir -p ~/{{ workspace_dir }} && cd ~/{{ workspace_dir }} + +
+ +
+ This is where {{ instance_brand }} will live. Run the command in the terminal you opened for Step 1, then keep that terminal handy — the next step pastes the setup script into Claude Code from this same directory. +
+
+ +
+
Step 4 — install {{ instance_brand }} from inside Claude Code

- Click the button — {{ instance_name or "Agnes" }} creates a 90-day login token, copies a ready-to-paste setup script to your clipboard, and a follow-up popup tells you exactly where to paste it. The script bootstraps everything in ~/Agnes once Claude Code receives it. + Click the button — {{ instance_brand }} creates a 90-day login token, copies a ready-to-paste setup script to your clipboard, and a follow-up popup tells you exactly where to paste it. The script bootstraps everything in ~/{{ workspace_dir }} once Claude Code receives it.

- The preview above is the exact text the button copies; the placeholder is replaced with a real token at click time. Don't create ~/Agnes/Projects/ manually — the bundled plugin offers to set it up after install. + The preview above is the exact text the button copies; the placeholder is replaced with a real token at click time. Don't create ~/{{ workspace_dir }}/Projects/ manually — the bundled plugin offers to set it up after install.
@@ -1318,7 +1340,7 @@ Connect your tools (Asana / Google Workspace / Atlassian) - +
@@ -1365,13 +1387,13 @@
Drive, Calendar, Gmail, Docs, Sheets, Chat — Claude reads and acts across your work account via the official gws CLI.
{% if not gws_oauth.configured %}
- Heads up: your Agnes admin hasn't provisioned a shared Google Cloud OAuth app yet, so this connector needs GCP project setup (creating an OAuth client, enabling APIs). It's fastest to ask your admin first — the button below pre-fills the email. + Heads up: your {{ instance_brand }} admin hasn't provisioned a shared Google Cloud OAuth app yet, so this connector needs GCP project setup (creating an OAuth client, enabling APIs). It's fastest to ask your admin first — the button below pre-fills the email.
{% endif %}
{% if not gws_oauth.configured and instance_admin_email %} - {% endif %} @@ -1407,7 +1429,7 @@ -

You don't need Agnes installed locally to browse what's available. Anything you bookmark or subscribe to will be there waiting after you set Agnes up.

+

You don't need {{ instance_brand }} installed locally to browse what's available. Anything you bookmark or subscribe to will be there waiting after you set {{ instance_brand }} up.

diff --git a/app/web/templates/home_onboarded.html b/app/web/templates/home_onboarded.html index 80c3af1..7ef5dce 100644 --- a/app/web/templates/home_onboarded.html +++ b/app/web/templates/home_onboarded.html @@ -104,8 +104,8 @@
Welcome back, {{ display_name }}

You're all set up

- Open Claude Code in any project under ~/Agnes/Projects/ - and start a session — your data and plugins are already synced. Use the cards below to jump into the parts of {{ instance_name or "Agnes" }} you need. + Open Claude Code in any project under ~/{{ workspace_dir }}/Projects/ + and start a session — your data and plugins are already synced. Use the cards below to jump into the parts of {{ instance_brand }} you need.

@@ -130,7 +130,7 @@ 📈
Activity Center
-
Per-user analytics on Agnes adoption.
+
Per-user analytics on {{ instance_brand }} adoption.
🧠 diff --git a/app/web/templates/install.html b/app/web/templates/install.html index 6a41649..a850746 100644 --- a/app/web/templates/install.html +++ b/app/web/templates/install.html @@ -669,7 +669,7 @@
Getting started
-

Install the Agnes CLI on this machine

+

Install the {{ instance_brand or "Agnes" }} CLI on this machine

Connect your terminal and Claude Code to this server. Copy the one-liner below — it downloads and installs the CLI wheel, diff --git a/app/web/templates/login.html b/app/web/templates/login.html index 8fd3d35..67bb48c 100644 --- a/app/web/templates/login.html +++ b/app/web/templates/login.html @@ -97,12 +97,12 @@ {% set _err = request.query_params.get('error') %} {% set _err_messages = { - 'not_in_allowed_group': "Your Google account isn't a member of any group permitted to use this Agnes instance. Ask your Agnes administrator to grant you access.", + 'not_in_allowed_group': "Your Google account isn't a member of any group permitted to use this " ~ (instance_brand or "Agnes") ~ " instance. Ask your " ~ (instance_brand or "Agnes") ~ " administrator to grant you access.", 'group_check_unavailable': "We couldn't verify your group membership with Google right now. Please try signing in again in a moment.", - 'deactivated': "This account has been deactivated. Contact your Agnes administrator if you believe this is in error.", + 'deactivated': "This account has been deactivated. Contact your " ~ (instance_brand or "Agnes") ~ " administrator if you believe this is in error.", 'oauth_failed': "Google sign-in failed. Please try again.", 'no_email': "Google didn't return an email for this account.", - 'domain_not_allowed': "This email's domain is not permitted to sign in to this Agnes instance.", + 'domain_not_allowed': "This email's domain is not permitted to sign in to this " ~ (instance_brand or "Agnes") ~ " instance.", 'google_not_configured': "Google sign-in is not configured on this server.", } %} {% if _err and _err_messages.get(_err) %} diff --git a/app/web/templates/me_debug.html b/app/web/templates/me_debug.html index ef61d9d..59fe37c 100644 --- a/app/web/templates/me_debug.html +++ b/app/web/templates/me_debug.html @@ -113,7 +113,7 @@ What you see is your own data only. No raw JWT, no password hash, no full PAT. The "Refetch" button below asks Google what your current group membership looks like and shows a - diff against what Agnes has cached — it does not apply + diff against what {{ instance_brand or "Agnes" }} has cached — it does not apply the result. Your real next sync runs at next sign-in.

diff --git a/app/web/templates/setup.html b/app/web/templates/setup.html index eebcaf6..e4b6427 100644 --- a/app/web/templates/setup.html +++ b/app/web/templates/setup.html @@ -1,12 +1,12 @@ {% extends "base_login.html" %} -{% block title %}Setup - Agnes AI Data Analyst{% endblock %} +{% block title %}Setup - {{ instance_brand or "Agnes" }} {{ instance_name or "AI Data Analyst" }}{% endblock %} {% block content %}