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:
parent
929520f5e1
commit
41829e8a45
11 changed files with 616 additions and 124 deletions
62
CHANGELOG.md
62
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
|
|
|
|||
|
|
@ -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>/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.
|
||||
|
|
@ -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}' && 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.
|
||||
|
|
@ -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 ] && . ~/.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".
|
||||
|
|
|
|||
|
|
@ -544,7 +544,7 @@ Add-Content $PROFILE 'function yolo { claude --dangerously-skip-permissions @arg
|
|||
<section class="ad-section" id="tips">
|
||||
<h2>8. Tips, cost & 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 5–8h. Will hit limits within first week of real use.</td></tr>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue