"""Connector setup prompts — single source of truth. Two consumers share these strings: 1. ``app/web/templates/home_not_onboarded.html`` — renders each one inside a "Copy prompt" tile so an already-onboarded user can grab a single connector's prompt and paste it into Claude Code. 2. ``app/web/setup_instructions.py`` — inlines all three into the main "Setup a new Claude Code" script as step 9's interactive ask-then- inline-prompt block, so a fresh user gets connectors wired up in the same paste-and-go flow that installs Agnes. Keeping them here (instead of duplicating across template + script) means edits land in one place. The shape of each connector — slug, display name, what the prompt instructs Claude to do — is invariant; the GWS prompt is the only one that branches at render time (operator-provisioned OAuth client vs manual ``gws auth setup``), which is why ``gws_prompt`` takes the credentials dict. The text deliberately reads like a Claude Code prompt rather than a shell script. The whole flow is "paste into Claude Code, let it do the work" — the prompts tell Claude how to ask the user, where to write helper scripts, and how to verify against live APIs before storing anything. """ from __future__ import annotations from dataclasses import dataclass # --------------------------------------------------------------------------- # Public registry — single place to add / remove / reorder a connector. # --------------------------------------------------------------------------- @dataclass(frozen=True) class Connector: """One connector's identity, surfaced both in the /home tile registry and in the setup-script step. Adding a fourth connector means: one entry here, one ``_prompt()`` function below, one branch in :func:`all_connector_prompts`. No template or setup-script changes.""" slug: str display_name: str description: str CONNECTORS: list[Connector] = [ Connector( slug="asana", display_name="Asana", description="Read tasks and projects, comment, create updates — Claude works alongside your project boards without leaving the terminal.", ), Connector( slug="gws", display_name="Google Workspace", description="Drive, Calendar, Gmail, Docs, Sheets, Chat — Claude reads and acts across your work account via the official `gws` CLI.", ), Connector( slug="atlassian", display_name="Atlassian (Jira / Confluence)", description="Read and write Jira issues, search Confluence pages — Claude pulls ticket context and posts updates without leaving the workspace.", ), ] 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 consumers) passes the already-resolved ``gws_oauth`` dict from :func:`app.instance_config.get_gws_oauth_credentials`, the admin email from :func:`get_instance_admin_email`, and the Atlassian site URL from :func:`get_atlassian_base_url`. Returns a dict keyed by connector slug so both the template (``{{ connector_prompts.asana }}``) and the setup script (``connector_prompts['asana']``) read the same shape. ``instance_admin_email`` is currently unused inside the prompt bodies (the Email-admin button on /home is tile chrome, not prompt content) but is plumbed through anyway so a future GWS prompt branch that references the admin contact can add the string without changing the call sites. ``atlassian_base_url`` (when non-empty) bakes the operator-provisioned Atlassian Cloud site root into the Atlassian connector prompt — the user no longer has to guess / paste their org's URL. Empty value falls back to the existing "ask the user" flow. """ gws_oauth = gws_oauth or {} return { "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 ""), gws_client_secret=str(gws_oauth.get("client_secret") or ""), gws_project_id=str(gws_oauth.get("project_id") or ""), oauthlib_insecure_transport=str( gws_oauth.get("oauthlib_insecure_transport") or "1" ), instance_admin_email=instance_admin_email, ), "atlassian": atlassian_prompt( base_url=atlassian_base_url, instance_brand=instance_brand, ), } # --------------------------------------------------------------------------- # Individual prompt builders. # # Each returns the verbatim prompt body that Claude Code follows when the # user pastes it. Strings are plain Python (real `<` / `>` / `&` chars) — # the Jinja template re-escapes for HTML rendering, and the setup script # inlines them straight into bash heredocs / numbered steps. # --------------------------------------------------------------------------- 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. 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 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( *, gws_oauth_configured: bool, gws_client_id: str = "", gws_client_secret: str = "", gws_project_id: str = "", oauthlib_insecure_transport: str = "1", instance_admin_email: str = "", # noqa: ARG001 — plumbed for future use ) -> str: """Google Workspace setup via the official ``gws`` CLI. Step 5 branches on whether the operator has provisioned a shared OAuth app (``gws_oauth_configured=True``, set when both ``AGNES_GWS_CLIENT_ID`` + ``AGNES_GWS_CLIENT_SECRET`` are present). Configured → write ``client_secret.json`` directly, skip the ``gws auth setup`` walkthrough entirely (~2 min, zero clickops). Unconfigured → fall back to the manual GCP project walkthrough (~20 min, user needs GCP-admin help). ``oauthlib_insecure_transport`` only flows into step 6 because the gws CLI's loopback redirect is HTTP (Google's oauthlib refuses that without the env var set).""" if gws_oauth_configured: step5 = _GWS_STEP5_CONFIGURED_TEMPLATE.format( client_id=gws_client_id, project_id=gws_project_id, client_secret=gws_client_secret, ) # When configured, step 6 reuses the operator's `oauthlib_insecure_transport` # setting verbatim — even though "1" is the always-safe default, an operator # MAY have flipped it off via instance.yaml and we honour that. oauth_env = oauthlib_insecure_transport or "1" else: step5 = _GWS_STEP5_MANUAL oauth_env = "1" return _GWS_PROMPT_HEAD + step5 + _GWS_PROMPT_TAIL_TEMPLATE.format( oauth_env=oauth_env, ) 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- fallback verify so Confluence-only sites still onboard. ``base_url`` (when non-empty) is the operator-provisioned Atlassian Cloud site root — typically ``https://.atlassian.net``. When set, step 1's "ask me for the site URL" prompt is replaced by a one-line note that the URL is already baked in, and step 4's ``BASE_URL=''`` literal becomes ``BASE_URL=''`` directly. Empty value (default) keeps the existing flow — Claude asks the user. 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. 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 {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 body # --------------------------------------------------------------------------- # Prompt bodies — kept as module-level constants so they're free of # any per-call allocation cost and trivially diffable. # --------------------------------------------------------------------------- _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 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 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). Ground rules: this is idempotent — safe to re-run, the precheck below short-circuits when `gws` is already installed and authed. If any step fails with an unfamiliar error, paste the exact error back and stop — don't half-finish. Do NOT improvise around TLS errors by disabling verification (`-k`, `NODE_TLS_REJECT_UNAUTHORIZED=0`, npm `strict-ssl=false`, etc.) — those mask the real problem. 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 "✅ 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. 2. Check `command -v gws` (or `Get-Command gws` on Windows). If `gws` is already installed, skip to step 5. 3. Install Node.js 18+ to my user directory — no sudo, no UAC, no system package manager. Unix (macOS / Linux / WSL): a. Check `command -v node && node --version` — if 18+ already, skip. b. Otherwise install nvm into ~/.nvm: `curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash`. The installer writes to ~/.nvm and appends shellenv to ~/.bashrc / ~/.zshrc — no sudo. Source it for the current shell: `export NVM_DIR="$HOME/.nvm" && [ -s "$NVM_DIR/nvm.sh" ] && \\. "$NVM_DIR/nvm.sh"`. c. `nvm install --lts && nvm use --lts`. Verify `node --version` shows v20.x or v22.x. Native Windows (NOT WSL): a. Check `node --version` — if 18+, skip. b. Install fnm to user profile (no admin): run `winget install Schniz.fnm --scope user --accept-source-agreements --accept-package-agreements`. If winget triggers UAC, fall back to the manual zip from https://github.com/Schniz/fnm/releases/latest — extract `fnm.exe` to `$HOME\\.local\\bin\\` and add that dir to my user PATH via `[Environment]::SetEnvironmentVariable('Path', "$env:Path;$HOME\\.local\\bin", 'User')`. c. `fnm install --lts; fnm use lts-latest`. `fnm env --use-on-cd | Out-String | Invoke-Expression` to source it for the current shell. 4. Install `gws` via npm — runs as my user because Node is managed by nvm/fnm, so the global prefix lives inside ~/.nvm/versions/node//lib/ (Unix) or ~/.fnm/.../lib/ (Windows). No sudo, no UAC, no `npm config set prefix` workaround needed. a. `npm install -g @googleworkspace/cli` (run via Bash tool). Wait for it. If npm fails (network, registry, peer-dep), report the exact stderr and pause — don't half-finish. b. nvm/fnm Node + npm-installed binaries land under ~/.nvm/versions/node//bin/ — only on PATH when nvm is sourced interactively. YOUR Bash tool runs non-interactive subshells that do NOT source ~/.zshrc or ~/.bashrc, so `gws` and `node` will appear "not found" on the very next call. Symlink them into ~/.local/bin (which is on PATH in every shell context) right after install: `mkdir -p ~/.local/bin` `ln -sf "$(command -v gws)" ~/.local/bin/gws` `ln -sf "$(command -v node)" ~/.local/bin/node` Run these while nvm/fnm is sourced in the same Bash call so `command -v` resolves correctly. On native Windows, copy `gws.cmd` from the npm prefix into `$HOME\\.local\\bin\\` instead — symlinks need admin on Windows by default. c. Verify `gws --version` from a fresh `bash -c 'gws --version'` (deliberately non-interactive) — confirms the symlink path works for future tool calls. """ _GWS_STEP5_CONFIGURED_TEMPLATE = """5. The Agnes operator has already provisioned a shared Google Workspace OAuth app for this instance. Skip `gws auth setup` entirely. Do NOT use environment variables (Claude Code's security layer redacts vars containing the substring "SECRET" from non-interactive subshells, so the env-var approach is unreliable). Instead, write the credentials directly to the file `gws auth status` reads as `credential_source`: Use the Write tool to create `~/.config/gws/client_secret.json` (or `%APPDATA%\\gws\\client_secret.json` on native Windows) with EXACTLY the schema Google Cloud Console exports — the gws CLI's Rust struct rejects partial files with "Invalid client_secret.json format: missing field 'project_id'". Both `installed.project_id` (numeric project number) and the URI fields are mandatory: {{ "installed": {{ "client_id": "{client_id}", "project_id": "{project_id}", "auth_uri": "https://accounts.google.com/o/oauth2/auth", "token_uri": "https://oauth2.googleapis.com/token", "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", "client_secret": "{client_secret}", "redirect_uris": ["http://localhost"] }} }} Then `mkdir -p ~/.config/gws && chmod 700 ~/.config/gws && chmod 600 ~/.config/gws/client_secret.json`. Verify by running `gws auth status` — it should report this file as `credential_source` without complaining about missing fields. The values identify the OAuth app, not me; treat the secret like a publishable bundle key, not a per-user credential. """ _GWS_STEP5_MANUAL = """5. Run `gws auth setup` for me. This is a one-time Google Cloud project config; gcloud is NOT required (when gcloud is absent, `gws auth setup` walks through the manual OAuth flow). Open the URL it prints in my default browser, then walk me through each click because I am NOT a GCP admin: a. Pick or create a Google Cloud project (free tier is fine). b. Enable the APIs the connector needs: Google Drive API, Google Calendar API, Gmail API. Tell me each menu click. c. Create an OAuth 2.0 client. Either "Desktop app" or "Web application" works. For Web application: add `http://localhost` (exact value — no port, no path, no trailing slash) to Authorized redirect URIs. Google's loopback exemption then matches the `http://localhost:` redirect that `gws auth login` actually uses. Desktop app needs no URI registration. d. Copy the resulting client_id and client_secret. Paste them back into the terminal where `gws auth setup` is waiting. These identify the OAuth app — not the user — but still don't echo them back to me in chat. """ _GWS_PROMPT_TAIL_TEMPLATE = """ 6. Run `gws auth login --full` (no `--readonly` flag — Agnes uses full read + write access across Drive / Calendar / Gmail / Sheets / Docs / Chat so the agent can actually create, edit, and send on my behalf). The `--full` flag widens the default scope picker; without it Chat / People / Tasks scopes are silently dropped. One env var the loopback redirect needs is OAUTHLIB_INSECURE_TRANSPORT — set it in the SAME Bash invocation that runs login: `OAUTHLIB_INSECURE_TRANSPORT={oauth_env} gws auth login --full`. The CLI binds a local loopback server at `http://localhost:` — an OS-assigned ephemeral port, NOT a fixed 8080 — and prints an OAuth URL. If this errors with `redirect_uri_mismatch`, the Cloud Console OAuth client is a Web application type that's missing the `http://localhost` entry in Authorized redirect URIs (no port, no path) — add that exact value and retry. Capture the URL from gws's stdout. Before opening the browser, append the Chat write scopes (`https://www.googleapis.com/auth/chat.spaces` and `https://www.googleapis.com/auth/chat.messages`) to the URL's `scope=` query parameter — `--full` includes the readonly Chat scopes but NOT the read+write ones, and `gws chat ... send` calls fail without them. Decode the existing scope list, append the two URLs space-separated, re-encode, then open. Python one-liner via Bash tool: `URL=$(printf '%s' "$URL" | python3 -c 'import sys,urllib.parse as u; q=u.urlparse(sys.stdin.read().strip()); p=u.parse_qs(q.query); s=set(p.get("scope",[""])[0].split()); s |= {{"https://www.googleapis.com/auth/chat.spaces","https://www.googleapis.com/auth/chat.messages"}}; p["scope"]=[" ".join(sorted(s))]; print(q._replace(query=u.urlencode(p, doseq=True, quote_via=u.quote)).geturl())')` Then open the rewritten URL programmatically — do NOT print it to chat. Markdown line-wrapping in chat corrupts the long scope query string when the user copies it. Use your Bash tool: macOS `open "$URL"`, Linux/WSL `xdg-open "$URL"`, Windows `Start-Process "$URL"`. Detect OS first. While the browser tab is loading, read each requested scope in plain language for me — full read + write across Drive, Calendar, Gmail, Chat, and the rest — so I know what I'm consenting to before I click Approve. Tell me I can revoke any time at https://myaccount.google.com/permissions if I change my mind. If `gws auth status` later shows Chat scopes missing (e.g. on a re-run where a stale token cached the previous scope set), `rm ~/.config/gws/token.json` (or `%APPDATA%\\gws\\token.json` on native Windows) and re-run this step — the OAuth flow re-prompts with the new scope list. 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). Treat exit code 0 from each invocation as success — do NOT pipe gws output into `python3 -c 'f"..."'` (f-string expressions reject backslashes in Python <3.12, so escaping `\\"files\\"` inside a shell-quoted f-string raises SyntaxError) and do NOT call `json.load(sys.stdin)` on the raw stream (gws may emit log lines or a banner before the JSON body, which trips `JSONDecodeError`). If you really need to count rows for diagnostics, write the stdout to a temp file first and parse it with a plain `json.loads(open(path).read())` inside a `try/except`. If both calls exit 0, print `✅ Google Workspace ready — connected as . Drive + Chat scopes verified.` (exact prefix — the final summary grep for it). On any failure, print `❌ Google Workspace setup failed: , exit . .` 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.""" _ATLASSIAN_PROMPT = """Set up Atlassian (Jira + Confluence) API access 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 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 '"✅ 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 — {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 set -e EMAIL='' BASE_URL='' read -srp 'Paste Atlassian API token (hidden): ' t; echo # Guard 1 — Atlassian Cloud tokens are typically 192+ chars; sub-100 # means a truncated copy. Bail before touching the keychain. tlen=$(printf %s "$t" | wc -c | tr -d ' ') if [ "$tlen" -lt 100 ]; then 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 # Guard 2 — normalize the site root: strip a trailing slash, then a # trailing /wiki if present, so $BASE_URL is the bare site root. # `$BASE_URL/rest/api/3/myself` (Jira) and `$BASE_URL/wiki/rest/api/user/current` # (Confluence) both resolve correctly from the same normalized value. BASE_URL="${BASE_URL%/}" BASE_URL="${BASE_URL%/wiki}" # Guard 3 — verify against the live API before storing. Try Jira first # (most sites have it), fall back to Confluence on 404 only. On 401 # we stop immediately: the token itself is bad, no point probing the # other product. Anything else (5xx, network) also aborts. tmp=$(mktemp) product=jira status=$(curl -sS -o "$tmp" -w '%{http_code}' -u "$EMAIL:$t" "$BASE_URL/rest/api/3/myself" || true) if [ "$status" = "404" ]; then product=confluence status=$(curl -sS -o "$tmp" -w '%{http_code}' -u "$EMAIL:$t" "$BASE_URL/wiki/rest/api/user/current" || true) fi if [ "$status" != "200" ]; then if [ "$status" = "401" ]; then 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 "❌ 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 "❌ 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 exit 1 fi display=$(python3 -c 'import sys,json;d=json.load(sys.stdin);print(d.get("displayName","?"))' < "$tmp") rm -f "$tmp" # Verified — write token to Keychain + URL/email to secrets.env. security add-generic-password -U -s 'agnes-atlassian-api-token' -a "$EMAIL" -w "$t" umask 077; mkdir -p ~/.claude/agnes 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 "✅ 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 `✅ 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. 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)."""