/home install-hero polish: license link contrast, auto-mode reorder, Shift+Tab guidance (#243)
* Make /home install-hero links readable against blue background The Claude license-options link added in the previous commit inherited the default `<a>` style (`var(--hp-primary)` blue), which renders as blue-on-blue and is unreadable inside the blue install-hero. Add a scoped `.install-hero a` rule that uses white with an underline (matching the existing lead-paragraph contrast pattern) so any link nested in the hero stays legible. * Reorder /home install flow: auto-mode is now Step 2, Agnes install becomes Step 3 Step 3 (was Step 2) pastes a ~20-command bash bootstrap into a fresh Claude Code session. Without auto-mode enabled first, each Bash/edit command needs a manual approve click — bad UX for first-time users. Move auto-mode from the outside-hero `<details>` reference block into the install-hero as a real Step 2, between "install Claude Code" and "install Agnes". Content is the persistent `acceptEdits` snippet (write to ~/.claude/settings.json) plus a one-liner pointing at Shift+Tab for users who are already inside a running Claude Code session. YOLO mode for full Bash auto-approve stays on /setup-advanced behind the existing link. The outside-hero `setup-collapsible[data-section="step3"]` block is dropped — auto-mode is no longer reference content, it's a real install step, and duplicating it would just diverge over time. Onboarded users no longer see the auto-mode block at all (consistent with Steps 1 + 3 also hiding post-onboarding). Completion banner copy updated: "Step 1, 2 & 3 done — Claude Code installed, auto-mode set, Agnes ready". Dashboard CTA partial and other templates don't reference step numbers for this flow, so no adaptation needed there. * Simplify /home Step 2 to Shift+Tab only — drop the JSON snippet Operator pointed out two issues with the prior Step 2: 1. The settings.json snippet is redundant. Claude Code's first Shift+Tab cycle to auto-accept mode already prompts the user whether to persist it as default — Claude writes the config itself, no manual file edit needed. 2. The snippet only showed the POSIX path `~/.claude/settings.json`, which doesn't translate to native Windows. Replace the snippet + copy button with a plain Shift+Tab instruction, explicitly call out the first-time "make this the default?" prompt, and note that Claude handles the config write itself — same flow on macOS / Linux / WSL / Windows. Adds a fallback line for users who already closed the post-OAuth session. * Tighten /home Step 2 install-note to two paragraphs Operator: drop the 'Claude writes the setting itself, so this works the same on macOS / Linux / WSL / Windows...' line plus the 'auto-approves file edits going forward; Bash commands stay gated — that's the safe default' line. Both were filler — the make-default prompt already implies persistence, and gated Bash is the obvious default users won't be surprised by. Result: paragraph 1 carries Shift+Tab + first-time make-default say-yes + closed-session fallback in one breath; paragraph 2 keeps the verbatim YOLO link. Same affordances, less vertical space.
This commit is contained in:
parent
65342cd1fb
commit
a46b9dc928
19 changed files with 1507 additions and 400 deletions
18
CHANGELOG.md
18
CHANGELOG.md
|
|
@ -10,6 +10,24 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **`instance.admin_email` operator config knob** (env `AGNES_INSTANCE_ADMIN_EMAIL` > YAML `instance.admin_email` > unset). When set, the `/home` Google Workspace connector tile renders an "Email admin" mailto button so analysts whose operator hasn't pre-provisioned a shared OAuth app can request one without leaving the workspace. Empty default cleanly hides the button.
|
||||
|
||||
- **Connector setup folded into the main install script (step 8).** New `app/web/connector_prompts.py` is the single source of truth for the Asana / Google Workspace / Atlassian per-tool prompts; `_connectors_block` in `setup_instructions.py` inlines them under per-connector default-yes asks (empty/Enter installs; only "no" skips). Same prompts power the `/home` tile cards via `{{ connector_prompts.<slug> }}` so editing one place updates both surfaces. Resolves the "extra paste step" friction surfaced by the 2026-05-09 onboarding test — fresh install becomes one paste end-to-end (Agnes + skills + connectors). Note: see #246 for the planned move of the connector prompt set into the operator-side overlay (so non-Atlassian/Asana/GWS shops aren't bound to this opinion).
|
||||
|
||||
### Changed
|
||||
|
||||
- **`/home` install hero polish** — license-options link contrast against the blue gradient (white + underline; matches lead-paragraph pattern), step reorder so auto-mode (Shift+Tab) becomes step 2 and Agnes install shifts to step 3 (auto-mode must be on BEFORE the ~20-command bash bootstrap so each Bash/edit doesn't need a manual approve click), step-2 simplification (Shift+Tab-only — Claude Code prompts to persist as default; no `~/.claude/settings.json` snippet to maintain). Onboarded users no longer see the auto-mode block. Completion banner reads "Step 1, 2 & 3 done — Claude Code installed, auto-mode set, Agnes ready".
|
||||
|
||||
- **`/home` onboarding friction fixes from internal usability testing** — improved hero copy clarity, connector tile gating notes (so users understand why some tiles are disabled), Asana / GWS / Atlassian prompt-correctness fixes (Atlassian three-guard structure: length floor → URL normalization → Jira-then-Confluence verify with 401 short-circuit; GWS `127.0.0.1` → `localhost` correction grounded in `strings` analysis of the `gws` binary), step layout clarification, and post-OAuth-session fallback line for users who closed the OAuth window before saving.
|
||||
|
||||
- **Setup script step layout: connectors becomes step 8, Confirm shifts to step 9.** Skills step deleted in #242 (on-demand `agnes skills show <name>` is the default; bulk-copying skills was an opinion question). Layout now: install (1), init (2), catalog (3), preflight (4), marketplace (5), mcp_servers (6), diagnose (7), connectors (8), confirm (9).
|
||||
|
||||
### Removed
|
||||
|
||||
- **BREAKING: `/corporate-memory` page + dashboard widget + nav link restricted to admins.** The `/corporate-memory` route now requires `require_admin` (was `get_current_user`); non-admin users hitting it see 403 (was 200). The Memory link in the top nav and the corporate-memory widget on `/dashboard` are hidden via `{% if session.user.is_admin %}` guards. **Asymmetry:** the underlying `/api/memory/*` endpoints stay on `get_current_user` so CLI / agent flows that POST a knowledge item or fetch `/api/memory` keep working; the gating is web-UI-only. Operators who relied on non-admin web access need to either grant Admin to those users or use the API.
|
||||
|
||||
## [0.49.0] — 2026-05-11
|
||||
|
||||
### Fixed (PR #242 follow-ups)
|
||||
|
|
|
|||
|
|
@ -258,6 +258,23 @@ def get_instance_subtitle() -> str:
|
|||
return get_value("instance", "subtitle", default="")
|
||||
|
||||
|
||||
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
|
||||
connector tile renders an "Email admin" mailto button when no shared
|
||||
OAuth app is provisioned). Empty string when unset — the template
|
||||
branches on the value being truthy, so an empty value hides the
|
||||
button rather than rendering a broken `mailto:` link.
|
||||
|
||||
Resolution: ``AGNES_INSTANCE_ADMIN_EMAIL`` env > ``instance.admin_email`` YAML > "".
|
||||
Mirrors :func:`get_home_route` shape so Terraform overrides work.
|
||||
"""
|
||||
raw = os.environ.get("AGNES_INSTANCE_ADMIN_EMAIL")
|
||||
if raw is None:
|
||||
raw = get_value("instance", "admin_email", default="")
|
||||
return (raw or "").strip()
|
||||
|
||||
|
||||
def get_sync_interval() -> str:
|
||||
"""Human-readable refresh cadence shown in the analyst welcome prompt."""
|
||||
return get_value("instance", "sync_interval", default="1 hour")
|
||||
|
|
|
|||
351
app/web/connector_prompts.py
Normal file
351
app/web/connector_prompts.py
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
"""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 = "",
|
||||
) -> 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` and the admin
|
||||
email from :func:`get_instance_admin_email`. 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.
|
||||
"""
|
||||
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(),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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 PAT setup. Stores token in OS keychain under
|
||||
``agnes-asana-pat``. Idempotent — re-running short-circuits when the
|
||||
cached token still passes the Asana ``users/me`` probe."""
|
||||
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() -> 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."""
|
||||
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 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. 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. 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 | "Already connected as \\(.name) (\\(.workspaces | length) workspace(s)). Skipping setup."' && 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 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 "Already connected" 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 — Agnes", 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. Important: do NOT ask me to paste the token into the chat. Chat input is saved to ~/.claude/projects/.../*.jsonl. Instead, prepare a tiny helper script for me to run in my real terminal:
|
||||
a. Detect my OS. Use the Write/Edit tool (NOT a shell here-doc that prints 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
|
||||
security add-generic-password -U -s 'agnes-asana-pat' -a "$USER" -w "$t"
|
||||
unset t
|
||||
echo 'Stored in macOS Keychain.'
|
||||
Linux variant: same shape but `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)); cmdkey /generic:agnes-asana-pat /user:$env:USERNAME /pass:$p > $null; Remove-Variable p,t; 'Stored.'`
|
||||
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 the 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. 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 "Stored", verify by calling `curl -sS -H "Authorization: Bearer $(security find-generic-password -s 'agnes-asana-pat' -w)" https://app.asana.com/api/1.0/users/me | jq -r '.data | "\\(.name) — \\(.workspaces | length) workspace(s)"'` (macOS; Linux uses `secret-tool lookup` instead). Print only the one-line result. Never echo the token.
|
||||
5. 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 "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 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/<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 on-demand Atlassian MCP under .claude/mcp/atlassian referencing the stored credentials (read token from keychain via `security find-generic-password -s 'agnes-atlassian-api-token' -w` at MCP startup).
|
||||
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)."""
|
||||
|
|
@ -23,7 +23,9 @@ from app.instance_config import (
|
|||
get_instance_name, get_instance_subtitle, get_datasets,
|
||||
get_theme, get_corporate_memory_config, get_home_route,
|
||||
get_gws_oauth_credentials, get_home_automode_visibility,
|
||||
get_instance_admin_email,
|
||||
)
|
||||
from app.web.connector_prompts import all_connector_prompts
|
||||
from src.repositories.sync_state import SyncStateRepository
|
||||
from src.repositories.sync_settings import SyncSettingsRepository
|
||||
from src.repositories.knowledge import KnowledgeRepository
|
||||
|
|
@ -403,11 +405,21 @@ def _build_context(
|
|||
server_host = request.url.netloc
|
||||
ca_pem = _read_agnes_ca_pem()
|
||||
|
||||
# Connector prompts wired through so step 9 inlines the same text
|
||||
# the /home tiles render. all_connector_prompts() reads operator
|
||||
# GWS OAuth config so the GCP-frictionless branch fires when the
|
||||
# admin has provisioned a shared client_id+secret.
|
||||
_connector_prompts = all_connector_prompts(
|
||||
gws_oauth=get_gws_oauth_credentials(),
|
||||
instance_admin_email=get_instance_admin_email(),
|
||||
)
|
||||
|
||||
setup_instructions_lines = resolve_lines(
|
||||
_wheel_filename,
|
||||
plugin_install_names=[],
|
||||
server_host=server_host,
|
||||
ca_pem=ca_pem,
|
||||
connector_prompts=_connector_prompts,
|
||||
)
|
||||
|
||||
ctx = {
|
||||
|
|
@ -431,6 +443,20 @@ def _build_context(
|
|||
# /home connector prompt. {} when unset → template falls back
|
||||
# to manual `gws auth setup`. See app.instance_config docstring.
|
||||
"gws_oauth": get_gws_oauth_credentials(),
|
||||
# Operator-facing contact email used by the /home GWS connector
|
||||
# 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(),
|
||||
# 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
|
||||
# gws prompt branches on `gws_oauth.configured` so both surfaces
|
||||
# render the operator-provisioned shortcut when credentials are
|
||||
# set, and the manual GCP walkthrough when they're not.
|
||||
"connector_prompts": all_connector_prompts(
|
||||
gws_oauth=get_gws_oauth_credentials(),
|
||||
instance_admin_email=get_instance_admin_email(),
|
||||
),
|
||||
# Whether /home renders the "Step 3 — turn on auto-accept mode"
|
||||
# install-block. Operator can hide it via AGNES_HOME_SHOW_AUTOMODE=0
|
||||
# for cautious rollouts; same content stays on /setup-advanced.
|
||||
|
|
@ -826,9 +852,27 @@ async def catalog(
|
|||
@router.get("/corporate-memory", response_class=HTMLResponse)
|
||||
async def corporate_memory(
|
||||
request: Request,
|
||||
user: dict = Depends(get_current_user),
|
||||
user: dict = Depends(require_admin),
|
||||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||||
):
|
||||
"""Corporate Memory web view — admin-only.
|
||||
|
||||
The page route gates on ``require_admin``; non-admin users see 403.
|
||||
The Memory nav link in `_app_header.html` and the corporate-memory
|
||||
widget on `/dashboard` are correspondingly hidden behind
|
||||
``{% if session.user.is_admin %}`` guards (defence in depth — the
|
||||
backend is the authoritative gate).
|
||||
|
||||
**Asymmetry**: the underlying ``/api/memory/*`` endpoints stay on
|
||||
``get_current_user`` (not ``require_admin``). CLI / agent flows that
|
||||
POST a knowledge item or read ``/api/memory`` keep working for any
|
||||
authenticated user. The gating here is web-UI-only — the API is the
|
||||
surface the agent rails care about (`agnes` CLI, knowledge-extract
|
||||
pipeline), and locking it down would break the corporate-memory
|
||||
feature outright. Operators who want to relax the web-UI gate can
|
||||
either grant Admin to those users or revert this route to
|
||||
``get_current_user`` in their fork.
|
||||
"""
|
||||
repo = KnowledgeRepository(conn)
|
||||
items = repo.list_items(statuses=["approved", "mandatory"], limit=100)
|
||||
|
||||
|
|
|
|||
|
|
@ -367,18 +367,18 @@ def _diagnose_lines(*, diagnose_num: str) -> list[str]:
|
|||
|
||||
Putting it last (instead of right after `whoami`) means it doubles as
|
||||
a server-health smoke test that runs once everything else is in place,
|
||||
not as a gate before them. It is the last step before Confirm — the
|
||||
whole prompt is non-interactive.
|
||||
not as a gate before them.
|
||||
|
||||
The bundled `agnes skills` knowledge base (markdown documents listable
|
||||
via `agnes skills list` / readable via `agnes skills show <name>`) is
|
||||
no longer surfaced from this prompt: discovery happens organically
|
||||
when CLAUDE.md or another skill references a specific entry (see the
|
||||
intentionally NOT surfaced as its own setup step (#242 dropped that
|
||||
interactive prompt). Discovery happens organically when CLAUDE.md or
|
||||
another skill references a specific entry (see the
|
||||
`agnes skills show agnes-data-querying` mention in the CLAUDE.md
|
||||
template's BigQuery section). Bulk-copying every skill into
|
||||
`~/.claude/skills/agnes/` at setup time was an interactive opinion
|
||||
question with no obvious right answer; on-demand lookup is the
|
||||
one-size-fits-all default.
|
||||
`~/.claude/skills/agnes/` at setup time was an opinion question with
|
||||
no obvious right answer; on-demand lookup is the one-size-fits-all
|
||||
default.
|
||||
"""
|
||||
return [
|
||||
"",
|
||||
|
|
@ -394,6 +394,69 @@ def _diagnose_lines(*, diagnose_num: str) -> list[str]:
|
|||
]
|
||||
|
||||
|
||||
def _connectors_block(
|
||||
step_num: str,
|
||||
connector_prompts: dict[str, str],
|
||||
*,
|
||||
confirm_step_num: str,
|
||||
) -> list[str]:
|
||||
"""Per-connector interactive ask + inline prompt. Last interactive
|
||||
step before Confirm.
|
||||
|
||||
Defaults to install (Y) — the user has to actively type "no" to skip.
|
||||
Rationale: when the operator provisions a shared GWS OAuth app
|
||||
(AGNES_GWS_CLIENT_ID + AGNES_GWS_CLIENT_SECRET), the GWS path runs
|
||||
in ~2 minutes with zero GCP clickops; Asana + Atlassian only need
|
||||
the user's own API token from their respective developer consoles —
|
||||
which they need either way to use the tool. Default-install matches
|
||||
"wire everything up" — the common path.
|
||||
|
||||
Each connector's prompt body comes verbatim from
|
||||
``app/web/connector_prompts.py`` — same source the /home tile cards
|
||||
render, so editing one place updates both surfaces. The prompts
|
||||
themselves are idempotent (each has its own step-0 precheck) so
|
||||
re-runs short-circuit cleanly.
|
||||
|
||||
Connectors are presented in the order they appear in the
|
||||
``connector_prompts`` dict — the caller controls ordering by how
|
||||
they build the dict. Today that's asana → gws → atlassian, matching
|
||||
the /home tile order.
|
||||
"""
|
||||
lines = [
|
||||
"",
|
||||
f"{step_num}) Connect the user's tools (last interactive ask before Confirm):",
|
||||
"",
|
||||
" For each tool below, ask the user verbatim: \"Set up <NAME> now? (Y/n)\".",
|
||||
" Treat empty/Enter as YES — the default is install. Only skip when the",
|
||||
" user types an explicit \"no\" / \"n\" / \"skip\". Wait for each answer",
|
||||
" before moving to the next. The prompts below are idempotent and",
|
||||
" safe to re-run if anything goes sideways.",
|
||||
"",
|
||||
]
|
||||
# Stable ordering — let the dict iteration order win (Python 3.7+ insertion-ordered).
|
||||
# Caller controls which connectors are present; we just iterate.
|
||||
sub_letters = "abcdefghij"
|
||||
for i, (slug, body) in enumerate(connector_prompts.items()):
|
||||
# Display name is sourced from the registry so the ask phrasing stays
|
||||
# in lockstep with what /home renders.
|
||||
from app.web.connector_prompts import CONNECTORS # local import — avoids module-load cycles
|
||||
display = next((c.display_name for c in CONNECTORS if c.slug == slug), slug)
|
||||
desc = next((c.description for c in CONNECTORS if c.slug == slug), "")
|
||||
lines.append(f" {sub_letters[i]}) {display} — {desc}")
|
||||
lines.append(f" Ask: \"Set up {display} now? (Y/n)\"")
|
||||
lines.append(" If yes (default) — follow this inline prompt verbatim:")
|
||||
lines.append("")
|
||||
# Indent the prompt body two spaces so it visually nests under the
|
||||
# ask. Empty lines stay empty (no trailing whitespace).
|
||||
for body_line in body.split("\n"):
|
||||
lines.append(f" {body_line}" if body_line else "")
|
||||
lines.append("")
|
||||
lines.extend([
|
||||
f" After all asks (regardless of answers) continue to step {confirm_step_num}.",
|
||||
])
|
||||
return lines
|
||||
|
||||
|
||||
def _finale_lines(*, confirm_step_num: str, has_ca: bool) -> list[str]:
|
||||
"""Final Confirm step. Bullets it asks the assistant to report on must
|
||||
only reference earlier steps that were actually emitted, otherwise the
|
||||
|
|
@ -402,18 +465,20 @@ def _finale_lines(*, confirm_step_num: str, has_ca: bool) -> list[str]:
|
|||
the trust block ran (`has_ca`). The marketplace clone bullet is
|
||||
unconditional now — preflight + marketplace are always emitted (Fix B
|
||||
in the 2026-05-10 init-report response). Init + catalog + diagnose +
|
||||
version always render, so their bullets are unconditional."""
|
||||
skills + connectors + version always render, so their bullets are
|
||||
unconditional."""
|
||||
bullets = [
|
||||
" - `agnes --version` output",
|
||||
" - First few lines of `agnes catalog` (tables you can see)",
|
||||
" - Confirmation that `./CLAUDE.md` and `./AGNES_WORKSPACE.md` exist",
|
||||
" - Confirmation that `./.claude/settings.json` contains SessionStart/End hooks",
|
||||
" - The `agnes diagnose` overall status",
|
||||
" - Whether skills were copied or left on-demand",
|
||||
" - Confirmation that `~/.agnes/marketplace/.git/` exists "
|
||||
"(the marketplace clone) and that any plugins currently in the "
|
||||
"served stack installed cleanly",
|
||||
" - Reminder to scroll to the connector cards on /home and connect "
|
||||
"Asana / Google Workspace / Atlassian (those run separately from this script)",
|
||||
"(the marketplace clone) and that any granted plugins installed",
|
||||
" - Which connectors got set up: Asana, Google Workspace, and "
|
||||
"Atlassian — installed or declined for each (the per-connector ask "
|
||||
"in the previous step drives this)",
|
||||
]
|
||||
if has_ca:
|
||||
bullets.append(
|
||||
|
|
@ -478,26 +543,13 @@ def _marketplace_block(
|
|||
) -> list[str]:
|
||||
"""Build the marketplace + plugin-install block.
|
||||
|
||||
`plugin_install_names` is the user's current *served stack* as
|
||||
computed by `src/marketplace_filter.py:resolve_user_marketplace` —
|
||||
i.e.::
|
||||
|
||||
(admin_RBAC ∩ /marketplace subscriptions)
|
||||
∪ system-mandatory plugins (admin-pinned, auto-applied)
|
||||
∪ Flea market installs (skills/agents bundled, plugins standalone)
|
||||
|
||||
May be empty: the served stack is curated by the user on the
|
||||
`/marketplace` page (admin grants are eligibility only — the user
|
||||
opts in via "Add to stack") plus whatever the admin pinned as
|
||||
system-mandatory plus the user's own Flea market picks. A brand-new
|
||||
account with no system plugins and no curation has an empty stack
|
||||
until something lands in any of those three buckets.
|
||||
|
||||
Registering the marketplace clone is unconditional regardless —
|
||||
Claude Code learns about the `agnes` marketplace at bootstrap, and
|
||||
the moment the served stack becomes non-empty, the user's next
|
||||
`/update-agnes-plugins` run installs the diff. No need to re-run
|
||||
setup when the stack changes server-side.
|
||||
`plugin_install_names` may be empty: registering the per-user
|
||||
marketplace clone with Claude Code is useful even when the operator
|
||||
has zero plugin grants, because it pre-wires the SessionStart hook
|
||||
and the grant flow — admin grants land on the next Claude Code
|
||||
session without re-running setup. The block copy adapts for the
|
||||
empty case so the comment-bullet doesn't promise plugin installs
|
||||
that won't happen.
|
||||
|
||||
`step_num` is parameterized because step ordering shifted between
|
||||
layouts (this block now runs before diagnose/skills, so it's step 5
|
||||
|
|
@ -553,41 +605,29 @@ def _marketplace_block(
|
|||
"""
|
||||
has_plugins = bool(plugin_install_names)
|
||||
header = (
|
||||
"Register the Agnes Claude Code marketplace and install your current stack:"
|
||||
"Register the Agnes Claude Code marketplace and install plugins:"
|
||||
if has_plugins
|
||||
else "Register the Agnes Claude Code marketplace (your stack is empty for now):"
|
||||
else "Register the Agnes Claude Code marketplace (no plugins granted yet):"
|
||||
)
|
||||
bullet_5 = (
|
||||
" # 5. install every plugin currently in your served stack"
|
||||
" # 5. install every plugin listed in the served manifest"
|
||||
if has_plugins
|
||||
else " # 5. (your served stack is empty right now — nothing to install yet)"
|
||||
else " # 5. (no plugins to install — your account has zero grants)"
|
||||
)
|
||||
if has_plugins:
|
||||
trailer = [
|
||||
" These run non-interactively. After they finish, tell the user to /exit",
|
||||
" and run `claude` again so the new plugins load.",
|
||||
"",
|
||||
" Stack curation lives on the server — visit /marketplace to add or",
|
||||
" remove items (admin-granted opt-ins, system plugins your org pinned,",
|
||||
" and uploads from the Flea market tab). The SessionStart hook checks",
|
||||
" for server-side changes on every Claude Code session and, when it",
|
||||
" detects a diff, prompts you to run `/update-agnes-plugins` inside",
|
||||
" Claude Code to apply it. No silent auto-install at session start —",
|
||||
" the slash command runs full reconcile with output visible in the",
|
||||
" transcript, under your control.",
|
||||
" and run `claude` again so the new plugins load. From then on, the",
|
||||
" SessionStart hook keeps the marketplace clone in sync via",
|
||||
" `agnes refresh-marketplace --quiet` on every Claude Code session.",
|
||||
]
|
||||
else:
|
||||
trailer = [
|
||||
" Your served stack is empty right now — nothing to install yet.",
|
||||
" Registering the marketplace clone anyway pre-wires Claude Code so",
|
||||
" future picks land cleanly: visit /marketplace to add plugins to",
|
||||
" your stack (admin-granted opt-ins, uploads from the Flea market",
|
||||
" tab), or wait for your admin to pin something as system-mandatory.",
|
||||
"",
|
||||
" When your stack becomes non-empty, the SessionStart hook detects",
|
||||
" the change on the next Claude Code session and prompts you to run",
|
||||
" `/update-agnes-plugins` inside Claude Code to install the new",
|
||||
" items. No need to re-run this setup script.",
|
||||
" Your account has no plugin grants right now, but registering the",
|
||||
" marketplace anyway pre-wires the SessionStart hook. When an admin",
|
||||
" grants you a plugin later, `agnes refresh-marketplace` (run by the",
|
||||
" hook on every Claude Code session) will install it automatically —",
|
||||
" no need to re-run this setup script.",
|
||||
]
|
||||
return [
|
||||
"",
|
||||
|
|
@ -680,7 +720,7 @@ def _preamble_lines(*, has_ca: bool) -> list[str]:
|
|||
return lines
|
||||
|
||||
|
||||
def _step_numbers() -> dict[str, str]:
|
||||
def _step_numbers(*, has_connectors: bool = True) -> dict[str, str]:
|
||||
"""Compute the step numbers for the unified layout.
|
||||
|
||||
Returns a dict keyed by logical step name; values are stringified
|
||||
|
|
@ -689,14 +729,28 @@ def _step_numbers() -> dict[str, str]:
|
|||
|
||||
Steps (always emitted): install (1), init (2), catalog (3),
|
||||
preflight (4), marketplace (5), mcp_servers (6), diagnose (7),
|
||||
confirm (8). Preflight + marketplace + mcp_servers are all always-on:
|
||||
- Marketplace registration is useful even with an empty served
|
||||
stack (future admin grants / system pins / Flea installs land
|
||||
cleanly without re-running setup).
|
||||
connectors (8), confirm (9). Preflight + marketplace + mcp_servers
|
||||
+ connectors are all always-on:
|
||||
- Marketplace registration is useful even when the operator has
|
||||
zero plugin grants (SessionStart hook reconciles future grants
|
||||
automatically).
|
||||
- Atlassian MCP registration is unattended-safe (hosted Remote MCP
|
||||
with Claude Code-managed OAuth) and applies to every analyst
|
||||
whose work touches Jira/Confluence — high enough hit rate to
|
||||
justify default-on.
|
||||
- Connectors (Asana / GWS / Atlassian) are per-connector default-yes
|
||||
asks — the user can decline each individually, so always-emitting
|
||||
the block costs nothing for users who skip everything.
|
||||
|
||||
The interactive "Skills" step that previously sat between diagnose
|
||||
and Confirm was deleted in #242 — on-demand `agnes skills show
|
||||
<name>` is the one-size-fits-all default; bulk-copying every skill
|
||||
into ``~/.claude/skills/agnes/`` was an opinion question without an
|
||||
obvious right answer.
|
||||
|
||||
`has_connectors` is kept as a parameter for future flexibility
|
||||
(default True). When set False, the connectors step is skipped and
|
||||
Confirm shifts down to step 8.
|
||||
|
||||
Step-0 (TLS trust block) sits outside this numbering — it is gated by
|
||||
has_ca and has its own "0)" header rendered inside the trust block
|
||||
|
|
@ -707,12 +761,16 @@ def _step_numbers() -> dict[str, str]:
|
|||
marketplace = str(n); n += 1
|
||||
mcp_servers = str(n); n += 1
|
||||
diagnose = str(n); n += 1
|
||||
connectors = str(n) if has_connectors else ""
|
||||
if has_connectors:
|
||||
n += 1
|
||||
confirm = str(n)
|
||||
return {
|
||||
"preflight": preflight,
|
||||
"marketplace": marketplace,
|
||||
"mcp_servers": mcp_servers,
|
||||
"diagnose": diagnose,
|
||||
"connectors": connectors,
|
||||
"confirm": confirm,
|
||||
}
|
||||
|
||||
|
|
@ -723,6 +781,7 @@ def resolve_lines(
|
|||
plugin_install_names: list[str] | None = None,
|
||||
server_host: str = "",
|
||||
ca_pem: str | None = None,
|
||||
connector_prompts: dict[str, str] | None = None,
|
||||
) -> list[str]:
|
||||
"""Return the template lines with server-side placeholders substituted.
|
||||
|
||||
|
|
@ -730,13 +789,6 @@ def resolve_lines(
|
|||
`{server_url}` and `{token}` as placeholders for click-time JS
|
||||
substitution (or for `render_setup_instructions()` below).
|
||||
|
||||
The layout is the same regardless of `plugin_install_names`: install
|
||||
(1), init (2), catalog (3), preflight (4), marketplace (5),
|
||||
mcp_servers (6), diagnose (7), confirm (8). The marketplace block's
|
||||
copy adapts to an empty served stack but the step is always emitted
|
||||
so future stack changes (admin grants, system pins, Flea installs)
|
||||
land cleanly without re-running setup.
|
||||
|
||||
`ca_pem` (PEM-encoded fullchain of the Agnes server's TLS cert) gates
|
||||
the cross-platform step-0 trust-bootstrap block AND switches step 1 to
|
||||
the curl-then-local-install pattern AND switches step 5 to the
|
||||
|
|
@ -744,6 +796,13 @@ def resolve_lines(
|
|||
needs the bootstrap (typically: skip for publicly-trusted certs like
|
||||
Let's Encrypt, emit for self-signed or private corp CA).
|
||||
|
||||
`connector_prompts` is a dict {slug: prompt_text} sourced from
|
||||
:func:`app.web.connector_prompts.all_connector_prompts`. When empty
|
||||
or None we fall back to the module's defaults (no operator GWS OAuth
|
||||
credentials baked in — the unconfigured GCP-walkthrough branch
|
||||
renders). Both the /home tiles and this setup script consume the
|
||||
same dict so the prompt text stays in lockstep across surfaces.
|
||||
|
||||
Fallback: callers pass `"agnes.whl"` when no wheel is present on disk.
|
||||
The resulting URL (`/cli/wheel/agnes.whl`) will 404 at download time, but
|
||||
the instruction text still renders so operators can see the snippet shape
|
||||
|
|
@ -752,10 +811,22 @@ def resolve_lines(
|
|||
names = list(plugin_install_names or [])
|
||||
has_ca = bool(ca_pem and ca_pem.strip())
|
||||
|
||||
# Step layout — single fixed shape; `_step_numbers` returns the
|
||||
# renumbered step labels in one place so the layout is unambiguous
|
||||
# and trivially extendable when a future step is added.
|
||||
steps = _step_numbers()
|
||||
# Lazy default for connector_prompts. Imported inline so the
|
||||
# setup_instructions module stays import-light when callers don't
|
||||
# actually emit the connectors block (tests that hit a single helper
|
||||
# don't pay the cost of loading the prompt strings).
|
||||
if not connector_prompts:
|
||||
from app.web.connector_prompts import all_connector_prompts
|
||||
connector_prompts = all_connector_prompts()
|
||||
|
||||
# Step layout. Preflight + marketplace + MCP go BEFORE diagnose;
|
||||
# connectors is the LAST interactive ask before Confirm — once plugins
|
||||
# + MCP + diagnose are settled, the only remaining work is plugging
|
||||
# the user's tools (Asana / GWS / Atlassian). The Skills step that
|
||||
# used to sit between diagnose and Confirm was deleted in #242
|
||||
# (on-demand `agnes skills show <name>` is the default;
|
||||
# bulk-copying skills was an opinion question).
|
||||
steps = _step_numbers(has_connectors=True)
|
||||
|
||||
lines: list[str] = []
|
||||
if has_ca:
|
||||
|
|
@ -766,9 +837,13 @@ def resolve_lines(
|
|||
lines.extend(_preflight_block(steps["preflight"])) # 4
|
||||
lines.extend(_marketplace_block(names, step_num=steps["marketplace"])) # 5
|
||||
lines.extend(_mcp_servers_block(steps["mcp_servers"])) # 6
|
||||
# Diagnose runs AFTER marketplace + MCP wiring so it doubles as a
|
||||
# final smoke test, not a pre-install gate.
|
||||
lines.extend(_diagnose_lines(diagnose_num=steps["diagnose"])) # 7
|
||||
# Connectors are the LAST interactive ask before Confirm. Per-connector
|
||||
# default-yes — empty/Enter is install, explicit "no" skips.
|
||||
lines.extend(_connectors_block(
|
||||
steps["connectors"], connector_prompts,
|
||||
confirm_step_num=steps["confirm"],
|
||||
))
|
||||
lines.append("")
|
||||
lines.extend(_finale_lines(
|
||||
confirm_step_num=steps["confirm"],
|
||||
|
|
@ -789,19 +864,21 @@ def render_setup_instructions(
|
|||
plugin_install_names: list[str] | None = None,
|
||||
server_host: str = "",
|
||||
ca_pem: str | None = None,
|
||||
connector_prompts: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
"""Render the setup instructions as a single string.
|
||||
|
||||
Used server-side for tests and any non-JS rendering path. The browser
|
||||
clipboard flow uses the JS renderer embedded in the Jinja partial; both
|
||||
must produce byte-identical output for a given (server_url, token,
|
||||
wheel, plugins, host, ca_pem) tuple.
|
||||
wheel, plugins, host, ca_pem, connector_prompts) tuple.
|
||||
"""
|
||||
lines = resolve_lines(
|
||||
wheel_filename,
|
||||
plugin_install_names=plugin_install_names,
|
||||
server_host=server_host,
|
||||
ca_pem=ca_pem,
|
||||
connector_prompts=connector_prompts,
|
||||
)
|
||||
text = "\n".join(lines)
|
||||
return text.replace("{server_url}", server_url).replace("{token}", token)
|
||||
|
|
|
|||
|
|
@ -24,8 +24,14 @@
|
|||
<a class="app-nav-link {% if _path == _home or _path == '/' or _path == '/dashboard' or _path == '/home' %}is-active{% endif %}" href="{{ _home }}">Home</a>
|
||||
<a class="app-nav-link {% if _path == '/marketplace' or _path.startswith('/marketplace/') %}is-active{% endif %}" href="/marketplace">Marketplace</a>
|
||||
<a class="app-nav-link {% if _path.startswith('/catalog') %}is-active{% endif %}" href="/catalog">Data Packages</a>
|
||||
<a class="app-nav-link {% if _path.startswith('/corporate-memory') %}is-active{% endif %}" href="/corporate-memory">Memory</a>
|
||||
{# Memory + Admin menu: both admin-only. Backend gates the routes
|
||||
themselves via require_admin (see app/web/router.py for
|
||||
/corporate-memory + /corporate-memory/admin + /admin/*), so
|
||||
hiding the links is purely a visibility tidy-up — non-admins
|
||||
who deep-link still get a 403 from the route handler. Single
|
||||
guard wraps both for clarity. #}
|
||||
{% if session.user.is_admin %}
|
||||
<a class="app-nav-link {% if _path.startswith('/corporate-memory') %}is-active{% endif %}" href="/corporate-memory">Memory</a>
|
||||
{% set _admin_active = _path.startswith('/admin/tables') or _path.startswith('/admin/tokens') or _path.startswith('/admin/users') or _path.startswith('/admin/groups') or _path.startswith('/admin/access') or _path.startswith('/admin/server-config') or _path.startswith('/admin/agent-prompt') or _path.startswith('/admin/workspace-prompt') or _path.startswith('/admin/marketplaces') or _path.startswith('/admin/store') or _path.startswith('/admin/scheduler-runs') or _path.startswith('/activity') %}
|
||||
<div class="app-nav-menu" id="adminNavMenu">
|
||||
<button type="button"
|
||||
|
|
|
|||
|
|
@ -220,6 +220,13 @@
|
|||
btn.classList.remove('copied');
|
||||
btn.disabled = false;
|
||||
}, 3000);
|
||||
// Notify any host page that wants to chain a follow-up UI
|
||||
// (e.g. /home's P0-2 "where to paste" 3-step modal). The
|
||||
// include itself does nothing with the event — listeners
|
||||
// that don't exist are no-ops.
|
||||
try {
|
||||
document.dispatchEvent(new CustomEvent('agnes:setup-script-copied'));
|
||||
} catch (_e) { /* CustomEvent ctor missing — IE-era, ignore */ }
|
||||
} catch (clipErr) {
|
||||
btn.textContent = origText;
|
||||
btn.disabled = false;
|
||||
|
|
|
|||
|
|
@ -2129,6 +2129,9 @@
|
|||
<!-- ── Right Column ── -->
|
||||
<div class="right-column">
|
||||
|
||||
{# Memory is admin-only. Widget hidden for non-admin; route
|
||||
/corporate-memory + nav link are likewise admin-gated. #}
|
||||
{% if session.user.is_admin %}
|
||||
<!-- Corporate Memory Widget -->
|
||||
<div class="card memory-card">
|
||||
<div class="card-header">
|
||||
|
|
@ -2178,6 +2181,7 @@
|
|||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Activity Center Widget -->
|
||||
<div class="card activity-card">
|
||||
|
|
@ -2444,8 +2448,10 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Load memory stats on page load (only for existing users)
|
||||
{% if user_info.exists %}
|
||||
// Load memory stats on page load. Memory is admin-only, so non-admin
|
||||
// visitors never see the widget — skip the fetch to avoid pointless
|
||||
// 401s in their console.
|
||||
{% if user_info.exists and session.user.is_admin %}
|
||||
document.addEventListener('DOMContentLoaded', loadMemoryStats);
|
||||
{% endif %}
|
||||
|
||||
|
|
|
|||
|
|
@ -235,6 +235,22 @@
|
|||
font-family: var(--hp-font-mono);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
/* Links inside the blue install-hero need a non-blue color — the default
|
||||
`<a>` style elsewhere on /home renders as `var(--hp-primary)` blue,
|
||||
which is invisible against the hero's blue background. Use the same
|
||||
high-contrast white-with-underline pattern the lead paragraph uses,
|
||||
and bump hover to full white so the affordance stays obvious. */
|
||||
.home-mock .install-hero a {
|
||||
color: #ffffff;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: rgba(255, 255, 255, 0.6);
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.home-mock .install-hero a:hover,
|
||||
.home-mock .install-hero a:focus {
|
||||
color: #ffffff;
|
||||
text-decoration-color: #ffffff;
|
||||
}
|
||||
|
||||
.home-mock .automode-card {
|
||||
margin: 22px 0;
|
||||
|
|
@ -870,6 +886,268 @@
|
|||
font-size: 12px;
|
||||
color: #FBBF24;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
Onboarding polish — friction fixes from internal usability testing.
|
||||
Each block is tagged with its priority (P0/P1/P2) — P0 resolves the
|
||||
highest-frequency confusion, P2 is nice-to-have polish.
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* P0-1 — "Don't have a terminal open?" disclosure inside Step 1's
|
||||
blue install-block. Yellow accent matches Claude footer mode dots
|
||||
so it reads as a hint, not an error. */
|
||||
.home-mock details.terminal-howto {
|
||||
margin-top: 10px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(251, 191, 36, 0.10);
|
||||
border: 1px solid rgba(251, 191, 36, 0.30);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.home-mock details.terminal-howto > summary {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: #FBBF24;
|
||||
user-select: none;
|
||||
}
|
||||
.home-mock details.terminal-howto > summary::-webkit-details-marker { display: none; }
|
||||
.home-mock details.terminal-howto > summary::before { content: "▸ "; margin-right: 4px; }
|
||||
.home-mock details.terminal-howto[open] > summary::before { content: "▾ "; }
|
||||
.home-mock .terminal-howto-body {
|
||||
margin-top: 10px;
|
||||
font-size: 12.5px;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.home-mock .terminal-howto-body p { margin: 0 0 6px; }
|
||||
.home-mock .terminal-howto-body kbd {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border: 1px solid rgba(255, 255, 255, 0.30);
|
||||
border-bottom-width: 2px;
|
||||
border-radius: 4px;
|
||||
padding: 1px 6px;
|
||||
font-size: 11px;
|
||||
font-family: var(--hp-font-mono);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* P0-3 — connector card time badge ("~5 min · self-serve") and the
|
||||
post-copy "now paste into Step 2" hint with arrow back. */
|
||||
.home-mock .connector-tile .ttl-row {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.home-mock .time-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--hp-primary-light);
|
||||
color: var(--hp-primary-dark);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.home-mock .time-badge.is-warn {
|
||||
background: #FEF3C7;
|
||||
color: #92400E;
|
||||
}
|
||||
.home-mock .copy-next-hint {
|
||||
font-size: 12px;
|
||||
color: var(--hp-primary-dark);
|
||||
background: var(--hp-primary-light);
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
margin-top: 6px;
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.home-mock .copy-next-hint.is-visible { display: inline-flex; }
|
||||
.home-mock .copy-next-hint code {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-family: var(--hp-font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* P1-8 — GWS gating note + Email-admin button. Yellow background
|
||||
matches the same friction-level used by other "admin help likely"
|
||||
surfaces (advanced setup warnings). */
|
||||
.home-mock .gating-note {
|
||||
font-size: 12px;
|
||||
color: #92400E;
|
||||
background: #FEF3C7;
|
||||
border: 1px solid #FCD34D;
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 10px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.home-mock .email-admin {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: white;
|
||||
border: 1px solid var(--hp-border);
|
||||
color: var(--hp-text-primary);
|
||||
padding: 5px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
align-self: flex-start;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.home-mock .email-admin:hover {
|
||||
text-decoration: none;
|
||||
border-color: var(--hp-primary);
|
||||
color: var(--hp-primary-dark);
|
||||
}
|
||||
|
||||
/* P1-6 — Auto-detect badge replaces the "Mark me as onboarded"
|
||||
button as the primary affordance. Pulse dot signals it's actively
|
||||
listening; the manual self-mark button stays as a fallback. */
|
||||
.home-mock .auto-detect-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
background: rgba(255, 255, 255, 0.10);
|
||||
border: 1px solid rgba(255, 255, 255, 0.20);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.home-mock .auto-detect-badge .pulse {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #FBBF24;
|
||||
box-shadow: 0 0 0 0 rgba(251, 191, 36, 0.7);
|
||||
animation: agnes-pulse 1.6s infinite;
|
||||
}
|
||||
.home-mock .auto-detect-badge code {
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-family: var(--hp-font-mono);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
@keyframes agnes-pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(251, 191, 36, 0.65); }
|
||||
70% { box-shadow: 0 0 0 8px rgba(251, 191, 36, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(251, 191, 36, 0); }
|
||||
}
|
||||
|
||||
/* P0-2 — Post-CTA modal with "where to paste" 3-step guide. Modal
|
||||
markup lives at body level; this styles the backdrop + card. The
|
||||
.home-mock prefix is intentionally NOT used so the modal is
|
||||
render-isolated from the home page's CSS variables. */
|
||||
.cta-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.55);
|
||||
-webkit-backdrop-filter: blur(2px);
|
||||
backdrop-filter: blur(2px);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 20px;
|
||||
}
|
||||
.cta-modal-backdrop.is-open { display: flex; }
|
||||
.cta-modal {
|
||||
background: white;
|
||||
border-radius: 14px;
|
||||
padding: 28px 32px;
|
||||
max-width: 520px;
|
||||
width: 100%;
|
||||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
|
||||
color: #111827;
|
||||
font-family: inherit;
|
||||
}
|
||||
.cta-modal h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 6px;
|
||||
color: #047857;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.cta-modal .lead {
|
||||
font-size: 13px;
|
||||
color: #6B7280;
|
||||
margin: 0 0 18px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.cta-modal ol {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 18px;
|
||||
counter-reset: step;
|
||||
}
|
||||
.cta-modal ol li {
|
||||
counter-increment: step;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
padding: 12px;
|
||||
background: #F9FAFB;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.cta-modal ol li::before {
|
||||
content: counter(step);
|
||||
width: 26px; height: 26px;
|
||||
border-radius: 50%;
|
||||
background: #0073D1;
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cta-modal ol li code,
|
||||
.cta-modal ol li kbd {
|
||||
background: #fff;
|
||||
border: 1px solid #E5E7EB;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: var(--hp-font-mono, ui-monospace, monospace);
|
||||
font-size: 12px;
|
||||
}
|
||||
.cta-modal ol li kbd { border-bottom-width: 2px; }
|
||||
.cta-modal ol li strong { display: block; margin-bottom: 4px; color: #111827; }
|
||||
.cta-modal-foot {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
gap: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #F3F4F6;
|
||||
}
|
||||
.cta-modal-foot .meta { font-size: 12px; color: #6B7280; }
|
||||
.cta-modal-foot button {
|
||||
background: #0073D1;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 8px 18px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.cta-modal-foot button:hover { background: #0056A3; }
|
||||
</style>
|
||||
|
||||
<div class="home-mock">
|
||||
|
|
@ -885,19 +1163,16 @@
|
|||
</p>
|
||||
{% else %}
|
||||
<div class="eyebrow">Welcome, {{ display_name }} — let's get you set up</div>
|
||||
<h1>{{ instance_name or "Agnes" }} is your team's AI workspace</h1>
|
||||
<h1>Connect Claude Code on your machine to your team's data</h1>
|
||||
<p class="lead">
|
||||
<strong>{{ instance_name or "Agnes" }} is terminal-driven</strong> — you live in your terminal alongside Claude Code, not in a web app. This site is just where you set things up, browse plugins, and check what data is available; the work itself happens in Claude Code on your machine. Two things live here:
|
||||
<strong>a plugin marketplace</strong> with curated and community-built tools for Claude Code,
|
||||
and <strong>access to your team's curated data</strong>.
|
||||
To use any of it from your machine, you install Claude Code and Agnes once. Everything Agnes touches goes in a single folder: <code style="background: rgba(255,255,255,0.12); padding: 1px 6px; border-radius: 4px; font-family: var(--hp-font-mono); font-size: 12.5px;">~/Agnes</code>.
|
||||
{{ instance_name or "Agnes" }} gives <strong>Claude Code</strong> on your computer access to your team's <strong>curated data, plugins, and shared knowledge</strong> — so you can ask questions and get answers in plain language, right from your terminal. This page walks you through the <strong>one-time setup (~10 minutes)</strong>. Everything it installs lives in your home folder (<code style="background: rgba(255,255,255,0.12); padding: 1px 6px; border-radius: 4px; font-family: var(--hp-font-mono); font-size: 12.5px;">~/Agnes</code>) and can be removed in one command.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if onboarded %}
|
||||
<div class="install-done" role="status" aria-live="polite">
|
||||
<span class="check" aria-hidden="true">✅</span>
|
||||
<span><strong>Step 1 & Step 2 done</strong> — Claude Code is installed and Agnes finished setting up <code>~/Agnes</code>. The full install steps stay one click away under the offboard control below.</span>
|
||||
<span><strong>Step 1, 2 & 3 done</strong> — Claude Code installed, auto-mode set, Agnes ready in <code>~/Agnes</code>. The full install steps stay one click away under the offboard control below.</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
|
@ -918,16 +1193,44 @@
|
|||
<span class="multiline" id="install-cmd-claude-windows">irm https://claude.ai/install.ps1 | iex</span>
|
||||
<button class="copy-btn" data-copy-target="install-cmd-claude-windows">Copy</button>
|
||||
</div>
|
||||
|
||||
{# P0-1 — terminal-howto disclosure. Two panels (unix / windows)
|
||||
that flip in lockstep with the install-cmd OS tabs above. #}
|
||||
<details class="terminal-howto">
|
||||
<summary>Don't have a terminal open? — show how</summary>
|
||||
<div class="terminal-howto-body" data-howto-panel="unix">
|
||||
<p><strong>macOS:</strong> press <kbd>⌘</kbd> + <kbd>Space</kbd>, type <kbd>Terminal</kbd>, press <kbd>Enter</kbd>.</p>
|
||||
<p><strong>Linux:</strong> press <kbd>Ctrl</kbd> + <kbd>Alt</kbd> + <kbd>T</kbd> (most distros), or open the apps menu and search "Terminal".</p>
|
||||
<p><strong>WSL:</strong> open the Windows Start menu, search "Ubuntu" (or your installed distro), press <kbd>Enter</kbd>.</p>
|
||||
<p>Paste the command above with <kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>V</kbd> (Linux/WSL) or <kbd>⌘</kbd> + <kbd>V</kbd> (macOS), then press <kbd>Enter</kbd>.</p>
|
||||
</div>
|
||||
<div class="terminal-howto-body" data-howto-panel="windows" hidden>
|
||||
<p><strong>Windows:</strong> press <kbd>Win</kbd> + <kbd>R</kbd>, type <kbd>powershell</kbd>, press <kbd>Enter</kbd>. (Or: open Start menu, type "PowerShell".)</p>
|
||||
<p>Paste the command above with <kbd>Ctrl</kbd> + <kbd>V</kbd> and press <kbd>Enter</kbd>.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="install-note">
|
||||
Verify with <code>claude --version</code>. Sign in once with <code>claude</code> and complete the OAuth flow.
|
||||
Don't have a Claude license yet? See <a href="/setup-advanced#claude-plan">plan options on /setup-advanced</a> (Pro / Max 5× / Max 20×).
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if home_automode.show %}
|
||||
<div class="install-block">
|
||||
<div class="label">Step 2 — install Agnes from inside Claude Code</div>
|
||||
<div class="label">Step 2 — turn on auto-mode (recommended before Step 3)</div>
|
||||
<div class="install-note">
|
||||
In the Claude Code session you just signed into, press <strong>Shift + Tab</strong>. Claude cycles modes: default → <strong>auto-accept edits</strong> → plan mode → default; the footer shows <code>⏵⏵</code> when auto-accept is on. On the first cycle to auto-accept, Claude asks whether to make it the default — say <strong>yes</strong>. Closed the session already? Run <code>claude</code> again, then press <strong>Shift + Tab</strong>.
|
||||
<br><br>
|
||||
Want full auto-approve including Bash? See <a href="/setup-advanced#yolo">YOLO mode</a> on /setup-advanced — pairs <code>--dangerously-skip-permissions</code> with a reviewed <code>~/.claude/settings.local.json</code> allowlist. Skip if you're not sure.
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="install-block">
|
||||
<div class="label">Step 3 — install Agnes from inside Claude Code</div>
|
||||
<p class="setup-cta-lead">
|
||||
Click the button — Agnes mints a 90-day personal access token, copies a ready-to-paste setup script to your clipboard, and the script bootstraps everything in <code>~/Agnes</code> when you paste it into Claude Code.
|
||||
Click the button — {{ instance_name or "Agnes" }} <strong>creates a 90-day login token</strong>, 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 <code>~/Agnes</code> once Claude Code receives it.
|
||||
</p>
|
||||
<div class="setup-cta-row">
|
||||
<button type="button" id="setupClaudeBtn" class="btn-setup-primary" onclick="setupNewClaude(this)">
|
||||
|
|
@ -958,6 +1261,18 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# P1-6 — auto-detect badge is the PRIMARY affordance after the
|
||||
install-script copy: agnes-init's first POST to
|
||||
/api/me/onboarded flips state automatically and the page
|
||||
reloads. The manual "Mark me as onboarded" button below it
|
||||
stays as a fallback when auto-flip never lands. #}
|
||||
{% if not onboarded %}
|
||||
<div class="auto-detect-badge" role="status" aria-live="polite">
|
||||
<span class="pulse" aria-hidden="true"></span>
|
||||
<span>Waiting for your first <code>agnes pull</code> — auto-detects within ~30 s of the setup script finishing.</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Self-mark control lives inside the blue hero in both states.
|
||||
When onboarded, the install steps above are hidden so this is
|
||||
the only thing rendered below the lead paragraph. #}
|
||||
|
|
@ -977,11 +1292,13 @@
|
|||
</div>
|
||||
|
||||
{% if onboarded %}
|
||||
{# User-controlled minimize toggle for Step 3 + Connect-your-tools.
|
||||
Default OFF (sections render flat). State persists in
|
||||
{# User-controlled minimize toggle for Connect-your-tools.
|
||||
Default OFF (section renders flat). State persists in
|
||||
localStorage so the choice is per-device. The agnes-init
|
||||
auto-flip of users.onboarded never triggers a collapse on
|
||||
its own — only an explicit click here does. #}
|
||||
its own — only an explicit click here does. The auto-mode
|
||||
block used to be a peer collapsible (`step3`); it now lives
|
||||
inside the install-hero as Step 2 and is not collapsible. #}
|
||||
<div class="setup-minimize">
|
||||
<button id="setupMinimizeToggle" type="button" aria-pressed="false">
|
||||
Minimize setup view
|
||||
|
|
@ -990,49 +1307,10 @@
|
|||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if home_automode.show %}
|
||||
<details class="setup-collapsible" data-section="step3" open>
|
||||
<summary>
|
||||
<span class="ico" aria-hidden="true">⚡️</span>
|
||||
<span class="ttl">Step 3 — auto-accept mode <small>(reference)</small></span>
|
||||
<span class="chev" aria-hidden="true">›</span>
|
||||
</summary>
|
||||
<div class="automode-card">
|
||||
<div class="automode-head">
|
||||
<span class="ico">⚡️</span>
|
||||
<div>
|
||||
<h3>Step 3 — turn on auto-accept mode (recommended)</h3>
|
||||
<p>By default, Claude asks permission before every action. Auto-accept mode auto-approves file edits while keeping shell commands and other side-effect tools gated — fast for daily work, still safe.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="automode-grid">
|
||||
<div class="automode-step">
|
||||
<div class="num">1</div>
|
||||
<div class="step-text">
|
||||
<strong>One session</strong>
|
||||
Inside any active Claude Code session, press <kbd>Shift</kbd> + <kbd>Tab</kbd> to cycle modes:<br>
|
||||
default → <strong>auto-accept edits</strong> → plan mode → default. The footer shows <code>⏵⏵</code> when auto-accept is on.
|
||||
</div>
|
||||
</div>
|
||||
<div class="automode-step">
|
||||
<div class="num">2</div>
|
||||
<div class="step-text">
|
||||
<strong>Persist across sessions</strong>
|
||||
Add to <code>~/.claude/settings.json</code>:
|
||||
<pre class="automode-code">{
|
||||
"permissions": {
|
||||
"defaultMode": "acceptEdits"
|
||||
}
|
||||
}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="automode-foot">
|
||||
Want full auto-approve including Bash? See <a href="/setup-advanced#yolo">YOLO mode</a> on /setup-advanced — pairs <code>--dangerously-skip-permissions</code> with a reviewed <code>~/.claude/settings.local.json</code> allowlist. Skip if you're not sure.
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
{# Auto-mode card used to live here as a `<details>` reference block;
|
||||
moved into the install-hero as the new Step 2 so users enable it
|
||||
BEFORE Step 3's install runs ~20 commands. Gated by the same
|
||||
`home_automode.show` flag at the call site. #}
|
||||
|
||||
<details class="setup-collapsible" data-section="connectors" open>
|
||||
<summary>
|
||||
|
|
@ -1045,183 +1323,83 @@
|
|||
<div class="connector-tiles">
|
||||
<div class="connector-tile">
|
||||
<span class="ico">✅</span>
|
||||
<div class="ttl">Asana</div>
|
||||
{# P0-3 — title row + time-badge + post-copy hint. The hint
|
||||
container is rendered hidden; the JS reveals it after the
|
||||
copy succeeds, then auto-hides after 8 s. #}
|
||||
<div class="ttl-row">
|
||||
<span class="ttl">Asana</span>
|
||||
<span class="time-badge">~5 min · self-serve</span>
|
||||
</div>
|
||||
<div class="desc">Read tasks and projects, comment, create updates — Claude works alongside your project boards without leaving the terminal.</div>
|
||||
<div class="connector-actions">
|
||||
<button class="connector-copy" data-copy-target="asana-prompt">Copy prompt</button>
|
||||
<button class="connector-copy" data-copy-target="asana-prompt" data-connector="Asana">Copy prompt</button>
|
||||
<div class="copy-next-hint" data-hint-for="Asana">
|
||||
<span>✅ Copied. Now paste into Claude Code — run <code>claude</code> in your terminal, then paste & press Enter.</span>
|
||||
</div>
|
||||
<details class="connector-preview">
|
||||
<summary>Show prompt</summary>
|
||||
<div class="card-mini-cmd"><code id="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. 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. 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 | "Already connected as \(.name) (\(.workspaces | length) workspace(s)). Skipping setup."' && 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 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 "Already connected" 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 — Agnes", 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. Important: do NOT ask me to paste the token into the chat. Chat input is saved to ~/.claude/projects/.../*.jsonl. Instead, prepare a tiny helper script for me to run in my real terminal:
|
||||
a. Detect my OS. Use the Write/Edit tool (NOT a shell here-doc that prints 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
|
||||
security add-generic-password -U -s 'agnes-asana-pat' -a "$USER" -w "$t"
|
||||
unset t
|
||||
echo 'Stored in macOS Keychain.'
|
||||
Linux variant: same shape but `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)); cmdkey /generic:agnes-asana-pat /user:$env:USERNAME /pass:$p > $null; Remove-Variable p,t; 'Stored.'`
|
||||
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 the 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. 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 "Stored", verify by calling `curl -sS -H "Authorization: Bearer $(security find-generic-password -s 'agnes-asana-pat' -w)" https://app.asana.com/api/1.0/users/me | jq -r '.data | "\(.name) — \(.workspaces | length) workspace(s)"'` (macOS; Linux uses `secret-tool lookup` instead). Print only the one-line result. Never echo the token.
|
||||
5. 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.</code></div>
|
||||
{# Asana prompt body sourced from app/web/connector_prompts.py
|
||||
(`asana_prompt()`). Same string the main setup script
|
||||
inlines in step 9, so the two surfaces stay in lockstep. #}
|
||||
<div class="card-mini-cmd"><code id="asana-prompt">{{ connector_prompts.asana }}</code></div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="connector-tile">
|
||||
<span class="ico">📚</span>
|
||||
<div class="ttl">Google Workspace</div>
|
||||
{# P0-3 + P1-8 — Google Workspace tile. Time badge warns when
|
||||
the operator hasn't provisioned a shared OAuth app (forces
|
||||
the user to set up GCP themselves, which is a ~20-min
|
||||
clickops detour). The gating-note + email-admin button
|
||||
appear in that same un-configured branch so the user has
|
||||
a way out before copying. #}
|
||||
<div class="ttl-row">
|
||||
<span class="ttl">Google Workspace</span>
|
||||
{% if gws_oauth.configured %}
|
||||
<span class="time-badge">~5 min · self-serve</span>
|
||||
{% else %}
|
||||
<span class="time-badge is-warn">~20 min · admin help likely</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="desc">Drive, Calendar, Gmail, Docs, Sheets, Chat — Claude reads and acts across your work account via the official <code>gws</code> CLI.</div>
|
||||
{% if not gws_oauth.configured %}
|
||||
<div class="gating-note">
|
||||
<strong>Heads up:</strong> 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.
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="connector-actions">
|
||||
<button class="connector-copy" data-copy-target="gws-prompt">Copy prompt</button>
|
||||
<button class="connector-copy" data-copy-target="gws-prompt" data-connector="Google Workspace">Copy prompt</button>
|
||||
{% if not gws_oauth.configured and instance_admin_email %}
|
||||
<a class="email-admin" href="mailto:{{ instance_admin_email }}?subject=Agnes%20%E2%80%94%20Google%20Workspace%20OAuth%20setup&body=Hi%20%E2%80%94%20I'd%20like%20to%20connect%20Google%20Workspace%20in%20Agnes%20but%20it%20looks%20like%20a%20shared%20OAuth%20app%20isn't%20provisioned%20yet.%20Could%20you%20set%20it%20up%20for%20our%20instance%3F%20Thanks!">
|
||||
✉️ Email admin
|
||||
</a>
|
||||
{% endif %}
|
||||
<div class="copy-next-hint" data-hint-for="Google Workspace">
|
||||
<span>✅ Copied. Now paste into Claude Code — run <code>claude</code> in your terminal, then paste & press Enter.</span>
|
||||
</div>
|
||||
<details class="connector-preview">
|
||||
<summary>Show prompt</summary>
|
||||
<div class="card-mini-cmd"><code id="gws-prompt">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 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/<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.
|
||||
|
||||
{% if gws_oauth.configured %}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": "{{ gws_oauth.client_id }}",
|
||||
"project_id": "{{ gws_oauth.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": "{{ gws_oauth.client_secret }}",
|
||||
"redirect_uris": ["http://127.0.0.1"]
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
{% else %}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 of type "Desktop app".
|
||||
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.{% endif %}
|
||||
|
||||
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={{ gws_oauth.oauthlib_insecure_transport if gws_oauth.configured else "1" }} gws auth login --full`. The CLI binds a local loopback server at http://127.0.0.1:8080 and prints an OAuth URL.
|
||||
|
||||
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.</code></div>
|
||||
<div class="card-mini-cmd"><code id="gws-prompt">{{ connector_prompts.gws }}</code></div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="connector-tile">
|
||||
<span class="ico">🎟️</span>
|
||||
<div class="ttl">Atlassian (Jira / Confluence)</div>
|
||||
<div class="ttl-row">
|
||||
<span class="ttl">Atlassian (Jira / Confluence)</span>
|
||||
<span class="time-badge">~7 min · self-serve</span>
|
||||
</div>
|
||||
<div class="desc">Read and write Jira issues, search Confluence pages — Claude pulls ticket context and posts updates without leaving the workspace.</div>
|
||||
<div class="connector-actions">
|
||||
<button class="connector-copy" data-copy-target="jira-prompt">Copy prompt</button>
|
||||
<button class="connector-copy" data-copy-target="jira-prompt" data-connector="Atlassian">Copy prompt</button>
|
||||
<div class="copy-next-hint" data-hint-for="Atlassian">
|
||||
<span>✅ Copied. Now paste into Claude Code — run <code>claude</code> in your terminal, then paste & press Enter.</span>
|
||||
</div>
|
||||
<details class="connector-preview">
|
||||
<summary>Show prompt</summary>
|
||||
<div class="card-mini-cmd"><code id="jira-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 + base URL in `~/.claude/agnes/secrets.env` and the API token in the OS keychain under `agnes-atlassian-api-token`. Verify all three exist + auth works before reinstalling. 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) && curl -fsS -u "$ATLASSIAN_EMAIL:$t" "$ATLASSIAN_BASE_URL/rest/api/3/myself" | jq -r '"Already connected as \(.displayName) (\(.emailAddress)) on '"$ATLASSIAN_BASE_URL"'. Skipping setup."' && 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 returns 200, STOP with the "Already connected" line. Continue to step 1 only when secrets.env is missing OR keychain lookup fails OR `myself` returns 401.
|
||||
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 and (ii) verify the credentials against the Atlassian API BEFORE writing anything to the keychain. Both guards exist 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. Body for macOS:
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
EMAIL='<the email I gave you>'
|
||||
BASE_URL='<the site URL I gave you, no trailing slash>'
|
||||
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 — verify against the live API before storing. If auth fails
|
||||
# the keychain stays untouched and we know immediately why.
|
||||
tmp=$(mktemp)
|
||||
status=$(curl -sS -o "$tmp" -w '%{http_code}' -u "$EMAIL:$t" "$BASE_URL/rest/api/3/myself" || true)
|
||||
if [[ "$status" != "200" ]]; then
|
||||
echo "API verification failed (HTTP $status). Aborting without storing." >&2
|
||||
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. 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"`. Both guards (length floor, API verification) stay identical — they run before the storage call. Windows .ps1: same control flow using `Read-Host -AsSecureString`, convert to plain via `Marshal::PtrToStringAuto`, check `$t.Length -lt 100`, run `Invoke-RestMethod -Uri "$BASE_URL/rest/api/3/myself" -Authentication Basic -Credential (New-Object PSCredential($EMAIL, $secureToken))` wrapped in try/catch (writes to `cmdkey` only on success), then write secrets.env via Set-Content.
|
||||
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 on-demand Atlassian MCP under .claude/mcp/atlassian referencing the stored credentials (read token from keychain via `security find-generic-password -s 'agnes-atlassian-api-token' -w` at MCP startup).
|
||||
6. The store script already verified the token end-to-end. If I want a second redacted readback later, you can hit `GET $BASE_URL/rest/api/3/myself` again and 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).</code></div>
|
||||
<div class="card-mini-cmd"><code id="jira-prompt">{{ connector_prompts.atlassian }}</code></div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1242,12 +1420,17 @@ Ground rules: this is idempotent — safe to re-run, the precheck below short-ci
|
|||
<div class="ttl">Curated data packages <span class="arrow">→</span></div>
|
||||
<div class="desc">Tables, schema, and metric definitions your team has registered. Subscribe and Claude can query them with documented business rules.</div>
|
||||
</a>
|
||||
{# Corporate memory + Activity center are both admin-only — matches
|
||||
the top-nav gating logic (the "Memory" link is hidden for
|
||||
non-admin in _app_header.html, and the /corporate-memory route
|
||||
is `require_admin`). Without this gate non-admin users would
|
||||
see a Corporate memory tile that 403s when clicked. #}
|
||||
{% if is_admin %}
|
||||
<a class="what-is-item" href="/corporate-memory">
|
||||
<span class="ico">🧠</span>
|
||||
<div class="ttl">Corporate memory <span class="arrow">→</span></div>
|
||||
<div class="desc">Shared analyst knowledge and prior solutions to draw from. Searchable, versioned, fed back into Claude's context on demand.</div>
|
||||
</a>
|
||||
{% if is_admin %}
|
||||
<a class="what-is-item" href="/activity-center">
|
||||
<span class="ico">📈</span>
|
||||
<div class="ttl">Activity center <span class="arrow">→</span></div>
|
||||
|
|
@ -1277,6 +1460,44 @@ Ground rules: this is idempotent — safe to re-run, the precheck below short-ci
|
|||
|
||||
</div>
|
||||
|
||||
{# P0-2 — Post-CTA modal. Opens after the shared CTA include below has
|
||||
created the token + copied the script. _claude_setup_cta.jinja still
|
||||
owns the token request + clipboard write; this modal layers on top
|
||||
with a 3-step "where to paste" guide. Lives at body level so the
|
||||
home-mock styles don't bleed into it. #}
|
||||
{% if not onboarded %}
|
||||
<div class="cta-modal-backdrop" id="cta-modal-backdrop" role="dialog" aria-modal="true" aria-labelledby="cta-modal-title" hidden>
|
||||
<div class="cta-modal">
|
||||
<h2 id="cta-modal-title"><span aria-hidden="true">✅</span> Setup script copied to clipboard</h2>
|
||||
<p class="lead">Now paste it into Claude Code on your machine — three steps:</p>
|
||||
<ol>
|
||||
<li>
|
||||
<div>
|
||||
<strong>Open a terminal</strong>
|
||||
macOS: <kbd>⌘</kbd>+<kbd>Space</kbd>, type "Terminal". Windows: <kbd>Win</kbd>+<kbd>R</kbd>, type <code>powershell</code>. Linux: <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>T</kbd>.
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div>
|
||||
<strong>Start Claude Code</strong>
|
||||
Type <code>claude</code> and press <kbd>Enter</kbd>. You'll see a prompt waiting for input.
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div>
|
||||
<strong>Paste & press Enter</strong>
|
||||
macOS: <kbd>⌘</kbd>+<kbd>V</kbd>. Windows/Linux: <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>V</kbd>. Claude runs the setup and tells you when it's done.
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
<div class="cta-modal-foot">
|
||||
<span class="meta">Token is in clipboard only — never displayed here.</span>
|
||||
<button type="button" id="cta-modal-close">Got it</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Shared "Setup a new Claude Code" CTA behaviour — provides the JS that
|
||||
POSTs /auth/tokens, copies the rendered instructions to the clipboard,
|
||||
and falls back to a modal when the clipboard API is blocked. The button
|
||||
|
|
@ -1301,9 +1522,41 @@ Ground rules: this is idempotent — safe to re-run, the precheck below short-ci
|
|||
}).catch(function () { btn.textContent = 'Copy failed'; });
|
||||
});
|
||||
}
|
||||
document.querySelectorAll('.copy-btn[data-copy-target], .connector-copy[data-copy-target]').forEach(wireCopy);
|
||||
document.querySelectorAll('.copy-btn[data-copy-target]').forEach(wireCopy);
|
||||
|
||||
// OS tab switching for Step 1.
|
||||
// P0-3 + P2-12 — Connector copy buttons. Three things on click:
|
||||
// 1) copy the prompt (same textContent trick so closed <details>
|
||||
// still copies);
|
||||
// 2) flip the button to "✓ Copied" + disabled for 8 s so the user
|
||||
// stops mashing it while figuring out where to paste;
|
||||
// 3) reveal a paste hint pointing the user at Claude Code.
|
||||
document.querySelectorAll('.connector-copy[data-copy-target]').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
var src = document.getElementById(btn.getAttribute('data-copy-target'));
|
||||
if (!src) return;
|
||||
var raw = src.textContent || '';
|
||||
var connector = btn.getAttribute('data-connector') || '';
|
||||
var hint = connector
|
||||
? document.querySelector('.copy-next-hint[data-hint-for="' + connector + '"]')
|
||||
: null;
|
||||
navigator.clipboard.writeText(raw).then(function () {
|
||||
var orig = btn.textContent;
|
||||
btn.classList.add('copied');
|
||||
btn.textContent = '✓ Copied';
|
||||
btn.disabled = true;
|
||||
if (hint) hint.classList.add('is-visible');
|
||||
setTimeout(function () {
|
||||
btn.classList.remove('copied');
|
||||
btn.textContent = orig;
|
||||
btn.disabled = false;
|
||||
if (hint) hint.classList.remove('is-visible');
|
||||
}, 8000);
|
||||
}).catch(function () { btn.textContent = 'Copy failed'; });
|
||||
});
|
||||
});
|
||||
|
||||
// OS tab switching for Step 1. Flips both the command panel AND the
|
||||
// P0-1 terminal-howto body so the howto matches the active OS.
|
||||
document.querySelectorAll('.os-tab[data-os-tab]').forEach(function (tab) {
|
||||
tab.addEventListener('click', function () {
|
||||
var target = tab.getAttribute('data-os-tab');
|
||||
|
|
@ -1320,9 +1573,49 @@ Ground rules: this is idempotent — safe to re-run, the precheck below short-ci
|
|||
p.setAttribute('hidden', '');
|
||||
}
|
||||
});
|
||||
// P0-1 — flip the howto bodies in lockstep.
|
||||
scope.querySelectorAll('[data-howto-panel]').forEach(function (p) {
|
||||
if (p.getAttribute('data-howto-panel') === target) {
|
||||
p.removeAttribute('hidden');
|
||||
} else {
|
||||
p.setAttribute('hidden', '');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// P0-2 — Post-CTA modal. _claude_setup_cta.jinja owns the click
|
||||
// handler that POSTs /auth/tokens + copies the script; we wait for
|
||||
// its success signal (agnes:setup-script-copied custom event) and
|
||||
// then open the "where to paste" guide. Without the event the modal
|
||||
// simply never opens — the include's own fallback paths handle
|
||||
// older browsers / blocked clipboard.
|
||||
var ctaModal = document.getElementById('cta-modal-backdrop');
|
||||
var ctaModalClose = document.getElementById('cta-modal-close');
|
||||
function openCtaModal() {
|
||||
if (!ctaModal) return;
|
||||
ctaModal.removeAttribute('hidden');
|
||||
ctaModal.classList.add('is-open');
|
||||
if (ctaModalClose) ctaModalClose.focus();
|
||||
}
|
||||
function closeCtaModal() {
|
||||
if (!ctaModal) return;
|
||||
ctaModal.classList.remove('is-open');
|
||||
ctaModal.setAttribute('hidden', '');
|
||||
var setupBtn = document.getElementById('setupClaudeBtn');
|
||||
if (setupBtn) setupBtn.focus();
|
||||
}
|
||||
if (ctaModal) {
|
||||
document.addEventListener('agnes:setup-script-copied', openCtaModal);
|
||||
if (ctaModalClose) ctaModalClose.addEventListener('click', closeCtaModal);
|
||||
ctaModal.addEventListener('click', function (ev) {
|
||||
if (ev.target === ctaModal) closeCtaModal();
|
||||
});
|
||||
document.addEventListener('keydown', function (ev) {
|
||||
if (ev.key === 'Escape' && ctaModal.classList.contains('is-open')) closeCtaModal();
|
||||
});
|
||||
}
|
||||
|
||||
var btn = document.getElementById('self-mark-btn');
|
||||
var status = document.getElementById('self-mark-status');
|
||||
if (!btn) return;
|
||||
|
|
|
|||
|
|
@ -122,18 +122,22 @@
|
|||
<div class="ttl">Catalog</div>
|
||||
<div class="desc">Browse tables, schema, and metric definitions.</div>
|
||||
</a>
|
||||
{# Activity Center + Corporate Memory both admin-only — parity
|
||||
with the top-nav gating (Memory link hidden for non-admin in
|
||||
_app_header.html, /corporate-memory route is `require_admin`,
|
||||
same for the active /home not-onboarded view). #}
|
||||
{% if is_admin %}
|
||||
<a class="quick-card" href="/activity-center">
|
||||
<span class="ico">📈</span>
|
||||
<div class="ttl">Activity Center</div>
|
||||
<div class="desc">Per-user analytics on Agnes adoption.</div>
|
||||
</a>
|
||||
{% endif %}
|
||||
<a class="quick-card" href="/corporate-memory">
|
||||
<span class="ico">🧠</span>
|
||||
<div class="ttl">Corporate Memory</div>
|
||||
<div class="desc">Shared analyst knowledge and prior solutions.</div>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if is_admin %}
|
||||
<a class="quick-card" href="/admin/server-config">
|
||||
<span class="ico">⚙️</span>
|
||||
|
|
|
|||
|
|
@ -155,3 +155,10 @@ SESSION_SECRET= # python -c "import secrets; print(secrets.token_he
|
|||
# Resolution order when unset: LOCAL_DEV_MODE=1 -> "local"; else
|
||||
# RELEASE_CHANNEL value; else AGNES_DEPLOYMENT_ENV; else "unknown".
|
||||
# POSTHOG_ENVIRONMENT=production
|
||||
|
||||
# ── INSTANCE METADATA OVERRIDES ─────────────────────
|
||||
# Operator contact shown on /home GWS connector tile as an "Email admin"
|
||||
# mailto button. Analysts whose operator hasn't pre-provisioned a shared
|
||||
# OAuth app can request one without leaving the workspace. Empty/unset
|
||||
# hides the button. YAML equivalent: instance.admin_email in instance.yaml.
|
||||
# AGNES_INSTANCE_ADMIN_EMAIL=ops@acme.com
|
||||
|
|
|
|||
|
|
@ -85,28 +85,15 @@ and use `agnes snapshot create --estimate` to size-check before fetching.
|
|||
{% if marketplaces -%}
|
||||
## Agnes Marketplace — plugins available to you
|
||||
|
||||
The Agnes server publishes a per-user **`agnes`** Claude Code marketplace,
|
||||
aggregated from upstream marketplaces and RBAC-filtered. The list below is
|
||||
your **eligibility set** — plugins your account is allowed to add to its
|
||||
stack on the `/marketplace` page. Eligibility ≠ installed.
|
||||
These plugins reach Claude Code through the per-user **`agnes`** marketplace
|
||||
served by this server (an aggregated, RBAC-filtered view of the upstream
|
||||
marketplaces below). When you install or invoke one of these plugins inside
|
||||
Claude Code, address it as `<plugin>@agnes` regardless of which upstream it
|
||||
came from — e.g. `claude plugin install <plugin>@agnes`. The
|
||||
`agnes refresh-marketplace` command (run by the SessionStart hook every
|
||||
session) keeps the local clone in sync.
|
||||
|
||||
What actually reaches Claude Code is your **served stack**, which is:
|
||||
- plugins you opted in to on `/marketplace` (from the eligibility set above),
|
||||
- plus any plugin your admin pinned as system-mandatory (auto-applied to every user),
|
||||
- plus skills / agents / plugins you installed from the **Flea market** tab on `/marketplace`.
|
||||
|
||||
When you install or invoke a stack plugin inside Claude Code, address it
|
||||
as `<plugin>@agnes` regardless of which upstream marketplace it came from
|
||||
— e.g. `claude plugin install <plugin>@agnes`.
|
||||
|
||||
Updates: the SessionStart hook runs `agnes refresh-marketplace --check`
|
||||
on every Claude Code session — it only **detects** server-side changes
|
||||
(new admin grants, new system plugins, your own `/marketplace` picks) and
|
||||
prompts you to run `/update-agnes-plugins` inside Claude Code to install
|
||||
the diff. The slash command does the full reconcile with output visible
|
||||
in the transcript; no silent auto-install at session start.
|
||||
|
||||
Upstream marketplaces folded into your `agnes` view (eligibility):
|
||||
Upstream marketplaces folded into your `agnes` view:
|
||||
{% for mp in marketplaces -%}
|
||||
- **{{ mp.name }}** ({{ mp.slug }}): {{ mp.plugins | map(attribute="name") | join(", ") }}
|
||||
{% endfor %}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,11 @@ instance:
|
|||
# logo_svg: Full <svg> element for header logo (optional, default: Keboola logo)
|
||||
# Example: '<svg width="120" height="30" viewBox="0 0 100 30" xmlns="http://www.w3.org/2000/svg"><text y="22" font-size="24" fill="#333">Logo</text></svg>'
|
||||
# sync_interval: "1 hour" # Cadence shown in analyst CLAUDE.md (e.g., "1 hour", "30 minutes", "daily")
|
||||
# admin_email: "ops@acme.com" # Operator contact shown on /home GWS connector tile as
|
||||
# an "Email admin" mailto button (analysts whose operator
|
||||
# hasn't pre-provisioned a shared OAuth app can request
|
||||
# one without leaving the workspace). Empty/unset hides
|
||||
# the button. Env override: AGNES_INSTANCE_ADMIN_EMAIL.
|
||||
|
||||
# --- Server ---
|
||||
server:
|
||||
|
|
|
|||
|
|
@ -193,11 +193,32 @@ def compute_default_agent_prompt(
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
# Resolve connector prompts via the shared module so the bash
|
||||
# script's step-9 connector block uses the same operator-side
|
||||
# config (GWS OAuth credentials, admin email) as the /home tile
|
||||
# cards. Failure here falls back to the module's default empty
|
||||
# config — the unconfigured GCP-walkthrough branch renders, which
|
||||
# is the same behaviour as today on an instance with no
|
||||
# AGNES_GWS_CLIENT_ID / AGNES_GWS_CLIENT_SECRET set.
|
||||
connector_prompts: dict[str, str] | None = None
|
||||
try:
|
||||
from app.web.connector_prompts import all_connector_prompts
|
||||
from app.instance_config import (
|
||||
get_gws_oauth_credentials, get_instance_admin_email,
|
||||
)
|
||||
connector_prompts = all_connector_prompts(
|
||||
gws_oauth=get_gws_oauth_credentials(),
|
||||
instance_admin_email=get_instance_admin_email(),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("compute_default_agent_prompt: connector prompt resolution failed; using module defaults")
|
||||
|
||||
lines = resolve_lines(
|
||||
_wheel_filename,
|
||||
plugin_install_names=plugin_install_names,
|
||||
server_host=server_host,
|
||||
ca_pem=ca_pem,
|
||||
connector_prompts=connector_prompts,
|
||||
)
|
||||
return "\n".join(lines)
|
||||
except Exception:
|
||||
|
|
|
|||
|
|
@ -59,13 +59,14 @@ class TestPendingBannerForAdmins:
|
|||
assert "awaiting review" not in body.lower()
|
||||
|
||||
|
||||
class TestNonAdminNeverSeesPendingBanner:
|
||||
def test_analyst_does_not_see_banner_even_with_pending_items(self, seeded_app):
|
||||
class TestNonAdminBlocked:
|
||||
def test_analyst_gets_403_on_corporate_memory(self, seeded_app):
|
||||
"""Corporate Memory is admin-only — both the nav link and the
|
||||
widget are hidden for non-admin in the templates, and the route
|
||||
itself rejects with 403. Banner-leakage to non-admin is moot
|
||||
because the whole page is gated."""
|
||||
_seed_pending_item("p_no_admin_1")
|
||||
c = seeded_app["client"]
|
||||
token = seeded_app["analyst_token"]
|
||||
resp = c.get("/corporate-memory", headers=_auth(token))
|
||||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
# Non-admin must not see the admin-only banner copy.
|
||||
assert "awaiting review" not in body.lower()
|
||||
assert resp.status_code == 403
|
||||
|
|
|
|||
|
|
@ -91,6 +91,36 @@ def test_root_redirect_unauthed_goes_to_login(fresh_db):
|
|||
assert resp.headers["location"] == "/login"
|
||||
|
||||
|
||||
def test_instance_admin_email_default_empty(fresh_db, monkeypatch):
|
||||
"""Unset env + unset YAML → empty string. Template branches on
|
||||
truthiness so empty hides the GWS Email-admin button cleanly."""
|
||||
monkeypatch.delenv("AGNES_INSTANCE_ADMIN_EMAIL", raising=False)
|
||||
from app.instance_config import get_instance_admin_email
|
||||
assert get_instance_admin_email() == ""
|
||||
|
||||
|
||||
def test_instance_admin_email_env_overrides(fresh_db, monkeypatch):
|
||||
"""env var takes precedence over YAML / default."""
|
||||
monkeypatch.setenv("AGNES_INSTANCE_ADMIN_EMAIL", "ops@example.com")
|
||||
from app.instance_config import get_instance_admin_email
|
||||
assert get_instance_admin_email() == "ops@example.com"
|
||||
|
||||
|
||||
def test_instance_admin_email_strips_whitespace(fresh_db, monkeypatch):
|
||||
"""Operator quoting habits ("` ops@example.com `") shouldn't break the
|
||||
mailto link — strip surrounding whitespace at the resolver."""
|
||||
monkeypatch.setenv("AGNES_INSTANCE_ADMIN_EMAIL", " ops@example.com ")
|
||||
from app.instance_config import get_instance_admin_email
|
||||
assert get_instance_admin_email() == "ops@example.com"
|
||||
|
||||
|
||||
def test_instance_admin_email_empty_env_treated_as_unset(fresh_db, monkeypatch):
|
||||
"""Empty-string env var is intentional opt-out, not garbage."""
|
||||
monkeypatch.setenv("AGNES_INSTANCE_ADMIN_EMAIL", "")
|
||||
from app.instance_config import get_instance_admin_email
|
||||
assert get_instance_admin_email() == ""
|
||||
|
||||
|
||||
def test_gws_oauth_default_unset(fresh_db, monkeypatch):
|
||||
monkeypatch.delenv("AGNES_GWS_CLIENT_ID", raising=False)
|
||||
monkeypatch.delenv("AGNES_GWS_CLIENT_SECRET", raising=False)
|
||||
|
|
@ -150,7 +180,16 @@ def test_home_renders_configured_gws_branch(fresh_db, monkeypatch):
|
|||
"""Configured branch writes ~/.config/gws/client_secret.json directly
|
||||
instead of exporting env vars. Claude Code's security layer redacts
|
||||
env vars whose name contains 'SECRET', so the file-write path is the
|
||||
only reliable way to seed the OAuth app credentials."""
|
||||
only reliable way to seed the OAuth app credentials.
|
||||
|
||||
The gws prompt body now flows through Jinja's autoescape (the template
|
||||
moved from inline `<code>` text to a `{{ connector_prompts.gws }}`
|
||||
expression after the connector-prompts extraction). That means `"`
|
||||
characters render as `"` in the served HTML — the browser
|
||||
un-escapes them on read, but the raw response body has the entity-
|
||||
encoded form. So the test un-escapes before substring-matching."""
|
||||
import html as _html
|
||||
|
||||
monkeypatch.setenv(
|
||||
"AGNES_GWS_CLIENT_ID", "123456789012-abcd5678efgh.apps.googleusercontent.com"
|
||||
)
|
||||
|
|
@ -168,7 +207,7 @@ def test_home_renders_configured_gws_branch(fresh_db, monkeypatch):
|
|||
c = _client()
|
||||
resp = c.get("/home", cookies={"access_token": sess})
|
||||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
body = _html.unescape(resp.text)
|
||||
# Configured branch — JSON file path
|
||||
assert "~/.config/gws/client_secret.json" in body
|
||||
assert '"client_id": "123456789012-abcd5678efgh.apps.googleusercontent.com"' in body
|
||||
|
|
@ -221,8 +260,10 @@ def test_home_automode_env_can_hide(fresh_db, monkeypatch):
|
|||
|
||||
|
||||
def test_home_renders_automode_block_by_default(fresh_db, monkeypatch):
|
||||
"""Step 3 — turn on auto-accept mode renders by default for the
|
||||
not-onboarded /home view."""
|
||||
"""The auto-mode step renders by default for the not-onboarded /home
|
||||
view. The block is now Step 2 (the install-flow reorder put auto-mode
|
||||
BEFORE the Agnes install so users have auto-accept on for Step 3's
|
||||
~20 commands), so its label is "Step 2 — turn on auto-mode"."""
|
||||
monkeypatch.delenv("AGNES_HOME_SHOW_AUTOMODE", raising=False)
|
||||
|
||||
from src.db import get_system_db, close_system_db
|
||||
|
|
@ -236,9 +277,11 @@ def test_home_renders_automode_block_by_default(fresh_db, monkeypatch):
|
|||
|
||||
c = _client()
|
||||
body = c.get("/home", cookies={"access_token": sess}).text
|
||||
assert "Step 3 — turn on auto-accept mode" in body
|
||||
assert '<div class="automode-card">' in body # rendered element, not CSS selector
|
||||
assert "acceptEdits" in body # ~/.claude/settings.json snippet
|
||||
assert "Step 2 — turn on auto-mode" in body
|
||||
# The auto-mode step now lives inside the install-hero as an
|
||||
# install-block (peer with Step 1 + Step 3), not as a separate
|
||||
# automode-card. Look for the label + the keystroke prompt.
|
||||
assert "Shift + Tab" in body
|
||||
|
||||
|
||||
def test_home_hides_automode_block_when_env_off(fresh_db, monkeypatch):
|
||||
|
|
@ -255,9 +298,7 @@ def test_home_hides_automode_block_when_env_off(fresh_db, monkeypatch):
|
|||
|
||||
c = _client()
|
||||
body = c.get("/home", cookies={"access_token": sess}).text
|
||||
assert "Step 3 — turn on auto-accept mode" not in body
|
||||
# HTML element absent (CSS selector with same name still in <style>, that's fine)
|
||||
assert '<div class="automode-card">' not in body
|
||||
assert "Step 2 — turn on auto-mode" not in body
|
||||
|
||||
|
||||
def test_navbar_home_link_uses_home_route(fresh_db, monkeypatch):
|
||||
|
|
|
|||
|
|
@ -49,14 +49,14 @@ def test_render_setup_instructions_wires_all_placeholders():
|
|||
|
||||
def test_resolve_lines_no_plugins_unified_layout():
|
||||
"""Unified always-on layout: 1 install, 2 init, 3 catalog, 4 preflight,
|
||||
5 marketplace, 6 mcp_servers, 7 diagnose, 8 confirm. Preflight +
|
||||
marketplace + MCP block are emitted even when the operator's served
|
||||
stack is empty — registering the per-user marketplace clone pre-wires
|
||||
Claude Code for future stack changes (admin grants, system pins,
|
||||
Flea installs), and the Atlassian Remote MCP applies to every analyst
|
||||
whose work touches Jira/Confluence. Skills step deleted — the
|
||||
interactive copy-or-on-demand question was confusing and the
|
||||
on-demand path is the one-size-fits-all default."""
|
||||
5 marketplace, 6 mcp_servers, 7 diagnose, 8 connectors, 9 confirm.
|
||||
Preflight + marketplace + MCP + connectors block are emitted even when
|
||||
the operator has zero plugin grants — registering the per-user
|
||||
marketplace clone pre-wires the SessionStart hook, the Atlassian
|
||||
Remote MCP applies to every analyst whose work touches Jira/
|
||||
Confluence, and the connectors block is per-connector default-yes
|
||||
(the user can decline each individually). Skills step deleted in
|
||||
#242."""
|
||||
from app.web.setup_instructions import resolve_lines
|
||||
|
||||
joined = "\n".join(resolve_lines("agnes.whl"))
|
||||
|
|
@ -68,19 +68,16 @@ def test_resolve_lines_no_plugins_unified_layout():
|
|||
assert "5) Register the Agnes Claude Code marketplace" in joined
|
||||
assert "6) Register the Atlassian MCP server" in joined
|
||||
assert "7) Run diagnostics:" in joined
|
||||
assert "8) Confirm:" in joined
|
||||
assert "8) Connect the user's tools" in joined
|
||||
assert "9) Confirm:" in joined
|
||||
# No stray Confirms at other positions.
|
||||
assert "9) Confirm:" not in joined
|
||||
assert "10) Confirm:" not in joined
|
||||
assert "6) Confirm:" not in joined
|
||||
# No skills step in any form.
|
||||
# Skills step is intentionally absent.
|
||||
assert "Skills (ask the user" not in joined
|
||||
assert "Skills" not in joined or "agnes skills" in joined # comment refs still OK
|
||||
assert "8) Skills" not in joined
|
||||
assert "~/.claude/skills/agnes/" not in joined
|
||||
# The marketplace step header adapts to the empty-stack copy
|
||||
# The marketplace step header adapts to "no plugins granted yet" copy
|
||||
# rather than the plugin-installing variant.
|
||||
assert "your stack is empty for now" in joined
|
||||
assert "no plugins granted yet" in joined
|
||||
assert "agnes refresh-marketplace --bootstrap" in joined
|
||||
# MCP step uses SSE transport for Atlassian's hosted Remote MCP.
|
||||
assert "claude mcp add --transport sse atlassian https://mcp.atlassian.com/v1/sse" in joined
|
||||
|
|
@ -276,18 +273,12 @@ def test_resolve_lines_with_plugins_uses_install_first_diagnose_last_layout():
|
|||
assert "brew install git" in joined
|
||||
assert "winget install --id Git.Git -e --source winget --silent" in joined
|
||||
assert "sudo apt-get install git" in joined or "sudo dnf install git" in joined
|
||||
# Step 5 — marketplace + stack install. Collapsed to a single CLI call:
|
||||
# `agnes refresh-marketplace --bootstrap` does clone + PAT-strip +
|
||||
# chmod + register-with-Claude + auto-install-from-manifest internally.
|
||||
# Pulling that out of the inline shell script avoided Claude Code's
|
||||
# agent-driven `rm -rf` permission gate that the old multi-line
|
||||
# sequence tripped on.
|
||||
assert "5) Register the Agnes Claude Code marketplace and install your current stack" in joined
|
||||
# Step 5 — marketplace + stack install.
|
||||
assert "5) Register the Agnes Claude Code marketplace" in joined
|
||||
assert "agnes refresh-marketplace --bootstrap" in joined
|
||||
# The destructive prep + per-plugin install commands are now inside
|
||||
# the CLI; the prompt must not emit the inline shell forms in
|
||||
# operator-runnable lines (comment lines documenting what the CLI
|
||||
# does internally are fine — they're prose, not commands).
|
||||
# The destructive prep + per-plugin install commands are inside the
|
||||
# CLI; the prompt must not emit the inline shell forms in
|
||||
# operator-runnable lines.
|
||||
executable = _executable_lines(joined)
|
||||
assert "rm -rf ~/.agnes/marketplace" not in executable
|
||||
assert "git clone " not in executable
|
||||
|
|
@ -295,17 +286,15 @@ def test_resolve_lines_with_plugins_uses_install_first_diagnose_last_layout():
|
|||
assert "claude plugin marketplace add" not in executable
|
||||
assert "claude plugin install foo@agnes" not in executable
|
||||
assert "claude plugin install bar@agnes" not in executable
|
||||
# Step 6 — Atlassian MCP registration.
|
||||
# Step 6 — Atlassian MCP registration (Fix C in 2026-05-10 init-report response).
|
||||
assert "6) Register the Atlassian MCP server" in joined
|
||||
# Step 7 — diagnose now AFTER marketplace + MCP wiring.
|
||||
assert "7) Run diagnostics:" in joined
|
||||
# Step 8 — Confirm.
|
||||
assert "8) Confirm:" in joined
|
||||
# No skills step in any form.
|
||||
assert "Skills (ask the user" not in joined
|
||||
assert "8) Skills" not in joined
|
||||
assert "~/.claude/skills/agnes/" not in joined
|
||||
for stray in ("4) Confirm:", "5) Confirm:", "6) Confirm:", "7) Confirm:", "9) Confirm:"):
|
||||
# Step 8 — connectors, the LAST interactive step before Confirm
|
||||
# (skills step deleted in #242).
|
||||
assert "8) Connect the user's tools" in joined
|
||||
assert "9) Confirm:" in joined
|
||||
for stray in ("4) Confirm:", "5) Confirm:", "6) Confirm:", "7) Confirm:", "8) Confirm:", "10) Confirm:"):
|
||||
assert stray not in joined
|
||||
# Crucial ordering invariants for the new layout.
|
||||
install_idx = joined.index("1) Install the CLI")
|
||||
|
|
@ -315,8 +304,9 @@ def test_resolve_lines_with_plugins_uses_install_first_diagnose_last_layout():
|
|||
market_idx = joined.index("5) Register the Agnes Claude Code marketplace")
|
||||
mcp_idx = joined.index("6) Register the Atlassian MCP server")
|
||||
diag_idx = joined.index("7) Run diagnostics:")
|
||||
confirm_idx = joined.index("8) Confirm:")
|
||||
assert install_idx < init_idx < catalog_idx < git_idx < market_idx < mcp_idx < diag_idx < confirm_idx
|
||||
conn_idx = joined.index("8) Connect the user's tools")
|
||||
confirm_idx = joined.index("9) Confirm:")
|
||||
assert install_idx < init_idx < catalog_idx < git_idx < market_idx < mcp_idx < diag_idx < conn_idx < confirm_idx
|
||||
# Legacy `git config sslVerify=false` downgrade is gone — see CHANGELOG.
|
||||
assert "git config --global" not in joined
|
||||
# server_host is server-side substituted; the placeholder must be gone.
|
||||
|
|
@ -639,14 +629,15 @@ def test_resolve_lines_ca_pem_empty_string_is_treated_as_absent():
|
|||
|
||||
|
||||
def test_resolve_lines_ca_pem_works_without_plugins():
|
||||
"""Trust block is independent of the marketplace + MCP blocks — emit
|
||||
step 0 even when plugin list is empty. Confirm step is at 8 in the
|
||||
post-skills-removal layout. Step 0 is preamble, not numbered."""
|
||||
"""Trust block is independent of the marketplace + MCP + connectors
|
||||
blocks — emit step 0 even when plugin list is empty. Confirm step is
|
||||
at 9 in the always-on layout (skills step deleted in #242, connectors
|
||||
added in #243). Step 0 is preamble, not numbered."""
|
||||
from app.web.setup_instructions import resolve_lines
|
||||
|
||||
joined = "\n".join(resolve_lines("agnes.whl", ca_pem=_FAKE_CA_PEM))
|
||||
assert "0) Trust the Agnes TLS certificate" in joined
|
||||
assert "8) Confirm:" in joined
|
||||
assert "9) Confirm:" in joined
|
||||
# Marketplace block is now emitted unconditionally; the bootstrap
|
||||
# one-liner does the `claude plugin marketplace add` internally so
|
||||
# the literal string isn't in the prompt text — the user-facing
|
||||
|
|
@ -716,18 +707,21 @@ def test_no_skills_step_emitted():
|
|||
assert "Wait for the user's answer" not in joined
|
||||
|
||||
|
||||
def test_no_plugins_layout_diagnose_before_confirm():
|
||||
"""Always-on layout (post-skills-removal):
|
||||
install → init → catalog → preflight → marketplace → mcp_servers →
|
||||
diagnose → confirm. Step numbers: 7 diagnose, 8 confirm."""
|
||||
def test_no_plugins_layout_keeps_diagnose_before_connectors():
|
||||
"""Always-on layout: install → init → catalog → preflight →
|
||||
marketplace → mcp_servers → diagnose → connectors → confirm,
|
||||
regardless of plugin grants. Step numbers: 7 diagnose, 8 connectors,
|
||||
9 confirm. Skills step deleted in #242."""
|
||||
from app.web.setup_instructions import resolve_lines
|
||||
|
||||
joined = "\n".join(resolve_lines("agnes.whl"))
|
||||
assert "7) Run diagnostics:" in joined
|
||||
assert "8) Confirm:" in joined
|
||||
assert "8) Connect the user's tools" in joined
|
||||
assert "9) Confirm:" in joined
|
||||
diag_idx = joined.index("7) Run diagnostics:")
|
||||
confirm_idx = joined.index("8) Confirm:")
|
||||
assert diag_idx < confirm_idx
|
||||
conn_idx = joined.index("8) Connect the user's tools")
|
||||
confirm_idx = joined.index("9) Confirm:")
|
||||
assert diag_idx < conn_idx < confirm_idx
|
||||
|
||||
|
||||
def test_unified_flow_uses_only_agnes_verbs():
|
||||
|
|
@ -794,3 +788,99 @@ def test_install_page_uses_versioned_wheel_url(monkeypatch, tmp_path):
|
|||
assert "/cli/wheel/agnes_the_ai_analyst-2.0.0-py3-none-any.whl" in resp.text
|
||||
# The bare alias must no longer appear in the rendered snippet.
|
||||
assert "/cli/agnes.whl" not in resp.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Connector block (step 9) — per-connector default-yes interactive asks
|
||||
# wired to verbatim prompts from app/web/connector_prompts.py.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_connectors_block_renders_all_three_asks():
|
||||
"""Step 9 must contain a default-yes ask for Asana, Google Workspace,
|
||||
and Atlassian — in that order — and inline each connector's full
|
||||
prompt body verbatim. Catches drift between the registry order and
|
||||
what the script emits."""
|
||||
from app.web.setup_instructions import resolve_lines
|
||||
|
||||
joined = "\n".join(resolve_lines("agnes.whl"))
|
||||
# All three asks with the verbatim default-yes phrasing.
|
||||
assert 'Ask: "Set up Asana now? (Y/n)"' in joined
|
||||
assert 'Ask: "Set up Google Workspace now? (Y/n)"' in joined
|
||||
assert 'Ask: "Set up Atlassian (Jira / Confluence) now? (Y/n)"' in joined
|
||||
# The default-install copy is rendered once for the whole block.
|
||||
assert "Treat empty/Enter as YES — the default is install" in joined
|
||||
# Ordering: Asana → GWS → Atlassian (matches the CONNECTORS registry).
|
||||
asana_idx = joined.index('Set up Asana now? (Y/n)')
|
||||
gws_idx = joined.index('Set up Google Workspace now? (Y/n)')
|
||||
atl_idx = joined.index('Set up Atlassian (Jira / Confluence) now? (Y/n)')
|
||||
assert asana_idx < gws_idx < atl_idx
|
||||
|
||||
|
||||
def test_connectors_block_uses_gws_configured_branch_when_oauth_set():
|
||||
"""When the operator has provisioned a shared OAuth app (client_id +
|
||||
secret), the inlined GWS prompt body skips the manual `gws auth
|
||||
setup` walkthrough and writes client_secret.json directly. That's
|
||||
the GCP-frictionless path that takes ~2 min instead of ~20."""
|
||||
from app.web.setup_instructions import resolve_lines
|
||||
from app.web.connector_prompts import all_connector_prompts
|
||||
|
||||
prompts = all_connector_prompts(gws_oauth={
|
||||
"configured": True,
|
||||
"client_id": "1234-abc.apps.googleusercontent.com",
|
||||
"client_secret": "FAKE-SECRET",
|
||||
"project_id": "1234",
|
||||
"oauthlib_insecure_transport": "1",
|
||||
})
|
||||
joined = "\n".join(resolve_lines("agnes.whl", connector_prompts=prompts))
|
||||
# Configured branch signature: the operator's literal client_id is
|
||||
# baked into the inlined client_secret.json snippet.
|
||||
assert "1234-abc.apps.googleusercontent.com" in joined
|
||||
assert "FAKE-SECRET" in joined
|
||||
# The manual `gws auth setup` walkthrough must NOT appear when the
|
||||
# configured branch is active.
|
||||
assert "Run `gws auth setup` for me" not in joined
|
||||
|
||||
|
||||
def test_connectors_block_uses_gws_manual_branch_when_oauth_unset():
|
||||
"""Inverse: when no operator OAuth credentials are provisioned, the
|
||||
inlined GWS prompt walks the user through the manual `gws auth
|
||||
setup` flow (the ~20-min GCP clickops path)."""
|
||||
from app.web.setup_instructions import resolve_lines
|
||||
from app.web.connector_prompts import all_connector_prompts
|
||||
|
||||
prompts = all_connector_prompts(gws_oauth={"configured": False})
|
||||
joined = "\n".join(resolve_lines("agnes.whl", connector_prompts=prompts))
|
||||
assert "Run `gws auth setup` for me" in joined
|
||||
# The configured-branch landmark string ("Skip `gws auth setup` entirely")
|
||||
# must NOT appear in the unconfigured branch.
|
||||
assert "Skip `gws auth setup` entirely" not in joined
|
||||
|
||||
|
||||
def test_step_numbering_with_connectors_step():
|
||||
"""_step_numbers must return diagnose=7, connectors=8, confirm=9.
|
||||
Anchors the numeric expectations the rest of the test suite assumes
|
||||
(skills step deleted in #242, connectors added in #243)."""
|
||||
from app.web.setup_instructions import _step_numbers
|
||||
|
||||
steps = _step_numbers()
|
||||
assert steps["preflight"] == "4"
|
||||
assert steps["marketplace"] == "5"
|
||||
assert steps["mcp_servers"] == "6"
|
||||
assert steps["diagnose"] == "7"
|
||||
assert steps["connectors"] == "8"
|
||||
assert steps["confirm"] == "9"
|
||||
assert "skills" not in steps # deleted in #242
|
||||
|
||||
|
||||
def test_finale_bullets_mention_connector_outcomes():
|
||||
"""The Confirm step's summary bullets must include "Which connectors
|
||||
got set up: Asana, Google Workspace, and Atlassian — installed or
|
||||
declined for each". Without this the assistant has no reason to
|
||||
summarise the per-connector ask answers in the final Confirm
|
||||
message."""
|
||||
from app.web.setup_instructions import resolve_lines
|
||||
|
||||
joined = "\n".join(resolve_lines("agnes.whl"))
|
||||
assert "Which connectors got set up" in joined
|
||||
assert "Asana, Google Workspace, and Atlassian" in joined
|
||||
assert "installed or declined for each" in joined
|
||||
|
|
|
|||
|
|
@ -37,10 +37,10 @@ def test_setup_page_renders_unified_layout(client):
|
|||
|
||||
- `agnes init` is mandatory (subsumes the old admin-only
|
||||
`agnes auth import-token` + `agnes auth whoami` pair).
|
||||
- Marketplace block is always emitted: anonymous visitors with no
|
||||
plugin grants still get the marketplace registration step so
|
||||
future stack changes land cleanly. Confirm = step 8 in the
|
||||
post-skills-removal layout.
|
||||
- Marketplace block is always emitted (Fix B in 2026-05-10
|
||||
init-report response): anonymous visitors with no plugin grants
|
||||
still get the marketplace registration step so the SessionStart
|
||||
hook is pre-wired. Confirm = step 8.
|
||||
"""
|
||||
resp = client.get("/setup", follow_redirects=True)
|
||||
assert resp.status_code == 200
|
||||
|
|
@ -49,9 +49,9 @@ def test_setup_page_renders_unified_layout(client):
|
|||
assert "agnes init" in text
|
||||
# Legacy admin-only login verbs are gone from the rendered prompt.
|
||||
assert "agnes auth import-token" not in text
|
||||
# Always-on layout (preflight + marketplace + MCP all unconditional,
|
||||
# skills removed): Confirm = step 8.
|
||||
assert "8) Confirm:" in text
|
||||
# Always-on layout (preflight + marketplace + MCP + connectors block all
|
||||
# unconditional; skills step deleted in #242): Confirm = step 9.
|
||||
assert "9) Confirm:" in text
|
||||
|
||||
|
||||
def test_setup_page_ignores_role_query_param(client):
|
||||
|
|
@ -110,11 +110,12 @@ def test_setup_page_renders_marketplace_for_user_with_grants(client, monkeypatch
|
|||
# Marketplace block marker. The per-plugin install lines moved inside
|
||||
# `agnes refresh-marketplace --bootstrap`, so we check the section
|
||||
# header + the one-liner instead of `claude plugin install <name>@agnes`.
|
||||
# Non-empty stack → "install your current stack" header variant.
|
||||
assert "Register the Agnes Claude Code marketplace and install your current stack" in text
|
||||
# Non-empty stack → "install plugins" header variant.
|
||||
assert "Register the Agnes Claude Code marketplace and install plugins" in text
|
||||
assert "agnes refresh-marketplace --bootstrap" in text
|
||||
# Post-skills-removal layout: Confirm is step 8.
|
||||
assert "8) Confirm:" in text
|
||||
# Layout shift: Confirm is now step 9 (preflight + marketplace + MCP +
|
||||
# connectors all always-on; skills step deleted in #242).
|
||||
assert "9) Confirm:" in text
|
||||
# Pre-flight is in the rendered prompt at step 4.
|
||||
assert "Make sure git and claude are installed" in text
|
||||
# Atlassian MCP registration is at step 6.
|
||||
|
|
|
|||
|
|
@ -94,20 +94,32 @@ def test_home_onboarded_user_sees_nav_hub(fresh_db):
|
|||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
assert "Welcome back" in body
|
||||
assert "Step 1 & Step 2 done" in body # completion badge
|
||||
# Banner copy updated when auto-mode moved into install-hero as a real
|
||||
# Step 2 (between Claude install and Agnes install). The completion
|
||||
# badge now names all three.
|
||||
assert "Step 1, 2 & 3 done" in body # completion badge
|
||||
assert "Mark me as offboarded" in body # offboard control visible
|
||||
# Inline Step 1 / Step 2 install-blocks are hidden post-onboarding —
|
||||
# the labels rendered inside the install-block divs go away.
|
||||
# All three inline install-blocks are hidden post-onboarding — the
|
||||
# labels rendered inside the install-block divs go away.
|
||||
assert "Step 1 — install Claude Code" not in body
|
||||
assert "Step 2 — install Agnes from inside Claude Code" not in body
|
||||
assert "Step 2 — turn on auto-mode" not in body
|
||||
assert "Step 3 — install Agnes from inside Claude Code" not in body
|
||||
|
||||
|
||||
def test_step3_and_connectors_render_flat_when_onboarded_by_default(fresh_db):
|
||||
"""Step 3 + Connect-your-tools sections must NOT auto-collapse on the
|
||||
server-side `users.onboarded=TRUE` flip. They render flat (in <details
|
||||
def test_connectors_render_flat_when_onboarded_by_default(fresh_db):
|
||||
"""Connect-your-tools section must NOT auto-collapse on the
|
||||
server-side `users.onboarded=TRUE` flip. It renders flat (in <details
|
||||
open>) by default; only an explicit user click on the in-hero
|
||||
"Minimize setup view" toggle (persisted in localStorage, not server)
|
||||
activates the collapsed bar layout."""
|
||||
activates the collapsed bar layout.
|
||||
|
||||
Auto-mode used to be a peer `setup-collapsible` section
|
||||
(`data-section="step3"`) outside the install-hero. It moved into the
|
||||
install-hero as Step 2 of the install flow (so users enable it
|
||||
BEFORE Step 3's ~20-command install runs), and the standalone
|
||||
outside-hero copy was dropped to avoid duplicating reference
|
||||
content. Onboarded users no longer see the auto-mode block at all —
|
||||
consistent with Step 1 + Step 3 also hiding post-onboarding."""
|
||||
from src.db import get_system_db, close_system_db
|
||||
|
||||
conn = get_system_db()
|
||||
|
|
@ -121,13 +133,14 @@ def test_step3_and_connectors_render_flat_when_onboarded_by_default(fresh_db):
|
|||
resp = c.get("/home", cookies={"access_token": sess})
|
||||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
# The full Step 3 + Connect-your-tools content is in the body.
|
||||
assert 'class="automode-card"' in body
|
||||
# Auto-mode no longer renders for onboarded users — both the
|
||||
# in-hero install-block and the legacy outside-hero `<details>`
|
||||
# reference card are gated `{% if not onboarded %}` / removed.
|
||||
assert 'class="automode-card"' not in body
|
||||
assert 'data-section="step3"' not in body
|
||||
assert "Step 2 — turn on auto-mode" not in body
|
||||
# Connect-your-tools section is still flat-open by default.
|
||||
assert 'class="connector-tiles"' in body
|
||||
# Each section is wrapped in <details open> so the body is visible
|
||||
# without a click. The summary is rendered but CSS-hidden until
|
||||
# the page-level data-setup-minimized="1" attribute is set.
|
||||
assert 'class="setup-collapsible" data-section="step3" open' in body
|
||||
assert 'class="setup-collapsible" data-section="connectors" open' in body
|
||||
# Server-rendered HTML never carries the data-setup-minimized
|
||||
# attribute on the .home-mock root — that's a client-side
|
||||
|
|
@ -200,3 +213,121 @@ def test_home_no_auto_transition_after_post_until_reload(fresh_db):
|
|||
post = c.get("/home", cookies={"access_token": sess})
|
||||
assert "Welcome back" in post.text # nav hub view
|
||||
assert 'class="install-block"' not in post.text
|
||||
|
||||
|
||||
# ── GWS Email-admin button render tests (admin_email knob coverage) ────────
|
||||
|
||||
|
||||
def test_home_hides_email_admin_button_when_admin_email_unset(fresh_db, monkeypatch):
|
||||
"""When ``instance.admin_email`` is unset, the GWS connector tile
|
||||
must NOT render the mailto link (template guards on truthiness;
|
||||
empty resolver value cleanly hides). Defends against a `mailto:?`
|
||||
link sneaking out as a render-time artifact."""
|
||||
monkeypatch.delenv("AGNES_INSTANCE_ADMIN_EMAIL", raising=False)
|
||||
monkeypatch.delenv("AGNES_GWS_CLIENT_ID", raising=False)
|
||||
monkeypatch.delenv("AGNES_GWS_CLIENT_SECRET", raising=False)
|
||||
from src.db import get_system_db, close_system_db
|
||||
conn = get_system_db()
|
||||
try:
|
||||
_, sess = _make_user_and_session(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
close_system_db()
|
||||
body = _client().get("/home", cookies={"access_token": sess}).text
|
||||
# No "Email admin" CTA, no mailto: link in the body.
|
||||
assert "Email admin" not in body
|
||||
assert "mailto:?" not in body # specifically, no broken empty mailto
|
||||
|
||||
|
||||
def test_home_shows_email_admin_button_when_admin_email_set_and_gws_unconfigured(
|
||||
fresh_db, monkeypatch,
|
||||
):
|
||||
"""When admin_email is set AND gws_oauth is unconfigured, the mailto
|
||||
link renders. (Both conditions required — see template guard
|
||||
``{% if not gws_oauth.configured and instance_admin_email %}``.)"""
|
||||
monkeypatch.setenv("AGNES_INSTANCE_ADMIN_EMAIL", "ops@example.com")
|
||||
monkeypatch.delenv("AGNES_GWS_CLIENT_ID", raising=False)
|
||||
monkeypatch.delenv("AGNES_GWS_CLIENT_SECRET", raising=False)
|
||||
from src.db import get_system_db, close_system_db
|
||||
conn = get_system_db()
|
||||
try:
|
||||
_, sess = _make_user_and_session(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
close_system_db()
|
||||
body = _client().get("/home", cookies={"access_token": sess}).text
|
||||
assert "Email admin" in body
|
||||
assert "mailto:ops@example.com" in body
|
||||
|
||||
|
||||
def test_home_hides_email_admin_button_when_gws_configured(fresh_db, monkeypatch):
|
||||
"""Even with admin_email set, when GWS OAuth is operator-provisioned
|
||||
(gws_oauth.configured = True), the Email-admin CTA is redundant —
|
||||
user can just connect. Template gates on `not gws_oauth.configured`."""
|
||||
monkeypatch.setenv("AGNES_INSTANCE_ADMIN_EMAIL", "ops@example.com")
|
||||
monkeypatch.setenv("AGNES_GWS_CLIENT_ID", "abc.apps.googleusercontent.com")
|
||||
monkeypatch.setenv("AGNES_GWS_CLIENT_SECRET", "GOCSPX-secret")
|
||||
from src.db import get_system_db, close_system_db
|
||||
conn = get_system_db()
|
||||
try:
|
||||
_, sess = _make_user_and_session(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
close_system_db()
|
||||
body = _client().get("/home", cookies={"access_token": sess}).text
|
||||
assert "Email admin" not in body
|
||||
|
||||
|
||||
def test_home_renders_connector_prompts_from_shared_module(fresh_db):
|
||||
"""Single source of truth check: the prompt text the /home tiles
|
||||
paste must equal the strings ``app/web/connector_prompts.py`` returns.
|
||||
The same strings are also inlined into the setup script's step 9, so
|
||||
if they ever drift the two surfaces would tell users to do different
|
||||
things — this test catches that early."""
|
||||
import html as _html
|
||||
import re
|
||||
|
||||
from src.db import get_system_db, close_system_db
|
||||
from app.web.connector_prompts import (
|
||||
asana_prompt, gws_prompt, atlassian_prompt,
|
||||
)
|
||||
from app.instance_config import (
|
||||
get_gws_oauth_credentials, get_instance_admin_email,
|
||||
)
|
||||
|
||||
conn = get_system_db()
|
||||
try:
|
||||
_, sess = _make_user_and_session(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
close_system_db()
|
||||
|
||||
c = _client()
|
||||
body = c.get("/home", cookies={"access_token": sess}).text
|
||||
|
||||
# Resolve the same gws_oauth dict the route uses so the parity check
|
||||
# exercises whichever branch (configured / manual) is active in the
|
||||
# current test environment.
|
||||
gws = get_gws_oauth_credentials()
|
||||
expected_gws = gws_prompt(
|
||||
gws_oauth_configured=bool(gws.get("configured")),
|
||||
gws_client_id=str(gws.get("client_id") or ""),
|
||||
gws_client_secret=str(gws.get("client_secret") or ""),
|
||||
gws_project_id=str(gws.get("project_id") or ""),
|
||||
oauthlib_insecure_transport=str(gws.get("oauthlib_insecure_transport") or "1"),
|
||||
instance_admin_email=get_instance_admin_email(),
|
||||
)
|
||||
|
||||
for slug, expected in (
|
||||
("asana", asana_prompt()),
|
||||
("gws", expected_gws),
|
||||
("jira", atlassian_prompt()),
|
||||
):
|
||||
m = re.search(rf'<code id="{slug}-prompt">(.*?)</code>', body, re.DOTALL)
|
||||
assert m, f"{slug}-prompt block missing from /home"
|
||||
actual = _html.unescape(m.group(1))
|
||||
assert actual == expected, (
|
||||
f"{slug}-prompt body diverged from connector_prompts module — "
|
||||
f"the home tile and setup script will paste different text. "
|
||||
f"len(home)={len(actual)} len(module)={len(expected)}"
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue