agnes-the-ai-analyst/app/web/connector_prompts.py
Vojtech c09c85d13a
fix(cta): clipboard fallback + fold Atlassian MCP into connectors (#249)
* fix(cta): fall back to textarea+execCommand when Clipboard API rejects

The "Setup a new Claude Code" CTA fetches /auth/tokens, parses the JSON
response, renders the setup script, THEN calls
`navigator.clipboard.writeText()`. Modern browsers (Safari, Firefox, and
Chrome on stricter configurations) reject `writeText` with
NotAllowedError when transient user activation has been consumed by an
intervening `await` — which is exactly the case here. Users perceived
this as "the browser blocked the copy" and got the manual-paste fallback
modal even though the textarea + `document.execCommand('copy')` path
WOULD have worked synchronously without needing fresh user activation.

`copyToClipboard` now:
- prefers the modern Clipboard API (unchanged for the happy path)
- on writeText rejection, falls back to `copyViaTextarea` instead of
  surfacing the rejection to the caller's catch block.

`copyViaTextarea` is the previously-inline textarea fallback factored
out into a named helper, with two small hardening touches:
- `readonly` + `tabindex=-1` so the hidden textarea doesn't steal
  focus or pop the virtual keyboard on mobile.
- explicit `setSelectionRange(0, text.length)` to belt-and-braces the
  selection on iOS Safari (where `.select()` alone sometimes selects
  zero chars on touch-focused textareas).

Only the CTA button needed this — the Step-1 install-command and the
connector-copy buttons all call `writeText` synchronously inside the
click handler (no awaits in between), so they keep their existing
user-gesture context and didn't hit the same rejection. No template
changes there.

* refactor(home): fold Atlassian MCP registration into connectors block

The standalone "Register the Atlassian MCP server" step (was step 6 in
the unified setup script) moves INTO the Atlassian connector's prompt
body so all Atlassian-related setup lives in one logical group. Same
intent that #247 carried for connectors, applied one level deeper:
the hosted Remote MCP registration is part of "set up Atlassian", not
its own ungrouped step.

What changed:
- `app/web/connector_prompts.py` — the Atlassian prompt's step 5
  replaces the speculative "Register the on-demand Atlassian MCP under
  .claude/mcp/atlassian" line with the actual hosted Remote MCP
  registration: `claude mcp add --transport sse atlassian
  https://mcp.atlassian.com/v1/sse || true`. The `|| true` keeps re-runs
  idempotent and the body explains the OAuth-on-first-use contract.
  Both /home's Atlassian tile and the inlined setup-script Atlassian
  sub-block emit this line — single source of truth holds.
- `app/web/setup_instructions.py` — `_mcp_servers_block` deleted; the
  `mcp_servers` step is removed from `_step_numbers`; resolve_lines no
  longer calls it.
- Renumbering: install (1), init (2), catalog (3), preflight (4),
  marketplace (5), diagnose (6), connectors (7), confirm (8). Was:
  6 = mcp_servers, 7 = diagnose, 8 = connectors, 9 = confirm.
- `tests/test_setup_instructions.py` — Confirm step 9→8, Connect 8→7,
  diagnose 7→6, mcp_servers references dropped.
  `test_step_numbering_with_connectors_step` now asserts
  `"mcp_servers" not in steps`. Stray-Confirm assertion lists shift
  by one position.
- `tests/test_setup_page_unified.py` + `tests/test_web_ui.py` — same
  step-number shifts in the rendered /setup preview assertions.

The `claude mcp add` line is still the Atlassian Remote-MCP path that
the 2026-05-10 init-report Fix C added — only its position in the
flow changes. /home Atlassian tile copying continues to install the
MCP too (the prompt body the tile pastes contains the same line).
112 tests pass.

* feat(atlassian): operator-overrideable base URL via AGNES_ATLASSIAN_BASE_URL

Adds an env var / YAML key the operator (Terraform module, customer-VM
template, OSS instance.yaml) can set to bake the Atlassian Cloud site
root into the connector prompt — so end users don't have to guess /
paste their org's `https://<myorg>.atlassian.net`.

When set, the Atlassian connector prompt (rendered on both /home tile
and inlined into the setup-script step 7 Atlassian sub-block) replaces
step 1's "Ask me for my Atlassian Cloud site URL and email" with a
one-line note that the URL is already provisioned by the operator and
asks only for the email. Step 4's helper-script body has the
`BASE_URL='<the site URL I gave you>'` placeholder substituted with
the literal value. When unset (empty), the existing "ask the user"
flow remains — no regression for OSS instances.

Resolution + normalization in `get_atlassian_base_url()`:
- env `AGNES_ATLASSIAN_BASE_URL` > yaml `instance.atlassian.base_url` > ""
- strips trailing slash + trailing `/wiki` so the canonical value is
  the bare site root. Matches the per-user helper script's
  normalization at storage time (atlassian_prompt step 4 guard 2), so
  the literal baked in by the operator stays consistent with what the
  user's helper script would have computed from their input.

Plumbing:
- `app/instance_config.py`: new `get_atlassian_base_url()` resolver.
- `app/web/connector_prompts.py`:
  - `atlassian_prompt(*, base_url: str = "")` — string-replace two
    explicit placeholder phrases when base_url is truthy; otherwise
    return the prompt unchanged.
  - `all_connector_prompts(..., atlassian_base_url: str = "")` —
    forwards the kwarg.
- `app/web/router.py` (`_build_context`): reads
  `get_atlassian_base_url()` and passes it through to
  `all_connector_prompts(...)` so both the /home tile context AND the
  inlined-script `resolve_lines(...)` call use the same value.
- `src/welcome_template.py` (`compute_default_agent_prompt`): same
  threading via the existing import-on-demand path.

Tests (`tests/test_home_route_resolution.py`):
- `get_atlassian_base_url` resolver: default empty, env override,
  trailing-slash strip, trailing-`/wiki` strip.
- `atlassian_prompt(base_url=...)`: literal URL baked in, ask-step
  removed, placeholder replaced, operator-baked-in copy appears.
- `atlassian_prompt(base_url="")`: existing ask-the-user flow
  unchanged.
- `all_connector_prompts(atlassian_base_url=...)`: kwarg threads
  through to the rendered atlassian prompt.

135 tests pass.

* feat(asana): register hosted Asana Remote MCP in connector prompt

The Asana connector prompt only stored a PAT in the OS keychain + ran
a curl verify against /api/1.0/users/me. That set Claude Code up for
direct `curl` calls but didn't actually wire Asana into Claude's tool
list — so the user couldn't ask Claude to "find my open Asana tasks"
and have it work. Symmetric oversight to the Atlassian connector's
original speculative `.claude/mcp/atlassian` line that this branch
already replaced with `claude mcp add --transport sse atlassian
https://mcp.atlassian.com/v1/sse`.

Adds a new step 5 that registers Asana's hosted Remote MCP:

  claude mcp add --transport http asana https://mcp.asana.com/mcp || true

This is the V2 endpoint (streamable HTTP transport, launched February
2026). The V1 SSE endpoint at https://mcp.asana.com/sse was deprecated
2026-05-11 (today) and must NOT be used — calling it out explicitly
in the prompt body so a future operator who finds an old reference
doesn't paste the dead URL. OAuth is handled by Claude Code at first
use, same model as the Atlassian MCP step.

The PAT stored in step 3 stays for direct `curl` calls (precheck +
ad-hoc scripts) — the MCP path uses its own OAuth grant, not the PAT.
Old step 5 (revoke instructions) renumbers to step 6 and adds the
`claude mcp remove asana` cleanup hint.

Same single-source-of-truth invariant holds: /home Asana tile + the
inlined Asana sub-block in the setup script (step 7 connectors) both
emit identical text from `asana_prompt()`.

71 tests pass.

* feat(asana): drive MCP OAuth login + end-to-end validation post-register

`claude mcp add --transport http asana ...` only registers the
server in Claude Code's local config — it does NOT trigger OAuth.
The browser tab opens the first time any `mcp__asana__*` tool gets
invoked. So the previous step 5 left a user looking at a "registered"
MCP that, in practice, hadn't authed yet and would fail on first
real use. Same blind spot Atlassian's prompt also has, but Asana was
the one called out in the latest review pass.

Adds a new step 6 between MCP registration (step 5) and the revoke
instructions (now step 7):

  a. Tell the user verbatim what's about to happen — a low-impact
     read through the MCP will pop the OAuth browser tab; sign in
     with the same account whose PAT they stored in step 3 and
     approve. Frames the OAuth as one-time so users don't wait
     for it on every later call.
  b. Drive an actual MCP read. Don't prescribe the exact tool name
     because the Asana MCP's exposed surface (`mcp__asana__*`) is
     versioned upstream and we don't want to pin to a name that
     gets renamed. Instead: tell Claude to pick the lightest read
     from its surfaced tool list (users-me / list-workspaces /
     equivalent). Document the recovery path when Claude Code
     times out waiting for the OAuth tool use: `claude mcp list`
     to confirm registration before retrying.
  c. Print a single one-line proof that combines wiring + auth:
     "Asana MCP connected as <name> — <N> workspace(s) visible."
     Explicit anti-echo callout for tokens, task content, comments.
     On failure, surface the exact Claude-Code error and stop —
     no silent pass.
  d. Sanity-check that the MCP OAuth identity and the PAT identity
     reference the same Asana account. Easy mistake to make when
     the user has multiple Asana accounts — flag only on mismatch,
     keep quiet when they match. Recovery: `claude mcp remove asana
     && claude logout asana` then redo step 5.

Step 7 (revoke) absorbs both the keychain delete + the
`claude logout asana` line so users have a single place to undo
everything.

43 tests pass.

* fix(init): clear stale CA env vars on Windows before any TLS handshake

Reported by the 2026-05-11 Windows test pass: after `agnes init` the
gws connector failed with `UnknownIssuer` TLS errors because
`SSL_CERT_FILE` and `REQUESTS_CA_BUNDLE` were still set in Windows
User scope pointing at `C:\Users\localadmin\.config\agnes\ca-bundle.pem`
— a file that did not exist on the test host. Past Agnes installs
(the setup-prompt trust block + older bootstrap helpers) write those
pointers when they materialize a combined Agnes-CA bundle; when the
bundle file later disappears (re-init on a new VM, machine swap, the
~/.agnes dir wiped), the pointers go stale and every native Windows
TLS handshake fails before Agnes itself runs. SSL_CERT_FILE in
particular REPLACES (not appends to) the trust store, so a stale
pointer is silently catastrophic.

`agnes init` now clears stale pointers in two layers before the first
server roundtrip:

1. Current-process env (os.environ) — what the immediately-following
   `api_get` to /api/catalog/tables actually reads. Without this, init
   itself blows up before it gets to step 2.
2. Windows User-scope env via PowerShell
   `[Environment]::SetEnvironmentVariable(name, $null, 'User')` — what
   every future shell + every native tool (gws, claude.exe, pip, uv)
   inherits. The 2026-05-11 reporter expected this exact cleanup
   ("init was supposed to clear these but they persisted").

The cleanup is best-effort and conservative:
- Only deletes a var when its value points at a path that does NOT
  exist on disk. Intentional operator config (e.g. SSL_CERT_FILE
  pointing at a corp certifi bundle) stays put.
- PowerShell missing / restricted execution policy / WSL-without-pwsh:
  swallowed silently. The current-process leg still runs, which
  unblocks init even on hosts where the User-scope leg cannot fire.

Tests (`tests/test_init_ca_cleanup.py`, 6 cases):
- Stale pointers → removed from process env.
- Real-path pointers → preserved.
- Non-Windows hosts: PowerShell is not invoked.
- Windows hosts: PowerShell IS invoked with a script that checks
  all three vars + uses Test-Path + SetEnvironmentVariable.
- PowerShell FileNotFoundError: cleanup swallows it, does not raise.
- `_is_windows_host()` reflects sys.platform.

* refactor(asana): MCP-first flow — drop PAT storage, precheck via `claude mcp list`

The Asana hosted MCP at https://mcp.asana.com/mcp authenticates via
OAuth (Claude Code holds the grant; browser tab pops on first tool
use). The earlier prompt walked the user through creating + keychain-
storing an Asana Personal Access Token AND registering the MCP — two
parallel auth surfaces for one connector. Once the MCP works, the PAT
has no consumer: the precheck/verify steps that used `curl
$BASE/api/1.0/users/me` are just redundant proof that Asana itself is
reachable, which the OAuth handshake already establishes.

Removed:
- Step 0 keychain probe + curl verify against /users/me with PAT.
- Step 1 open developer-console / create PAT.
- Step 2 click "+ New access token", warn shown-ONCE.
- Step 3 helper-script for keychain-storage (per-OS bodies: macOS
  `security add-generic-password`, Linux `secret-tool store`, Windows
  `cmdkey /generic`).
- Step 4 PAT-side `users/me` verify.
- Step 5's split that kept the PAT around for direct curl scripts.
- Step 6d's "MCP vs PAT identity sanity check" — there is no PAT
  anymore, nothing to mismatch against.

New flow (3 steps total):
- Step 0 precheck: `claude mcp list | grep ^asana` — if found, the
  server is registered AND Claude Code is holding its OAuth grant
  (otherwise prior failure would have removed it); print
  "Asana MCP already registered — skipping setup" and stop. Tells the
  user the explicit reset command (`claude mcp remove asana && claude
  logout asana`) so a re-register stays one paste.
- Step 1: `claude mcp add --transport http asana
  https://mcp.asana.com/mcp` — no `|| true` because step 0 should have
  caught the "already exists" case. Step explains the V2-vs-V1
  endpoint distinction (V1 SSE deprecated 2026-05-11) and the
  abort-clean recovery if the precheck somehow missed the existing
  server.
- Step 2: same OAuth + low-impact-read validation pattern as before.
- Step 3: revoke instructions (mcp remove + logout + Asana-side app
  revoke at app.asana.com/Settings → Apps).

Both surfaces (the /home Asana tile and the inlined Asana sub-block
in the setup script's step 7) emit the new text from the same
asana_prompt() — single-source-of-truth invariant intact.

77 tests pass.
2026-05-11 21:54:51 +02:00

384 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 ``<slug>_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 = "",
) -> 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(),
"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),
}
# ---------------------------------------------------------------------------
# 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() -> 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.
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.
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
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 = "") -> 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://<myorg>.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='<the site URL I gave you>'`` literal becomes
``BASE_URL='<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`)."""
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("<the site URL I gave you>", base_url) \
.replace(
"1. Ask me for my Atlassian Cloud site URL (looks like https://<myorg>.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.",
)
return _ATLASSIAN_PROMPT
# ---------------------------------------------------------------------------
# Prompt bodies — kept as module-level constants so they're free of
# 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.
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.
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 &lt;display name from the MCP response&gt; — &lt;workspace count&gt; 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."""
_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 "Already connected as <email from `gws auth status`> — 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 15 (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/<v>/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/<v>/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:<ephemeral-port>` 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:<random-port>` — 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). Print only "Connected as <my email>" plus the file + space counts. 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 '"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.
1. Ask me for my Atlassian Cloud site URL (looks like https://<myorg>.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.
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='<the email I gave you>'
BASE_URL='<the site URL I gave you>'
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 "Token looks too short ($tlen chars) — copy the full value via the Copy button on the Atlassian token page. Aborting." >&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 "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
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
else
echo "API verification failed (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 "Stored ($product). Verified as $display."
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 <your name>." on success, or fail loudly with the exact reason (too short / HTTP 401 / etc.) without writing anything.
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.
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)."""