Setup-prompt + bootstrap fixes from 2026-05-10 init report (#240)

* Setup-prompt + bootstrap fixes from David's 2026-05-10 init report

Three issues from clean-machine bootstrap evidence:

1. `agnes refresh-marketplace --bootstrap` failed to recover when the
   local clone existed but Claude Code's marketplace registry had lost
   the `agnes` entry. Bootstrap path now parses
   `claude plugin marketplace list`, re-runs
   `claude plugin marketplace add ~/.agnes/marketplace` when missing,
   and treats `add` failures as fatal (was warn-and-continue, root cause
   of the cascade into "Marketplace 'agnes' not found" plugin install
   errors).

2. Setup prompt now always emits the marketplace-registration block,
   even when the operator has zero plugin grants. Pre-wires the
   SessionStart hook so future admin grants land automatically without
   re-running setup. Block copy adapts: empty list shows
   "no plugins granted yet", populated list shows "install plugins".

3. Setup prompt registers the Atlassian Remote MCP server unattended
   (`claude mcp add --transport sse atlassian
   https://mcp.atlassian.com/v1/sse`). Hosted Remote MCP, OAuth handled
   automatically by Claude Code on first use. Asana / GWS stay on the
   /home connector cards (PAT/keychain flows don't fit unattended
   bootstrap).

Confirm step nudges the user toward the /home connector cards for the
PAT-flow services. CLAUDE.md template renames the marketplace section
to "Agnes Marketplace" and documents that all plugins are addressed as
`<plugin>@agnes` regardless of upstream slug.

Layout: Confirm shifts from step 6/8 to step 9 across all variants
(preflight, marketplace, MCP all unconditional). Tests updated.

* Link Claude license options from /home install pane

Step-1 Claude install on /home pointed users to  OAuth without
explaining what to do if they don't have a Pro/Max subscription. Add
a one-line follow-up link to the plan-tier section on /setup-advanced
(new `#claude-plan` anchor) so first-time users discover the
subscription tiers rather than bouncing on the OAuth screen.

* Add idempotent + no-TLS-bypass guardrails to /home connector prompts

The Asana / Google Workspace / Atlassian connector prompts on /home
already shipped a precheck step that short-circuits when the service
is already wired, but they didn't carry the same idempotency +
surface-errors-verbatim + don't-disable-TLS-verification guardrails
the bash bootstrap prompt has. Add a one-paragraph 'Ground rules'
block at the top of each prompt so a connector failure doesn't
tempt the model into bypass workarounds, matching the same posture
David's 2026-05-10 init report flagged for the bash flow.

* skip Source: lines in marketplace registry detector

`claude plugin marketplace list` prints a `Source: <local path>` line
under each registered marketplace; the local clone almost always lives
under a path containing the marketplace name itself
(`~/.agnes/marketplace`). A naive \\bagnes\\b match over the full
stdout therefore false-positives whenever ANY unrelated marketplace
sits under `~/.agnes-…/` or similar. Filter Source: lines out before
matching so the recovery path actually re-adds when needed instead of
silently falling through to a broken `marketplace update agnes`.
Adds regression test covering the substring-only case.

* drop customer-specific tokens from CHANGELOG entries

Per CLAUDE.md vendor-agnostic OSS rule ("nothing customer-specific
... in changelogs"):
- "agnes-vrysanek.groupondev.com" -> "a private-CA Agnes deployment"
- "Groupon Marketplace / groupon-marketplace" -> "<Org> Marketplace /
  <org>-marketplace" (placeholder example)
- Removed "David flagged" attribution language; init-report context
  stays intact, just stripped of the named host + brand

---------

Co-authored-by: ZdenekSrotyr <zdenek.srotyr@keboola.com>
This commit is contained in:
Vojtech 2026-05-10 22:24:00 +04:00 committed by GitHub
parent 929520f5e1
commit 41829e8a45
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 616 additions and 124 deletions

View file

@ -10,8 +10,59 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
## [Unreleased]
### Fixed
- **`agnes refresh-marketplace --bootstrap` now recovers when the local
marketplace clone exists but Claude Code's registry has lost the
`agnes` entry** (fresh Claude Code install on the same machine, manual
`claude plugin marketplace remove agnes`, or an earlier interrupted
bootstrap). The previous behaviour skipped `_bootstrap_clone` whenever
`~/.agnes/marketplace/.git` existed and fell straight through to
`claude plugin marketplace update agnes`, which failed with
`Marketplace 'agnes' not found. Available marketplaces: claude-plugins-official`
and cascaded into per-plugin install errors. The bootstrap path now
parses `claude plugin marketplace list`, calls
`claude plugin marketplace add ~/.agnes/marketplace` when `agnes`
isn't registered, and only then proceeds with fetch + reset +
reconcile. Idempotent: a second bootstrap run with `agnes` already
registered is a no-op.
In the same path, `claude plugin marketplace add` failures are now
fatal instead of `warn:`-and-continue. The previous warn-and-continue
was the root cause of the cascade above — the operator never saw the
real error from `add`, only the downstream "Marketplace not found"
symptoms.
Source: 2026-05-10 init report from a clean-machine bootstrap
against a private-CA Agnes deployment.
### Added
- **Setup prompt always registers the `agnes` Claude Code marketplace**,
even when the operator has zero plugin grants. Registering the
per-user marketplace clone pre-wires the SessionStart hook so future
admin grants land automatically on the next Claude Code session
without re-running setup. The marketplace block's copy adapts: empty
plugin list shows "no plugins granted yet", populated list shows
"install plugins". Steps 4 (preflight) + 5 (marketplace) are now
always emitted; Confirm shifts from step 6 to step 9 across the
full layout.
- **Setup prompt registers the Atlassian Remote MCP server unattended**
via `claude mcp add --transport sse atlassian https://mcp.atlassian.com/v1/sse`
(Fix C in the 2026-05-10 init-report response). Hosted Remote MCP, so
Claude Code handles OAuth automatically the first time the operator
asks it to read a Jira ticket or Confluence page — no PAT/keychain
dance. Idempotent across re-runs (`|| true` swallows the
"server already exists" exit). Asana and Google Workspace stay on the
/home connector cards because their PAT/CLI flows don't fit an
unattended bootstrap.
- **Setup prompt's Confirm step nudges the user toward connector cards
on /home** for Asana / Google Workspace / Atlassian PAT flows that
the bash script can't automate. Surfaces the cards so analysts don't
finish bootstrap thinking they're fully wired.
- **`/update-agnes-plugins` slash command** — installed automatically by
`agnes init` into `<workspace>/.claude/commands/`. Runs
`agnes refresh-marketplace` (the chatty default mode) so the user sees
@ -71,6 +122,17 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
### Changed
- **CLAUDE.md template renames the marketplace section to
"Agnes Marketplace — plugins available to you"** and clarifies that
Claude Code addresses every plugin as `<plugin>@agnes` regardless of
upstream marketplace slug — the per-user aggregated marketplace name
is always `agnes`. Resolves the naming-drift confusion flagged in the
2026-05-10 init report (CLAUDE.md previously rendered upstream
marketplace registry names like `<Org> Marketplace` / `<org>-marketplace`
without explaining the typed name is always `agnes`). Upstream
marketplace names still render as nested bullets so admins see
what's been folded in.
- **SessionStart marketplace hook is now read-only.** The hook installed
by `agnes init` was previously `agnes refresh-marketplace --quiet`,
which performed a full fetch+reset+install cycle on every session start

View file

@ -409,15 +409,15 @@ def _diagnose_skills_lines(*, diagnose_num: str, skills_num: str) -> list[str]:
]
def _finale_lines(*, confirm_step_num: str, has_ca: bool, has_marketplace: bool) -> list[str]:
def _finale_lines(*, confirm_step_num: str, has_ca: bool) -> list[str]:
"""Final Confirm step. Bullets it asks the assistant to report on must
only reference earlier steps that were actually emitted, otherwise the
assistant either hallucinates an answer or asks the user about a
non-existent step. The CA-bundle-source bullet only makes sense when
the trust block ran (`has_ca`); the marketplace bullet only makes
sense when the marketplace block ran (`has_marketplace`). Init +
catalog + diagnose + skills + version always render, so their bullets
are unconditional."""
the trust block ran (`has_ca`). The marketplace clone bullet is
unconditional now preflight + marketplace are always emitted (Fix B
in the 2026-05-10 init-report response). Init + catalog + diagnose +
skills + version always render, so their bullets are unconditional."""
bullets = [
" - `agnes --version` output",
" - First few lines of `agnes catalog` (tables you can see)",
@ -425,17 +425,16 @@ def _finale_lines(*, confirm_step_num: str, has_ca: bool, has_marketplace: bool)
" - Confirmation that `./.claude/settings.json` contains SessionStart/End hooks",
" - The `agnes diagnose` overall status",
" - Whether skills were copied or left on-demand",
" - Confirmation that `~/.agnes/marketplace/.git/` exists "
"(the marketplace clone) and that any granted plugins installed",
" - Reminder to scroll to the connector cards on /home and connect "
"Asana / Google Workspace / Atlassian (those run separately from this script)",
]
if has_ca:
bullets.append(
" - Which CA bundle source got picked in step 0(d) "
"(system Python certifi / system curl bundle / uv-fetched)"
)
if has_marketplace:
bullets.append(
" - Confirmation that `~/.agnes/marketplace/.git/` exists "
"(the marketplace clone) and that all requested plugins installed"
)
return [
f"{confirm_step_num}) Confirm:",
" Tell me \"Agnes workspace is ready\" and summarize:",
@ -494,7 +493,13 @@ def _marketplace_block(
) -> list[str]:
"""Build the marketplace + plugin-install block.
Pre-condition: `plugin_install_names` is non-empty (caller checks).
`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
@ -548,16 +553,42 @@ def _marketplace_block(
``/data/state/certs/fullchain.pem``) so step 0 can read it via
``_read_agnes_ca_pem``.
"""
has_plugins = bool(plugin_install_names)
header = (
"Register the Agnes Claude Code marketplace and install plugins:"
if has_plugins
else "Register the Agnes Claude Code marketplace (no plugins granted yet):"
)
bullet_5 = (
" # 5. install every plugin listed in the served manifest"
if has_plugins
else " # 5. (no plugins to install — your account has zero grants)"
)
if has_plugins:
trailer = [
" These run non-interactively. After they finish, tell the user to /exit",
" and run `claude` again so the new plugins load. From then on, the",
" SessionStart hook keeps the marketplace clone in sync via",
" `agnes refresh-marketplace --quiet` on every Claude Code session.",
]
else:
trailer = [
" Your account has no plugin grants right now, but registering the",
" marketplace anyway pre-wires the SessionStart hook. When an admin",
" grants you a plugin later, `agnes refresh-marketplace` (run by the",
" hook on every Claude Code session) will install it automatically —",
" no need to re-run this setup script.",
]
return [
"",
f"{step_num}) Register the Agnes Claude Code marketplace and install plugins:",
f"{step_num}) {header}",
" # `agnes refresh-marketplace --bootstrap` does:",
" # 1. clone the per-user marketplace bare repo to ~/.agnes/marketplace",
" # 2. strip the PAT from the cloned origin URL (refreshes use a",
" # per-invocation git credential helper, not the URL)",
" # 3. best-effort chmod 700/600 on POSIX (no-op on Windows NTFS)",
" # 4. `claude plugin marketplace add ~/.agnes/marketplace`",
" # 5. install every plugin listed in the served manifest",
bullet_5,
" # Idempotent — re-runs over an existing clone do fetch+reset+reconcile",
" # via the same path the SessionStart hook uses.",
" agnes refresh-marketplace --bootstrap || {",
@ -565,10 +596,47 @@ def _marketplace_block(
" exit 1",
" }",
"",
" These run non-interactively. After they finish, tell the user to /exit",
" and run `claude` again so the new plugins load. From then on, the",
" SessionStart hook keeps the marketplace clone in sync via",
" `agnes refresh-marketplace --quiet` on every Claude Code session.",
*trailer,
]
def _mcp_servers_block(step_num: str) -> list[str]:
"""Register the Atlassian Remote MCP unattended.
Why only Atlassian here:
- Atlassian publishes a hosted SSE MCP at
https://mcp.atlassian.com/v1/sse with OAuth handled by Claude Code
automatically on first tool call. No PAT/keychain dance, no
per-user setup beyond clicking through OAuth once when an
operator first asks Claude to read a Jira ticket. Safe to
register unattended in the bootstrap script.
- Asana and Google Workspace need PAT/keychain flows that don't
survive non-interactive bootstrap, so those stay on the /home
connector cards (operator-driven).
Idempotent across re-runs: `claude mcp add` returns non-zero when the
server name already exists, so we soft-fail with `|| true` and a
one-line note rather than tripping the `set -e` style operators
sometimes wrap the prompt in. Subsequent runs of the prompt are
no-ops.
Reference: 2026-05-10 init-report David's `claude mcp list` showed
only the pre-existing claude.ai Drive connector; Atlassian/Asana/GWS
weren't registered because the prompt had zero `claude mcp add`
lines. Fix C in the response plan.
"""
return [
"",
f"{step_num}) Register the Atlassian MCP server (Jira + Confluence on demand):",
" # Hosted Remote MCP — Claude Code handles OAuth automatically the",
" # first time you ask it to read a Jira ticket or Confluence page.",
" # Idempotent: re-runs are a no-op (the `|| true` swallows the",
" # \"server already exists\" error from `claude mcp add`).",
" claude mcp add --transport sse atlassian https://mcp.atlassian.com/v1/sse || true",
"",
" Asana and Google Workspace use per-user PAT / CLI flows that don't",
" fit an unattended bootstrap — connect those from the /home connector",
" cards after this script finishes.",
]
@ -602,30 +670,37 @@ def _preamble_lines(*, has_ca: bool) -> list[str]:
return lines
def _step_numbers(*, has_marketplace: bool, has_skills: bool = True) -> dict[str, str]:
"""Compute the step numbers for the unified layout based on which optional
blocks are emitted.
def _step_numbers(*, has_skills: bool = True) -> dict[str, str]:
"""Compute the step numbers for the unified layout.
Returns a dict keyed by logical step name; values are stringified
1-based step numbers (preserving the existing string-based helper API
so call sites stay diff-minimal).
Mandatory steps (always emitted): install (1), init (2), catalog (3),
diagnose, confirm. Optional: preflight + marketplace (gated on
has_marketplace), skills (gated on has_skills default True; the
Resolved-Question section in the plan settled on always-on, so the
parameter is here purely to keep the helper general for future use,
not to expose a real toggle).
preflight (4), marketplace (5), mcp_servers (6), diagnose (7), skills
(8), confirm (9). Preflight + marketplace + mcp_servers 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.
`has_skills` is kept as a parameter for future flexibility; default
True (the Resolved-Question section in the original plan settled on
always-on).
Step-0 (TLS trust block) sits outside this numbering it is gated by
has_ca and has its own "0)" header rendered inside the trust block
helper.
"""
n = 4
preflight = marketplace = ""
if has_marketplace:
preflight = str(n); n += 1
marketplace = str(n); n += 1
mcp_servers = str(n); n += 1
diagnose = str(n); n += 1
skills = str(n) if has_skills else ""
if has_skills:
@ -634,6 +709,7 @@ def _step_numbers(*, has_marketplace: bool, has_skills: bool = True) -> dict[str
return {
"preflight": preflight,
"marketplace": marketplace,
"mcp_servers": mcp_servers,
"diagnose": diagnose,
"skills": skills,
"confirm": confirm,
@ -671,15 +747,16 @@ def resolve_lines(
and diagnose the missing wheel on the server.
"""
names = list(plugin_install_names or [])
has_marketplace = bool(names)
has_ca = bool(ca_pem and ca_pem.strip())
# Step layout. Marketplace (when emitted) goes BEFORE diagnose/skills,
# so the human-loop skills question is the last step before Confirm.
# `_step_numbers` returns the renumbered step labels in one place — no
# branch on every helper — so the layout is unambiguous and trivially
# extendable when a future step is added.
steps = _step_numbers(has_marketplace=has_marketplace, has_skills=True)
# Step layout. Preflight + marketplace go BEFORE diagnose/skills so the
# human-loop skills question is the last step before Confirm. Both are
# always emitted: the marketplace registration is useful even with zero
# plugin grants (the SessionStart hook reconciles future admin grants
# automatically without re-running setup). `_step_numbers` returns the
# renumbered step labels in one place — no branch on every helper — so
# the layout is unambiguous and trivially extendable.
steps = _step_numbers(has_skills=True)
lines: list[str] = []
if has_ca:
@ -687,11 +764,10 @@ def resolve_lines(
lines.extend(_preamble_lines(has_ca=has_ca))
lines.extend(_install_cli_lines(has_ca=has_ca)) # 1
lines.extend(_init_lines()) # 2, 3
if has_marketplace:
lines.extend(_preflight_block(steps["preflight"])) # 4
lines.extend(_marketplace_block(names, step_num=steps["marketplace"])) # 5
# Diagnose + skills come AFTER the marketplace block (or right after
# the catalog smoke verify if there's no marketplace step at all).
lines.extend(_mcp_servers_block(steps["mcp_servers"])) # 6
# Diagnose + skills come AFTER marketplace + MCP wiring.
lines.extend(_diagnose_skills_lines(
diagnose_num=steps["diagnose"], skills_num=steps["skills"],
))
@ -699,7 +775,6 @@ def resolve_lines(
lines.extend(_finale_lines(
confirm_step_num=steps["confirm"],
has_ca=has_ca,
has_marketplace=has_marketplace,
))
return [

View file

@ -920,6 +920,7 @@
</div>
<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>
@ -1050,7 +1051,10 @@
<button class="connector-copy" data-copy-target="asana-prompt">Copy prompt</button>
<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:
<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&gt;/dev/null) &amp;&amp; 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."' &amp;&amp; exit 0`. Linux: `t=$(secret-tool lookup service agnes-asana-pat username "$USER" 2&gt;/dev/null) &amp;&amp; ...same curl...`. Windows PowerShell: `$cred = cmdkey /list:agnes-asana-pat 2&gt;$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.
@ -1081,6 +1085,8 @@
<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}' &amp;&amp; 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 &lt;email from `gws auth status`&gt; — Drive + Chat scopes verified. Skipping setup." and STOP. If `gws drive` succeeds but `gws chat` fails with 403/PERMISSION_DENIED, the user authed without `--full` previously — skip to step 6 (re-login with widened scopes), do NOT re-install. Only walk steps 15 (install + OAuth client setup) when `command -v gws` itself fails.
@ -1164,7 +1170,10 @@ YOU run every command via your Bash tool. Do NOT print install commands and ask
<button class="connector-copy" data-copy-target="jira-prompt">Copy prompt</button>
<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:
<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 ] &amp;&amp; . ~/.claude/agnes/secrets.env &amp;&amp; t=$(security find-generic-password -s 'agnes-atlassian-api-token' -a "$ATLASSIAN_EMAIL" -w 2&gt;/dev/null) &amp;&amp; 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."' &amp;&amp; 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://&lt;myorg&gt;.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".

View file

@ -544,7 +544,7 @@ Add-Content $PROFILE 'function yolo { claude --dangerously-skip-permissions @arg
<section class="ad-section" id="tips">
<h2>8. Tips, cost &amp; troubleshooting</h2>
<h3>Claude plan tier</h3>
<h3 id="claude-plan">Claude plan tier</h3>
<table class="ad-table">
<tr><th>Plan</th><th>Price</th><th>Best for</th></tr>
<tr><td><strong>Pro</strong></td><td>$20/mo</td><td>Trying Agnes — base limits, resets every 58h. Will hit limits within first week of real use.</td></tr>

View file

@ -27,6 +27,7 @@ from __future__ import annotations
import json
import os
import re
import shutil
import subprocess
from pathlib import Path
@ -113,6 +114,17 @@ def refresh_marketplace(
if not clone_exists:
if not _bootstrap_clone(token):
raise typer.Exit(1)
elif bootstrap:
# Clone survived but Claude Code's registry may not list `agnes`
# — fresh Claude Code install on the same box, manual
# `claude plugin marketplace remove`, or an earlier interrupted
# bootstrap that warn-and-continued past the add step. The
# `--bootstrap` contract is "after this returns, plugins work";
# ensure the registration is current before we fall through to
# `claude plugin marketplace update agnes`, which would otherwise
# fail with "Marketplace 'agnes' not found".
if not _ensure_marketplace_registered():
raise typer.Exit(1)
# --check: lightweight detector. Don't fetch+reset, don't reconcile
# plugins — that's the slash command's job. Just check whether the
@ -223,25 +235,104 @@ def _bootstrap_clone(token: str) -> bool:
except OSError:
pass
if shutil.which("claude") is not None:
if not _register_clone_with_claude(CLONE_DIR):
return False
typer.echo(f"Marketplace bootstrapped at {CLONE_DIR}.")
return True
def _register_clone_with_claude(clone_dir: Path) -> bool:
"""Call `claude plugin marketplace add <clone_dir>` and treat failures as fatal.
Soft-passes when `claude` is not on PATH so workspaces without Claude
Code installed (CI, sandbox) still complete the clone step. When
`claude` IS available, a non-zero exit from `add` is fatal: continuing
silently is the bug captured in David's 2026-05-10 init report — the
subsequent `claude plugin marketplace update agnes` (and every plugin
install) blew up with "Marketplace 'agnes' not found" because the add
step had silently warned-and-continued. Returning False here lets the
caller exit non-zero with the actual `add` stderr, which is the signal
the operator needs to fix their machine state.
"""
if shutil.which("claude") is None:
return True
add = subprocess.run(
["claude", "plugin", "marketplace", "add", str(CLONE_DIR)],
["claude", "plugin", "marketplace", "add", str(clone_dir)],
capture_output=True, text=True, encoding="utf-8", errors="replace", check=False,
)
if add.returncode != 0:
typer.echo(
f"warn: `claude plugin marketplace add {CLONE_DIR}` exited {add.returncode}.",
f"error: `claude plugin marketplace add {clone_dir}` exited {add.returncode}.",
err=True,
)
if add.stderr:
typer.echo(add.stderr.rstrip(), err=True)
elif add.stdout:
return False
if add.stdout:
typer.echo(add.stdout.rstrip())
typer.echo(f"Marketplace bootstrapped at {CLONE_DIR}.")
return True
def _claude_marketplace_is_registered() -> bool:
"""Return True iff Claude Code already has MARKETPLACE_NAME in its registry.
Parses `claude plugin marketplace list` text output. The CLI doesn't
expose a --json flag for that subcommand at time of writing, so we
match the marketplace name as a whole word in stdout. Returns False
when `claude` is missing or the command itself fails callers treat
that as "not registered" and run the add path, which is the correct
fail-safe (worst case: a redundant add that itself errors out cleanly).
"""
if shutil.which("claude") is None:
return False
try:
result = subprocess.run(
["claude", "plugin", "marketplace", "list"],
capture_output=True, text=True, encoding="utf-8", errors="replace", check=False,
)
except FileNotFoundError:
return False
if result.returncode != 0:
return False
# Filter out `Source: …` lines before matching: the CLI prints the
# local clone path there (e.g. `Source: Local path (~/.agnes/marketplace)`),
# so a naive `\bagnes\b` over the full stdout false-positives whenever
# ANY registered marketplace happens to live under a path containing
# the marketplace name. We only care about the registry headers.
relevant = "\n".join(
line for line in (result.stdout or "").splitlines()
if not line.lstrip().startswith("Source:")
)
pattern = re.compile(rf"\b{re.escape(MARKETPLACE_NAME)}\b")
return bool(pattern.search(relevant))
def _ensure_marketplace_registered() -> bool:
"""Make sure Claude Code has the cloned marketplace registered.
Used by the `--bootstrap` recovery path when CLONE_DIR already exists
but the Claude Code marketplace registry doesn't list `agnes` (fresh
Claude Code install on the same machine, manual `claude plugin
marketplace remove agnes`, or any other state where the clone survived
but the registration didn't). Idempotent — re-registering an already-
registered marketplace short-circuits in `_claude_marketplace_is_registered`.
Returns False only when registration was needed and failed; True when
registration was already in place OR `claude` is not on PATH (the
latter matches `_register_clone_with_claude`'s soft-pass behavior).
"""
if shutil.which("claude") is None:
return True
if _claude_marketplace_is_registered():
return True
typer.echo(
f"Claude Code does not have `{MARKETPLACE_NAME}` registered; "
f"running `claude plugin marketplace add {CLONE_DIR}`..."
)
return _register_clone_with_claude(CLONE_DIR)
def _git_fetch_only(token: str) -> bool:
"""Fetch from origin without resetting the working tree.

View file

@ -65,7 +65,17 @@ and `agnes init` will overwrite local edits — put personal notes into
and use `agnes snapshot create --estimate` to size-check before fetching.
{% if marketplaces -%}
## Plugins available to you
## Agnes Marketplace — plugins available to you
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.
Upstream marketplaces folded into your `agnes` view:
{% for mp in marketplaces -%}
- **{{ mp.name }}** ({{ mp.slug }}): {{ mp.plugins | map(attribute="name") | join(", ") }}
{% endfor %}

View file

@ -835,3 +835,207 @@ def test_check_and_bootstrap_are_mutually_exclusive(
result = runner.invoke(refresh_marketplace_app, ["--check", "--bootstrap"])
assert result.exit_code == 2
assert recorder.calls == []
# --- --bootstrap recovery: clone-exists-but-CC-not-registered -------------------
def test_bootstrap_recovers_when_clone_exists_but_cc_marketplace_missing(
with_clone, with_token, claude_in_path, recorder, monkeypatch, tmp_path,
):
"""Clone survived but Claude Code's registry doesn't list `agnes`
(fresh Claude Code install on the same box, manual remove, etc.).
`--bootstrap` must re-register the clone with `claude plugin
marketplace add CLONE_DIR` BEFORE falling through to fetch+reset+
`marketplace update agnes` otherwise the update fails with
"Marketplace 'agnes' not found", which is the bug from David's
2026-05-10 init report."""
workspace = tmp_path / "ws"
workspace.mkdir()
monkeypatch.chdir(workspace)
# `claude plugin marketplace list` returns ONLY the upstream Anthropic
# marketplace — no `agnes` entry. This is the state on a clean Claude
# Code install where the prior `agnes` registration got wiped.
recorder.script(
("claude", "plugin", "marketplace", "list"),
stdout=(
"Configured marketplaces:\n"
"\n"
" claude-plugins-official\n"
" Source: GitHub (anthropics/claude-plugins-official)\n"
),
)
result = runner.invoke(refresh_marketplace_app, ["--bootstrap"])
assert result.exit_code == 0, result.output
add_calls = [
c for c in recorder.calls
if c.cmd[:4] == ["claude", "plugin", "marketplace", "add"]
]
assert len(add_calls) == 1, (
f"--bootstrap with existing clone but missing CC registration must "
f"call `claude plugin marketplace add`; got: {[c.cmd for c in recorder.calls]!r}"
)
assert add_calls[0].cmd[4] == str(with_clone)
def test_bootstrap_skips_register_when_cc_marketplace_already_present(
with_clone, with_token, claude_in_path, recorder, monkeypatch, tmp_path,
):
"""Clone exists AND Claude Code already has `agnes` registered →
`--bootstrap` must NOT re-add (idempotent). A redundant add would
surface the `Marketplace 'agnes' already exists` error and abort
the recovery path uselessly."""
workspace = tmp_path / "ws"
workspace.mkdir()
monkeypatch.chdir(workspace)
recorder.script(
("claude", "plugin", "marketplace", "list"),
stdout=(
"Configured marketplaces:\n"
"\n"
" agnes\n"
" Source: Local path (/Users/x/.agnes/marketplace)\n"
" claude-plugins-official\n"
" Source: GitHub (anthropics/claude-plugins-official)\n"
),
)
result = runner.invoke(refresh_marketplace_app, ["--bootstrap"])
assert result.exit_code == 0, result.output
add_calls = [
c for c in recorder.calls
if c.cmd[:4] == ["claude", "plugin", "marketplace", "add"]
]
assert add_calls == [], (
f"--bootstrap must not re-add when `agnes` is already registered; "
f"got: {[c.cmd for c in add_calls]!r}"
)
def test_bootstrap_does_not_false_positive_on_source_path_substring(
with_clone, with_token, claude_in_path, recorder, monkeypatch, tmp_path,
):
"""Regression: registry detector must not match the marketplace name
when it appears only inside a `Source: ` line of an UNRELATED
marketplace. Real-world trigger: an earlier `claude plugin marketplace
add ~/.agnes/some-other-clone` registers a different marketplace whose
Source line still mentions `.agnes`, which a naive `\\bagnes\\b` over
the full stdout would treat as `agnes` already registered. Recovery
path then skips the add and falls through to a guaranteed-broken
`marketplace update agnes`."""
workspace = tmp_path / "ws"
workspace.mkdir()
monkeypatch.chdir(workspace)
# `agnes` (our marketplace name) appears ONLY in the Source path,
# never as a registered marketplace header. Recovery must add it.
recorder.script(
("claude", "plugin", "marketplace", "list"),
stdout=(
"Configured marketplaces:\n"
"\n"
" third-party-fork\n"
" Source: Local path (/Users/x/.agnes-related/marketplace)\n"
" claude-plugins-official\n"
" Source: GitHub (anthropics/claude-plugins-official)\n"
),
)
result = runner.invoke(refresh_marketplace_app, ["--bootstrap"])
assert result.exit_code == 0, result.output
add_calls = [
c for c in recorder.calls
if c.cmd[:4] == ["claude", "plugin", "marketplace", "add"]
]
assert len(add_calls) == 1, (
f"--bootstrap must not be fooled by `agnes` substring inside an "
f"unrelated `Source:` line; expected one add call, got: "
f"{[c.cmd for c in recorder.calls]!r}"
)
assert add_calls[0].cmd[4] == str(with_clone)
def test_bootstrap_marketplace_add_failure_is_fatal_on_fresh_clone(
tmp_path, monkeypatch, with_token, claude_in_path, recorder,
):
"""`claude plugin marketplace add` failure during fresh-clone bootstrap
must be fatal silent warn-and-continue is the bug that caused David's
init report to cascade into 4× `Marketplace 'agnes' not found` plugin
install errors. Returning non-zero with the actual `add` stderr is the
signal operators need to fix their machine state."""
cfg_dir = tmp_path / "_cfg"
(cfg_dir / "config.yaml").write_text(
"server: https://agnes.example.com\n", encoding="utf-8",
)
clone_target = tmp_path / "fresh_marketplace"
monkeypatch.setattr(rm_module, "CLONE_DIR", clone_target)
real_run = recorder.run
def fake_run(cmd, *args, **kwargs):
if cmd[:2] == ["git", "clone"]:
(clone_target / ".git").mkdir(parents=True, exist_ok=True)
(clone_target / ".claude-plugin").mkdir(parents=True, exist_ok=True)
(clone_target / ".claude-plugin" / "marketplace.json").write_text(
json.dumps({"name": "agnes", "plugins": []}),
encoding="utf-8",
)
return real_run(cmd, *args, **kwargs)
monkeypatch.setattr(rm_module.subprocess, "run", fake_run)
recorder.script(
("claude", "plugin", "marketplace", "add"),
returncode=1,
stderr="error: filesystem path is not readable",
)
result = runner.invoke(refresh_marketplace_app, ["--bootstrap"])
assert result.exit_code == 1, result.output
# Fetch+reset must NOT have run after the fatal add failure.
fetch_calls = [c for c in recorder.calls if "fetch" in c.cmd and "origin" in c.cmd]
assert fetch_calls == [], (
f"bootstrap must abort on `add` failure; fetch should not run, got: "
f"{[c.cmd for c in fetch_calls]!r}"
)
def test_bootstrap_recovery_add_failure_is_fatal_on_existing_clone(
with_clone, with_token, claude_in_path, recorder, monkeypatch, tmp_path,
):
"""When the recovery path (clone exists, CC registry empty) tries to
re-add and `claude plugin marketplace add` fails, exit non-zero
instead of pressing on to a guaranteed-broken `marketplace update`."""
workspace = tmp_path / "ws"
workspace.mkdir()
monkeypatch.chdir(workspace)
recorder.script(
("claude", "plugin", "marketplace", "list"),
stdout="Configured marketplaces:\n\n claude-plugins-official\n",
)
recorder.script(
("claude", "plugin", "marketplace", "add"),
returncode=1,
stderr="error: not a directory",
)
result = runner.invoke(refresh_marketplace_app, ["--bootstrap"])
assert result.exit_code == 1
# `marketplace update agnes` must NOT have run — that's the cascade we're
# cutting off.
update_calls = [
c for c in recorder.calls
if c.cmd[:4] == ["claude", "plugin", "marketplace", "update"]
]
assert update_calls == [], (
f"recovery must abort before `marketplace update` when add fails; got: "
f"{[c.cmd for c in update_calls]!r}"
)

View file

@ -47,9 +47,14 @@ def test_render_setup_instructions_wires_all_placeholders():
assert "T-123" in out
def test_resolve_lines_no_plugins_unified_six_step_layout():
"""Unified no-plugin layout: 1 install, 2 init, 3 catalog, 4 diagnose,
5 skills, 6 confirm. No marketplace, no preflight."""
def test_resolve_lines_no_plugins_unified_layout():
"""Unified always-on layout (Fix B + Fix C in 2026-05-10 init-report
response): 1 install, 2 init, 3 catalog, 4 preflight, 5 marketplace,
6 mcp_servers, 7 diagnose, 8 skills, 9 confirm. Preflight +
marketplace + MCP block are emitted even when the operator has zero
plugin grants registering the per-user marketplace clone pre-wires
the SessionStart hook, and the Atlassian Remote MCP applies to every
analyst whose work touches Jira/Confluence."""
from app.web.setup_instructions import resolve_lines
joined = "\n".join(resolve_lines("agnes.whl"))
@ -57,28 +62,29 @@ def test_resolve_lines_no_plugins_unified_six_step_layout():
assert "1) Install the CLI" in joined
assert "2) Bootstrap your Agnes workspace" in joined
assert "3) Verify the data is queryable:" in joined
assert "4) Run diagnostics:" in joined
assert "5) Skills" in joined
assert "6) Confirm:" in joined
assert "4) Make sure git and claude are installed" in joined
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) Skills" in joined
assert "9) Confirm:" in joined
# No stray Confirms at other positions.
assert "7) Confirm:" not in joined
assert "8) Confirm:" not in joined
assert "claude plugin marketplace add" not in joined
assert "claude plugin install" not in joined
# No preflight step when there's no marketplace block to gate.
assert "Make sure git and claude are installed" not in joined
assert "10) Confirm:" not in joined
assert "6) Confirm:" not in joined
# The marketplace step header adapts to "no plugins granted yet" copy
# rather than the plugin-installing variant.
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
# Legacy `git config sslVerify=false` downgrade must NOT be emitted.
# Match the specific config line, not the bare substring (which appears
# in the preamble as a "don't do this" example).
assert "git config --global" not in joined
# Trust block isn't emitted without ca_pem either.
assert "0) Trust the Agnes TLS certificate" not in joined
# Confirm step's CA bundle / marketplace bullets must NOT appear when
# those steps weren't emitted — otherwise the assistant is told to
# report on phantom steps.
assert "step 0(d)" not in joined
assert "Which CA bundle source got picked" not in joined
assert "~/.agnes/marketplace/.git/" not in joined
# Legacy admin-only auth verbs are gone — `agnes init` subsumes them.
assert "agnes auth import-token" not in joined
assert "agnes auth whoami" not in joined
@ -108,8 +114,10 @@ def test_preamble_step_zero_d_reference_only_when_trust_block_emitted():
def test_finale_bullets_match_emitted_steps():
"""The Confirm step's bullets must reference only steps that were
actually emitted. CA bundle bullet only when has_ca=True; marketplace
clone bullet only when plugins are configured."""
actually emitted. CA bundle bullet is gated on `has_ca`. The
marketplace clone bullet is unconditional now (Fix B in 2026-05-10
init-report response: marketplace block is always emitted regardless
of plugin grants)."""
from app.web.setup_instructions import resolve_lines
fake_ca = (
@ -118,15 +126,15 @@ def test_finale_bullets_match_emitted_steps():
"-----END CERTIFICATE-----\n"
)
# No ca, no plugins: neither bullet present.
# No ca, no plugins: marketplace bullet present, CA bullet absent.
plain = "\n".join(resolve_lines("agnes.whl"))
assert "Which CA bundle source got picked" not in plain
assert "~/.agnes/marketplace/.git/" not in plain
assert "~/.agnes/marketplace/.git/" in plain
# ca only: CA bullet yes, marketplace bullet no.
# ca only: both bullets present.
ca_only = "\n".join(resolve_lines("agnes.whl", ca_pem=fake_ca))
assert "Which CA bundle source got picked" in ca_only
assert "~/.agnes/marketplace/.git/" not in ca_only
assert "~/.agnes/marketplace/.git/" in ca_only
# plugins only: marketplace bullet yes, CA bullet no.
pl_only = "\n".join(
@ -279,13 +287,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 — diagnose now AFTER marketplace (used to be step 4 right after whoami).
assert "6) Run diagnostics:" in joined
# Step 7 — skills, the last interactive step before Confirm.
assert "7) Skills" in joined
# Step 8 — Confirm renumbered (no stray Confirms at other positions).
assert "8) Confirm:" in joined
for stray in ("4) Confirm:", "5) Confirm:", "6) Confirm:", "7) Confirm:"):
# 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 — skills, the last interactive step before Confirm.
assert "8) Skills" in joined
# Step 9 — Confirm renumbered (no stray Confirms at other positions).
assert "9) Confirm:" in joined
for stray in ("4) Confirm:", "5) Confirm:", "6) Confirm:", "7) Confirm:", "8) Confirm:"):
assert stray not in joined
# Crucial ordering invariants for the new layout.
install_idx = joined.index("1) Install the CLI")
@ -293,10 +303,11 @@ def test_resolve_lines_with_plugins_uses_install_first_diagnose_last_layout():
catalog_idx = joined.index("3) Verify the data is queryable:")
git_idx = joined.index("4) Make sure git and claude are installed")
market_idx = joined.index("5) Register the Agnes Claude Code marketplace")
diag_idx = joined.index("6) Run diagnostics:")
skills_idx = joined.index("7) Skills")
confirm_idx = joined.index("8) Confirm:")
assert install_idx < init_idx < catalog_idx < git_idx < market_idx < diag_idx < skills_idx < confirm_idx
mcp_idx = joined.index("6) Register the Atlassian MCP server")
diag_idx = joined.index("7) Run diagnostics:")
skills_idx = joined.index("8) Skills")
confirm_idx = joined.index("9) Confirm:")
assert install_idx < init_idx < catalog_idx < git_idx < market_idx < mcp_idx < diag_idx < skills_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.
@ -619,15 +630,20 @@ 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 block — emit step 0
even when plugin list is empty. Confirm step number stays at 6
(the original layout) since step 0 is preamble, not numbered."""
"""Trust block is independent of the marketplace + MCP blocks — emit
step 0 even when plugin list is empty. Confirm step is at 9 in the
always-on layout (Fix B + Fix C in 2026-05-10 init-report response).
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 "6) Confirm:" in joined
assert "claude plugin marketplace add" not 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
# invocation is `agnes refresh-marketplace --bootstrap`.
assert "agnes refresh-marketplace --bootstrap" in joined
def test_render_setup_instructions_propagates_ca_pem():
@ -697,19 +713,20 @@ def test_skills_step_is_last_blocking_step_before_confirm():
assert market_idx < skills_idx
def test_no_marketplace_layout_keeps_diagnose_before_skills():
"""Without plugins, the layout collapses to: install → init → catalog →
diagnose skills confirm. (No preflight or marketplace steps to
interleave.) Step numbers: 4 diagnose, 5 skills, 6 confirm."""
def test_no_plugins_layout_keeps_diagnose_before_skills():
"""Always-on layout (Fix B + Fix C in 2026-05-10 init-report response):
install init catalog preflight marketplace mcp_servers
diagnose skills confirm, regardless of plugin grants. Step
numbers: 7 diagnose, 8 skills, 9 confirm."""
from app.web.setup_instructions import resolve_lines
joined = "\n".join(resolve_lines("agnes.whl"))
assert "4) Run diagnostics:" in joined
assert "5) Skills" in joined
assert "6) Confirm:" in joined
diag_idx = joined.index("4) Run diagnostics:")
skills_idx = joined.index("5) Skills")
confirm_idx = joined.index("6) Confirm:")
assert "7) Run diagnostics:" in joined
assert "8) Skills" in joined
assert "9) Confirm:" in joined
diag_idx = joined.index("7) Run diagnostics:")
skills_idx = joined.index("8) Skills")
confirm_idx = joined.index("9) Confirm:")
assert diag_idx < skills_idx < confirm_idx

View file

@ -37,8 +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).
- Anonymous visitors with no plugin grants get the no-marketplace
layout (Confirm = step 6).
- 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
@ -47,8 +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
# No-marketplace layout: Confirm = step 6.
assert "6) Confirm:" in text
# Always-on layout (preflight + marketplace + MCP block all unconditional):
# Confirm = step 9.
assert "9) Confirm:" in text
def test_setup_page_ignores_role_query_param(client):
@ -108,10 +111,13 @@ def test_setup_page_renders_marketplace_for_user_with_grants(client, monkeypatch
# header + the one-liner instead of `claude plugin install <name>@agnes`.
assert "Register the Agnes Claude Code marketplace" in text
assert "agnes refresh-marketplace --bootstrap" in text
# Layout shift: Confirm is now step 8 (was 6 without marketplace).
assert "8) Confirm:" in text
# Layout shift: Confirm is now step 9 (preflight + marketplace + MCP all
# always-on per Fix B + Fix C in 2026-05-10 init-report response).
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.
assert "claude mcp add --transport sse atlassian" in text
def test_install_legacy_path_redirects_to_setup(client):

View file

@ -187,11 +187,16 @@ def test_home_no_auto_transition_after_post_until_reload(fresh_db):
c = _client()
pre = c.get("/home", cookies={"access_token": sess})
assert "install Claude Code" in pre.text # setup view
# `class="install-block"` is the not-onboarded-only structural element
# holding the inline Step-1 install pane. Use it as the discriminator
# instead of a free-form string like "install Claude Code", which now
# also appears in the always-on SETUP_INSTRUCTIONS_TEMPLATE clipboard
# payload's preflight comment after the 2026-05-10 init-report fix.
assert 'class="install-block"' in pre.text # setup view
flip = c.post("/api/me/onboarded", cookies={"access_token": sess})
assert flip.status_code == 200
post = c.get("/home", cookies={"access_token": sess})
assert "Welcome back" in post.text # nav hub view
assert "install Claude Code" not in post.text
assert 'class="install-block"' not in post.text

View file

@ -231,9 +231,12 @@ class TestClaudeSetupPreview:
# because it validates the PEP 427 filename in the URL before fetch).
assert "/cli/wheel/" in body
assert "/cli/agnes.whl" not in body
# Unified layout: numbered headers + diagnose step
# Unified always-on layout (Fix B + Fix C in 2026-05-10 init-report
# response): preflight + marketplace + Atlassian MCP all unconditional.
# Step 1 install, step 4 preflight, step 5 marketplace, step 6 MCP,
# step 7 diagnose.
assert "1) Install the CLI" in body
assert "4) Run diagnostics" in body
assert "7) Run diagnostics" in body
assert "agnes diagnose" in body
# `agnes init` is now the mandatory bootstrap step.
assert "agnes init" in body
@ -247,9 +250,12 @@ class TestClaudeSetupPreview:
def test_install_preview_unified_layout(self, web_client, admin_cookie):
"""The clipboard payload (SETUP_INSTRUCTIONS_TEMPLATE JS array)
carries the unified layout for every caller admin-vs-analyst
is no longer a layout branch. Without plugin grants, the
marketplace block is omitted (no `claude plugin marketplace
add` line)."""
is no longer a layout branch. Marketplace + Atlassian MCP blocks
are always emitted (Fix B + Fix C in 2026-05-10 init-report
response): the user-facing one-liner is `agnes refresh-marketplace
--bootstrap` (the literal `claude plugin marketplace add` shows up
only as a documentation comment listing what the binary does
internally, never as an instruction to run by hand)."""
import re
resp = web_client.get("/setup", cookies=admin_cookie)
assert resp.status_code == 200
@ -261,7 +267,12 @@ class TestClaudeSetupPreview:
assert match, "SETUP_INSTRUCTIONS_TEMPLATE array missing"
clipboard = match.group(1)
assert "agnes init" in clipboard
assert "claude plugin marketplace add" not in clipboard
# User runs the bootstrap one-liner, not raw `claude plugin
# marketplace add` — the latter is an internal step described in a
# comment block, never an action line to run.
assert "agnes refresh-marketplace --bootstrap" in clipboard
# Atlassian MCP registration is always-on now.
assert "claude mcp add --transport sse atlassian" in clipboard
# Legacy admin-only auth verbs are gone from the generated prompt.
assert "agnes auth import-token" not in clipboard
# `agnes auth whoami` was the old admin step 3; subsumed by
@ -283,13 +294,15 @@ class TestClaudeSetupPreview:
def test_install_mcp_card_removed(self, web_client):
"""The stale 'Use with Claude Code / MCP' card on /setup has been
removed there is no Agnes MCP server today.
removed there is no Agnes-as-MCP-server today. The Atlassian
MCP server registration step (Fix C in the 2026-05-10 init-report
response) is registered FROM the setup script, not as a /setup-
page card; that's an unrelated wiring direction.
"""
resp = web_client.get("/setup")
assert resp.status_code == 200
body = resp.text
assert "Use with Claude Code / MCP" not in body
assert "MCP" not in body
class TestAdminRoleGuards: