From 3caccdd9797e2b19df179ad6c7f2679d4cb7211f Mon Sep 17 00:00:00 2001 From: Philip Chmalts Date: Wed, 2 Sep 2026 11:14:46 -0700 Subject: [PATCH 01/10] =?UTF-8?q?docs(plans):=20PLA-2951=20workstream=20C?= =?UTF-8?q?=20=E2=80=94=20agent=20grant=20registration=20and=20.env=20hard?= =?UTF-8?q?ening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- ...09-02-pla-2951-agent-grant-registration.md | 1111 +++++++++++++++++ 1 file changed, 1111 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-02-pla-2951-agent-grant-registration.md diff --git a/docs/superpowers/plans/2026-09-02-pla-2951-agent-grant-registration.md b/docs/superpowers/plans/2026-09-02-pla-2951-agent-grant-registration.md new file mode 100644 index 0000000..0165056 --- /dev/null +++ b/docs/superpowers/plans/2026-09-02-pla-2951-agent-grant-registration.md @@ -0,0 +1,1111 @@ +# Agent Grant Registration and `.env` Hardening — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** After the wizard installs the Seam plugin skills, register the *authenticated* Seam MCP (`https://mcp.seam.co/mcp/authenticated`) with the developer's coding agent — via `claude mcp add` when Claude Code is detected, by printing the equivalent `.mcp.json` snippet otherwise — and refuse to write `SEAM_API_KEY` through a symlinked `.env`. + +**Architecture:** One new module, `src/lib/steps/register-seam-mcp.ts`, holds every constant and string this feature needs (the `claude mcp add` argv, the `.mcp.json` snippet, the three per-tool hints, the printed-notice composer) plus a thin runner that spawns the argv through the existing `runInstall` seam and maps failure to a printed fallback. `src/lib/app.tsx`'s `install-plugin` phase calls the runner, reports the outcome on `wizard_install_finished`, and renders the composed notices through the existing `addMessage`. `src/lib/env-file.ts` gains an `lstat` guard and a fourth `EnvWriteResult` variant that the three save paths propagate so the Ink app can tell the developer to add the key by hand. + +**Tech Stack:** TypeScript (ESM, `type: module`), React + Ink 7 for the TUI, vitest 4 (`npm test`), eslint 9 / neostandard + prettier, Node >= 22.12. + +**Spec:** `/Users/philchmalts/Documents/development/seam-connect/docs/superpowers/specs/2026-09-01-api-key-bootstrap-delegated-agent-merge-design.md` — this plan is **Workstream C** (`seamapi/wizard`). Workstream A shipped as seam-connect#17512; Workstream B (seam-ai) has its own plan in its own repo. Read spec §2 (Invariant), §3 (as-is facts), and §5.C before starting. + +## Global Constraints + +- Branch: `phil/pla-2951-agent-grant-registration` in `/Users/philchmalts/Documents/development/wizard` (already checked out, off `main` at tag `0.44.2`). Do not `cd` outside it. Dependencies are already installed — do **not** run `npm ci` or `npm install`. +- Tests: `npm test` (`vitest run --coverage`, whole suite, fast) or a single file with `npx vitest run `. Never call a live service from a test. +- Lint: `npm run lint` (`eslint .` then `prettier --check`). Format: `npm run format`. Typecheck: `npm run typecheck` (`tsc`). All three are project-wide and fast enough to run per task. +- **Invariant (spec §2):** the API key is never printed to the terminal and never enters the agent's context; only the `seam_wiz_` inference token reaches the subprocess env. Nothing in this PR may log, echo, or pass `SEAM_API_KEY` — the new `claude mcp add` spawn passes no `env` and no key, and the symlink-refusal message names the variable, never a value. +- **No change to the embedded agent harnesses.** `src/lib/steps/harness/anthropic.ts:10` and `src/lib/steps/harness/pi.ts:16` keep `const SEAM_MCP_URL = 'https://mcp.seam.co/mcp'` (anonymous). They only need docs (spec §5.C.4). Do not touch those files. +- No change to `seamapi/seam-plugin`'s registered URL, and keep `CLAUDE_CODE_COMMANDS` (the `/plugin` lines) as the optional docs-plugin path (spec §6). +- Style, per the existing code: `camelCase` locals and functions; `snake_case` only for analytics property keys and existing interface fields (`api_key`); prettier with `semi: false`, `singleQuote: true`, `jsxSingleQuote: true`; `no-console` is an eslint error — all output goes through `addMessage`; `@typescript-eslint/no-non-null-assertion` is an error; relative imports of `..`/`../**` are **forbidden** — reach across directories with the `lib/` path alias (e.g. `import { runInstall } from 'lib/run-install.js'`), same-directory `./x.js` is fine. +- Imports are sorted by `simple-import-sort` in these groups: `node:` · packages · `@seamapi/wizard` · `eval|lib|test` aliases · other · `./` relative. Run `npm run format` if the order is ever in doubt. +- Every comment added must earn its place: delete it if it would read identically at every similar call site, if a test already states the behavior, or if it is addressed to a reviewer. +- Every commit message mentions `PLA-2951` and ends with the trailer `Co-Authored-By: Claude Fable 5.1 `. + +--- + +## File Structure + +| File | Responsibility | Change | +|---|---|---| +| `src/lib/steps/register-seam-mcp.ts` | Every string and decision for authenticated-MCP registration: the `claude mcp add` argv, the `.mcp.json` snippet, the Cursor/Codex/OpenCode hints, the notice composer, and the runner that spawns the argv | **Create** | +| `src/lib/steps/register-seam-mcp.test.ts` | Unit tests for that module: exact argv, snippet JSON, hints, runner outcomes (injected runner), composed notices | **Create** | +| `src/lib/app.tsx` | The Ink app. `install-plugin` phase (lines 725-780) runs the skills install, then registration; reports `wizard_install_finished`; renders notices | Modify the `install-plugin` effect, the import block, and the four env-write call sites | +| `src/lib/screens/done.tsx` | Final screen | Add the exported `AGENT_CONSENT_NOTICE` copy line below the card | +| `src/lib/screens/done.test.tsx` | Done-screen render tests | Add one test for the consent copy | +| `src/lib/env-file.ts` | dotenv read/write helpers | `lstat` guard in `upsertEnvVar`, new `'symlink-refused'` variant, exported refusal message | +| `src/lib/env-file.test.ts` | Unit tests for those helpers | Add symlink tests | +| `src/lib/steps/authenticate.ts` | Pure auth logic | `saveVerifiedKey` returns the env result; `AuthResult` carries it | +| `src/lib/steps/authenticate.test.ts` | Unit tests for auth | Add one propagation test | +| `src/lib/steps/connect-web.ts` | Browser → CLI key handoff | `WebConnectResult` carries the env result | + +Task order: 1 (pure strings) → 2 (runner) → 3 (app wiring + analytics + done copy) → 4 (`.env` hardening, independent of 1-3) → 5 (verify + PR). + +--- + +### Task 1: The registration constants, snippet, and hints + +**Files:** +- Create: `src/lib/steps/register-seam-mcp.ts` +- Create: `src/lib/steps/register-seam-mcp.test.ts` + +**Interfaces:** +- Consumes: `type PluginTarget = 'claude-code' | 'universal'` from `./install-seam-plugin.js` (already exported at `src/lib/steps/install-seam-plugin.ts:4`). +- Produces (all used by Tasks 2 and 3): + - `const AUTHENTICATED_SEAM_MCP_URL: string` + - `const SEAM_MCP_SERVER_NAME: string` + - `const CLAUDE_MCP_ADD_COMMAND: string[]` + - `function mcpJsonSnippet(): string` + - `const UNIVERSAL_MCP_HINTS: readonly string[]` + +- [ ] **Step 1: Write the failing tests** + +Create `src/lib/steps/register-seam-mcp.test.ts`: + +```ts +import { expect, test } from 'vitest' + +import { + AUTHENTICATED_SEAM_MCP_URL, + CLAUDE_MCP_ADD_COMMAND, + mcpJsonSnippet, + SEAM_MCP_SERVER_NAME, + UNIVERSAL_MCP_HINTS, +} from './register-seam-mcp.js' + +test('CLAUDE_MCP_ADD_COMMAND is the exact non-interactive argv', () => { + expect(CLAUDE_MCP_ADD_COMMAND).toEqual([ + 'claude', + 'mcp', + 'add', + '--transport', + 'http', + '--scope', + 'project', + 'seam', + 'https://mcp.seam.co/mcp/authenticated', + ]) +}) + +test('mcpJsonSnippet is valid JSON registering the authenticated URL', () => { + const snippet = JSON.parse(mcpJsonSnippet()) as { + mcpServers: Record + } + + expect(Object.keys(snippet.mcpServers)).toEqual([SEAM_MCP_SERVER_NAME]) + expect(snippet.mcpServers[SEAM_MCP_SERVER_NAME]).toEqual({ + type: 'http', + url: AUTHENTICATED_SEAM_MCP_URL, + }) +}) + +test('UNIVERSAL_MCP_HINTS names one config file per supported tool', () => { + expect(UNIVERSAL_MCP_HINTS).toHaveLength(3) + expect(UNIVERSAL_MCP_HINTS[0]).toContain('Cursor') + expect(UNIVERSAL_MCP_HINTS[0]).toContain('.cursor/mcp.json') + expect(UNIVERSAL_MCP_HINTS[1]).toContain('Codex') + expect(UNIVERSAL_MCP_HINTS[1]).toContain('.codex/config.toml') + expect(UNIVERSAL_MCP_HINTS[2]).toContain('OpenCode') + expect(UNIVERSAL_MCP_HINTS[2]).toContain('opencode.json') + for (const hint of UNIVERSAL_MCP_HINTS) { + expect(hint.split('\n')).toHaveLength(1) + } +}) + +// The anonymous /mcp is the plugin's and the embedded harnesses' server. Every +// URL this module hands the developer's own agent must be the authenticated one, +// or the agent gets docs instead of a delegated grant. +test('nothing this module emits points at the anonymous MCP', () => { + const anonymousUrl = /mcp\.seam\.co\/mcp(?!\/authenticated)/ + + for (const emitted of [ + CLAUDE_MCP_ADD_COMMAND.join(' '), + mcpJsonSnippet(), + ...UNIVERSAL_MCP_HINTS, + ]) { + expect(emitted).not.toMatch(anonymousUrl) + } +}) + +test('nothing this module emits could carry an API key', () => { + for (const emitted of [ + CLAUDE_MCP_ADD_COMMAND.join(' '), + mcpJsonSnippet(), + ...UNIVERSAL_MCP_HINTS, + ]) { + expect(emitted).not.toContain('SEAM_API_KEY') + expect(emitted).not.toMatch(/seam_[A-Za-z0-9]/) + } +}) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npx vitest run src/lib/steps/register-seam-mcp.test.ts` + +Expected: FAIL — the suite cannot resolve `./register-seam-mcp.js` ("Failed to load url ./register-seam-mcp.js"). + +- [ ] **Step 3: Write the module** + +Create `src/lib/steps/register-seam-mcp.ts`: + +```ts +// The authenticated Seam MCP. Unlike the anonymous https://mcp.seam.co/mcp the +// plugin and the embedded harnesses use, this endpoint answers an unauthenticated +// request with 401 + WWW-Authenticate, which is what makes a coding agent start +// the OAuth consent flow and end up on its own delegated grant instead of +// borrowing the app's key from .env. +export const AUTHENTICATED_SEAM_MCP_URL = + 'https://mcp.seam.co/mcp/authenticated' + +export const SEAM_MCP_SERVER_NAME = 'seam' + +// Project scope writes .mcp.json in the project root, so the registration +// travels with the repo the wizard just set up. No flag here prompts, so the +// wizard can spawn it with stdin ignored like every other install. +export const CLAUDE_MCP_ADD_COMMAND = [ + 'claude', + 'mcp', + 'add', + '--transport', + 'http', + '--scope', + 'project', + SEAM_MCP_SERVER_NAME, + AUTHENTICATED_SEAM_MCP_URL, +] + +// What `claude mcp add` would have written, for the developer to paste when the +// CLI is missing or another agent is in use. +export function mcpJsonSnippet(): string { + return JSON.stringify( + { + mcpServers: { + [SEAM_MCP_SERVER_NAME]: { + type: 'http', + url: AUTHENTICATED_SEAM_MCP_URL, + }, + }, + }, + null, + 2, + ) +} + +export const UNIVERSAL_MCP_HINTS = [ + `Cursor — add the same mcpServers block to .cursor/mcp.json`, + `Codex — add [mcp_servers.${SEAM_MCP_SERVER_NAME}] with url = "${AUTHENTICATED_SEAM_MCP_URL}" to ~/.codex/config.toml`, + `OpenCode — add "${SEAM_MCP_SERVER_NAME}": { "type": "remote", "url": "${AUTHENTICATED_SEAM_MCP_URL}" } under "mcp" in opencode.json`, +] as const +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `npx vitest run src/lib/steps/register-seam-mcp.test.ts` + +Expected: PASS — 5 tests. + +- [ ] **Step 5: Lint and typecheck** + +Run: `npm run lint && npm run typecheck` + +Expected: both exit 0 with no findings. If prettier complains about the new file, run `npm run format` and re-run. + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/steps/register-seam-mcp.ts src/lib/steps/register-seam-mcp.test.ts +git commit -m "feat(mcp): add the authenticated Seam MCP registration constants + +The claude mcp add argv, the equivalent .mcp.json snippet, and one-line +Cursor/Codex/OpenCode hints, all built from a single authenticated-URL +constant. Tests pin the exact argv and assert nothing emitted here points +at the anonymous /mcp or could carry an API key. + +PLA-2951 + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 2: The registration runner + +**Files:** +- Modify: `src/lib/steps/register-seam-mcp.ts` (append the runner below the constants from Task 1) +- Modify: `src/lib/steps/register-seam-mcp.test.ts` (append the runner tests) + +**Interfaces:** +- Consumes: `runInstall(command: string[], cwd: string, onLine: (line: string) => void): Promise` from `lib/run-install.js`. It spawns with `stdio: ['ignore', 'pipe', 'pipe']`, `shell: false`, no `env` override (so the child inherits the wizard's environment and nothing key-bearing is added); it rejects with the spawn `error` event — an `Error` whose `code` is `'ENOENT'` when the binary is missing — and with `new Error("claude exited with code ")` on a non-zero close. Also `CLAUDE_MCP_ADD_COMMAND` from Task 1. +- Produces: + - `type McpRegistration = 'claude_cli' | 'printed' | 'failed'` + - `type RunCommand = (command: string[], cwd: string, onLine: (line: string) => void) => Promise` + - `function registerSeamMcpWithClaudeCli(args: { root: string; onLine: (line: string) => void; runCommand?: RunCommand }): Promise<'claude_cli' | 'printed'>` + +- [ ] **Step 1: Write the failing tests** + +Append to `src/lib/steps/register-seam-mcp.test.ts`: + +```ts +test('registerSeamMcpWithClaudeCli reports claude_cli after a clean run', async () => { + const calls: Array<{ command: string[]; cwd: string }> = [] + + const registration = await registerSeamMcpWithClaudeCli({ + root: '/tmp/seam-wizard-project', + onLine: () => {}, + runCommand: async (command, cwd) => { + calls.push({ command, cwd }) + }, + }) + + expect(registration).toBe('claude_cli') + expect(calls).toEqual([ + { + command: CLAUDE_MCP_ADD_COMMAND, + cwd: '/tmp/seam-wizard-project', + }, + ]) +}) + +// The developer may not have the Claude Code CLI on PATH at all: spawn rejects +// with ENOENT before anything runs, and the wizard has to fall back to printing. +test('registerSeamMcpWithClaudeCli reports printed when the binary is missing', async () => { + const registration = await registerSeamMcpWithClaudeCli({ + root: '/tmp/seam-wizard-project', + onLine: () => {}, + runCommand: async () => { + throw Object.assign(new Error('spawn claude ENOENT'), { code: 'ENOENT' }) + }, + }) + + expect(registration).toBe('printed') +}) + +test('registerSeamMcpWithClaudeCli reports printed on a non-zero exit', async () => { + const registration = await registerSeamMcpWithClaudeCli({ + root: '/tmp/seam-wizard-project', + onLine: () => {}, + runCommand: async () => { + throw new Error('claude exited with code 1') + }, + }) + + expect(registration).toBe('printed') +}) + +test('registerSeamMcpWithClaudeCli streams the command output it is given', async () => { + const lines: string[] = [] + + await registerSeamMcpWithClaudeCli({ + root: '/tmp/seam-wizard-project', + onLine: (line) => lines.push(line), + runCommand: async (_command, _cwd, onLine) => { + onLine('Added HTTP MCP server seam') + }, + }) + + expect(lines).toEqual(['Added HTTP MCP server seam']) +}) +``` + +Extend the existing import in that file so it also pulls `registerSeamMcpWithClaudeCli` (keep the named imports alphabetical for `simple-import-sort`): + +```ts +import { + AUTHENTICATED_SEAM_MCP_URL, + CLAUDE_MCP_ADD_COMMAND, + mcpJsonSnippet, + registerSeamMcpWithClaudeCli, + SEAM_MCP_SERVER_NAME, + UNIVERSAL_MCP_HINTS, +} from './register-seam-mcp.js' +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npx vitest run src/lib/steps/register-seam-mcp.test.ts` + +Expected: FAIL — `registerSeamMcpWithClaudeCli is not a function` (or a TS/resolve error on the missing export) on the four new tests; the five from Task 1 still pass. + +- [ ] **Step 3: Implement the runner** + +In `src/lib/steps/register-seam-mcp.ts`, add the import at the top (packages/aliases group, above nothing else — this is the file's only import): + +```ts +import { runInstall } from 'lib/run-install.js' +``` + +Add the types directly under `AUTHENTICATED_SEAM_MCP_URL`'s block: + +```ts +// What the run did about MCP registration, as reported on +// wizard_install_finished. 'printed' covers both fallbacks — a missing or +// failing CLI, and a non-Claude-Code project that only gets the snippet. +// 'failed' is the caller's outcome when the registration step itself threw, so +// the developer got neither a registration nor a snippet. +export type McpRegistration = 'claude_cli' | 'printed' | 'failed' + +export type RunCommand = ( + command: string[], + cwd: string, + onLine: (line: string) => void, +) => Promise +``` + +Add the runner at the bottom of the file, below `UNIVERSAL_MCP_HINTS`: + +```ts +// Register the authenticated MCP with the Claude Code CLI, in the project the +// wizard is setting up. Any spawn failure — no `claude` on PATH (ENOENT), or a +// non-zero exit — is a fallback, not an error: the caller prints the snippet +// instead. `runCommand` is injected so a test can drive both outcomes. +export async function registerSeamMcpWithClaudeCli({ + root, + onLine, + runCommand = runInstall, +}: { + root: string + onLine: (line: string) => void + runCommand?: RunCommand +}): Promise<'claude_cli' | 'printed'> { + try { + await runCommand(CLAUDE_MCP_ADD_COMMAND, root, onLine) + return 'claude_cli' + } catch { + return 'printed' + } +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `npx vitest run src/lib/steps/register-seam-mcp.test.ts` + +Expected: PASS — 9 tests. + +- [ ] **Step 5: Lint and typecheck** + +Run: `npm run lint && npm run typecheck` + +Expected: both exit 0. In particular there must be no `no-restricted-imports` error: `runInstall` is imported as `lib/run-install.js`, never `../run-install.js`. + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/steps/register-seam-mcp.ts src/lib/steps/register-seam-mcp.test.ts +git commit -m "feat(mcp): spawn claude mcp add with a printed fallback + +registerSeamMcpWithClaudeCli runs the argv through the same runInstall +spawn the SDK and plugin installs use (stdin ignored, no env override, so +no API key can reach the child) and maps a missing binary or non-zero exit +to 'printed' so the caller can show the .mcp.json snippet instead. + +PLA-2951 + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 3: Wire registration into the install-plugin phase, analytics, and the done screen + +**Files:** +- Modify: `src/lib/steps/register-seam-mcp.ts` (add the notice composer) +- Modify: `src/lib/steps/register-seam-mcp.test.ts` (composer tests) +- Modify: `src/lib/app.tsx` — the import block (lines 74-79) and the `install-plugin` effect (lines 725-780) +- Modify: `src/lib/screens/done.tsx` +- Modify: `src/lib/screens/done.test.tsx` + +**Interfaces:** +- Consumes from Tasks 1-2: `CLAUDE_MCP_ADD_COMMAND`, `mcpJsonSnippet()`, `UNIVERSAL_MCP_HINTS`, `type McpRegistration`, `registerSeamMcpWithClaudeCli`. From existing code: `detectPluginTarget(root): PluginTarget`, `SEAM_PLUGIN_NPX_COMMAND`, `CLAUDE_CODE_COMMANDS` (`src/lib/steps/install-seam-plugin.ts`); `addMessage(message: { tone: 'ok' | 'info' | 'warn' | 'plain'; text: string }): void` (`app.tsx:205`); `trackInstallFinished(target: 'sdk' | 'plugin', ok: boolean, properties: Record): void` (`app.tsx:288`) — its third parameter is already `Record`, so adding `mcp_registration` needs no signature change; typing comes from declaring the value as `McpRegistration` at the call site. +- Produces: + - `interface McpNotice { tone: 'info' | 'warn' | 'plain'; text: string }` + - `function buildMcpRegistrationNotices(args: { target: PluginTarget; registration: McpRegistration }): McpNotice[]` + - `const AGENT_CONSENT_NOTICE: string` exported from `src/lib/screens/done.js` + +- [ ] **Step 1: Write the failing composer tests** + +Append to `src/lib/steps/register-seam-mcp.test.ts` (and add `buildMcpRegistrationNotices` to that file's existing import list, keeping it alphabetical — it sorts first, before `CLAUDE_MCP_ADD_COMMAND`): + +```ts +test('buildMcpRegistrationNotices confirms a CLI registration without reprinting it', () => { + const notices = buildMcpRegistrationNotices({ + target: 'claude-code', + registration: 'claude_cli', + }) + + expect(notices).toHaveLength(1) + expect(notices[0]?.tone).toBe('info') + expect(notices[0]?.text).toContain('Registered the Seam MCP') + expect(notices.map((notice) => notice.text).join('\n')).not.toContain( + 'mcpServers', + ) +}) + +test('buildMcpRegistrationNotices prints the snippet when the CLI could not register', () => { + const notices = buildMcpRegistrationNotices({ + target: 'claude-code', + registration: 'printed', + }) + const text = notices.map((notice) => notice.text).join('\n') + + expect(text).toContain('.mcp.json') + expect(text).toContain('https://mcp.seam.co/mcp/authenticated') + expect(JSON.parse(mcpJsonSnippet())).toBeTruthy() + // A Claude Code project does not need another agent's config file named at it. + expect(text).not.toContain('opencode.json') +}) + +test('buildMcpRegistrationNotices adds the per-tool hints for a universal project', () => { + const text = buildMcpRegistrationNotices({ + target: 'universal', + registration: 'printed', + }) + .map((notice) => notice.text) + .join('\n') + + for (const hint of UNIVERSAL_MCP_HINTS) { + expect(text).toContain(hint) + } +}) + +test('buildMcpRegistrationNotices warns and prints when registration failed', () => { + const notices = buildMcpRegistrationNotices({ + target: 'claude-code', + registration: 'failed', + }) + const text = notices.map((notice) => notice.text).join('\n') + + expect(notices[0]?.tone).toBe('warn') + expect(text).toContain(CLAUDE_MCP_ADD_COMMAND.join(' ')) + expect(text).toContain('https://mcp.seam.co/mcp/authenticated') +}) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npx vitest run src/lib/steps/register-seam-mcp.test.ts` + +Expected: FAIL — `buildMcpRegistrationNotices is not a function` on the four new tests. + +- [ ] **Step 3: Implement the composer** + +In `src/lib/steps/register-seam-mcp.ts`, add the `PluginTarget` type import (it belongs in the trailing `./` relative group, so it goes below the `lib/run-install.js` import): + +```ts +import type { PluginTarget } from './install-seam-plugin.js' +``` + +Add the interface next to `McpRegistration`: + +```ts +// A line for the Ink app to render. The tones are the app's own Msg tones minus +// 'ok', which is reserved there for a step that actually succeeded. +export interface McpNotice { + tone: 'info' | 'warn' | 'plain' + text: string +} +``` + +Add the composer at the bottom of the file, below `registerSeamMcpWithClaudeCli`: + +```ts +export function buildMcpRegistrationNotices({ + target, + registration, +}: { + target: PluginTarget + registration: McpRegistration +}): McpNotice[] { + if (registration === 'claude_cli') { + return [ + { + tone: 'info', + text: 'Registered the Seam MCP for Claude Code in .mcp.json (project scope)', + }, + ] + } + + const heading: McpNotice = + registration === 'failed' + ? { + tone: 'warn', + text: `Couldn't register the Seam MCP — run it yourself: ${CLAUDE_MCP_ADD_COMMAND.join(' ')}`, + } + : { + tone: 'info', + text: 'Add the Seam MCP to your coding agent — put this in .mcp.json:', + } + + const snippetLines: McpNotice[] = mcpJsonSnippet() + .split('\n') + .map((line) => ({ tone: 'plain', text: ` ${line}` })) + + const hintLines: McpNotice[] = + target === 'universal' + ? UNIVERSAL_MCP_HINTS.map((hint) => ({ tone: 'plain', text: ` ${hint}` })) + : [] + + return [heading, ...snippetLines, ...hintLines] +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `npx vitest run src/lib/steps/register-seam-mcp.test.ts` + +Expected: PASS — 13 tests. + +- [ ] **Step 5: Wire the install-plugin phase in `app.tsx`** + +Add the new import to `src/lib/app.tsx` after the `./steps/integrate.js` import at line 79 (alphabetically `install-seam-plugin` < `integrate` < `register-seam-mcp`): + +```tsx +import { + buildMcpRegistrationNotices, + type McpRegistration, + registerSeamMcpWithClaudeCli, +} from './steps/register-seam-mcp.js' +``` + +Replace the whole body of the `install-plugin` effect (`src/lib/app.tsx:725-780`, the block whose comment begins `// install the official Seam plugin skills, then finish.`) with: + +```tsx + // install the official Seam plugin skills, then register the authenticated + // Seam MCP so the developer's own agent gets a delegated grant instead of + // reading the app's key out of .env. For Claude Code we additionally point at + // the native /plugin path, which wires up the anonymous docs MCP. + useEffect(() => { + if (phase.t !== 'install-plugin') return + const target = detectPluginTarget(root) + + let cancelled = false + const streamLine = (line: string): void => { + if (!cancelled) { + setInstallLines((previous) => [...previous.slice(-3), line]) + } + } + const run = async (): Promise => { + installStartedAtRef.current = Date.now() + let skillsInstalled = true + try { + await runInstall(SEAM_PLUGIN_NPX_COMMAND, root, streamLine) + } catch { + skillsInstalled = false + } + if (cancelled) return + + addMessage( + skillsInstalled + ? { tone: 'ok', text: 'Installed the Seam plugin skills' } + : { + tone: 'warn', + text: `Couldn't install the plugin — run it yourself: ${SEAM_PLUGIN_NPX_COMMAND.join(' ')}`, + }, + ) + + let registration: McpRegistration = 'printed' + if (target === 'claude-code') { + try { + registration = await registerSeamMcpWithClaudeCli({ + root, + onLine: streamLine, + }) + } catch { + registration = 'failed' + } + } + if (cancelled) return + + trackInstallFinished('plugin', skillsInstalled, { + plugin_target: target, + mcp_registration: registration, + }) + for (const notice of buildMcpRegistrationNotices({ + target, + registration, + })) { + addMessage(notice) + } + + setInstallLines([]) + if (target === 'claude-code') { + addMessage({ + tone: 'info', + text: 'Claude Code: for the native plugin + seam-docs MCP, you can also run:', + }) + for (const command of CLAUDE_CODE_COMMANDS) { + addMessage({ tone: 'plain', text: ` ${command}` }) + } + } + setPhase({ t: 'offer-integrate' }) + } + run().catch((error: unknown) => { + if (cancelled) return + setPhase({ + t: 'error', + message: + error instanceof Error + ? error.message + : 'The wizard hit an unexpected error.', + }) + }) + return () => { + cancelled = true + } + }, [phase.t]) +``` + +Three things changed beyond the new registration: the streamed-line closure is hoisted so both spawns share it, the skills-install messages moved out of the `try`/`catch` so registration can run before the analytics event, and `trackInstallFinished` fires once with both properties. + +- [ ] **Step 6: Add the done-screen consent copy** + +In `src/lib/screens/done.tsx`, add the exported constant directly above the `DoneScreen` function (below the `IntegrationOutcome` interface): + +```tsx +// Verbatim from the merge design: the developer is told, before they leave, that +// the agent authenticates itself rather than reusing the app's key. +export const AGENT_CONSENT_NOTICE = + 'Your coding agent will be asked to sign in to Seam and choose permissions the first time it uses a Seam tool.' +``` + +Render it in the outer column, between the assistant-link block and the "Press any key to exit" margin box (it sits outside the bordered card, which is too narrow for a sentence this long): + +```tsx + + {AGENT_CONSENT_NOTICE} + +``` + +- [ ] **Step 7: Write the done-screen test** + +Append to `src/lib/screens/done.test.tsx`, and add `AGENT_CONSENT_NOTICE` to its existing `./done.js` import: + +```tsx +test('DoneScreen: says the agent will sign in and choose permissions', () => { + const { lastFrame, unmount } = render( + , + ) + try { + // Ink wraps the sentence across rows, so compare on collapsed whitespace. + const frame = (lastFrame() ?? '').replace(/\s+/g, ' ') + expect(frame).toContain(AGENT_CONSENT_NOTICE) + } finally { + unmount() + } +}) + +test('AGENT_CONSENT_NOTICE is the copy the merge design specifies', () => { + expect(AGENT_CONSENT_NOTICE).toBe( + 'Your coding agent will be asked to sign in to Seam and choose permissions the first time it uses a Seam tool.', + ) +}) +``` + +- [ ] **Step 8: Run the affected tests** + +Run: `npx vitest run src/lib/steps/register-seam-mcp.test.ts src/lib/screens/done.test.tsx test/app.test.tsx` + +Expected: PASS — 13 register-seam-mcp tests, 5 done-screen tests, and the pre-existing app tests unchanged. If the frame assertion fails, print `lastFrame()` to check the notice is not being clipped by the terminal height the test harness reports; widen the assertion to the first clause only if the sentence is genuinely truncated, and keep the exact-copy test as the authority. + +- [ ] **Step 9: Lint and typecheck** + +Run: `npm run lint && npm run typecheck` + +Expected: both exit 0. + +- [ ] **Step 10: Commit** + +```bash +git add src/lib/steps/register-seam-mcp.ts src/lib/steps/register-seam-mcp.test.ts src/lib/app.tsx src/lib/screens/done.tsx src/lib/screens/done.test.tsx +git commit -m "feat(wizard): register the authenticated Seam MCP after the skills install + +The install-plugin phase now runs claude mcp add in the project root when +Claude Code is detected, and prints the .mcp.json snippet (plus Cursor, +Codex, and OpenCode hints for a universal project) when it cannot. The +outcome rides on wizard_install_finished as mcp_registration, and the done +screen tells the developer their agent will sign in and pick permissions on +first use. The /plugin lines stay as the optional docs-plugin path. + +PLA-2951 + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 4: Refuse to write `SEAM_API_KEY` through a symlink + +**Files:** +- Modify: `src/lib/env-file.ts:1` (imports), `:4` (`EnvWriteResult`), `:103-126` (`upsertEnvVar`) +- Modify: `src/lib/env-file.test.ts` +- Modify: `src/lib/steps/authenticate.ts:5-19` (`AuthResult`), `:62-64` (`saveVerifiedKey`) +- Modify: `src/lib/steps/authenticate.test.ts` +- Modify: `src/lib/steps/connect-web.ts:18-21` (`WebConnectResult`), `:116` +- Modify: `src/lib/app.tsx` — `useCliKey` (~line 429), `useProjectKey` (~line 445), the `verify-paste` effect (~line 636), the `browser` effect (~line 584) + +**Interfaces:** +- Consumes: `existsSync`, `readFileSync`, `writeFileSync` from `node:fs` (already imported in `env-file.ts`); `type ProjectEnvResult { env: EnvWriteResult; example: EnvWriteResult | 'unchanged'; gitignore: 'added' | 'unchanged' }` and `saveProjectApiKey(root: string, apiKey: string): ProjectEnvResult` (already exported). +- Produces: + - `type EnvWriteResult = 'created' | 'updated' | 'added' | 'symlink-refused'` (fourth variant added) + - `const ENV_SYMLINK_REFUSAL_MESSAGE: string` + - `saveVerifiedKey(root: string, apiKey: string): ProjectEnvResult` (was `void`) + - `interface AuthResult { workspace: SeamWorkspace; api_key: string; env: ProjectEnvResult }` (field added) + - `interface WebConnectResult { workspace: SeamWorkspace; api_key: string; env: ProjectEnvResult }` (field added) + +- [ ] **Step 1: Write the failing tests** + +Append to `src/lib/env-file.test.ts` (and add `symlinkSync` to its `node:fs` import list, and `ENV_SYMLINK_REFUSAL_MESSAGE` to its `./env-file.js` import list): + +```ts +// A symlinked .env usually points at a shared secrets file outside the repo. +// Writing through it would edit that file — and it is exactly the case where +// the wizard cannot know the destination is the developer's to change. +test('upsertEnvVar refuses a symlinked file and leaves the target untouched', () => { + const targetPath = join(dir, 'shared-secrets.env') + const linkPath = join(dir, '.env') + writeFileSync(targetPath, 'OTHER=1\n') + symlinkSync(targetPath, linkPath) + + expect(upsertEnvVar(linkPath, 'SEAM_API_KEY', 'seam_new')).toBe( + 'symlink-refused', + ) + expect(readFileSync(targetPath, 'utf8')).toBe('OTHER=1\n') +}) + +// existsSync follows the link, so a dangling one would otherwise look absent +// and get created at the far end. +test('upsertEnvVar refuses a dangling symlink without creating its target', () => { + const targetPath = join(dir, 'missing-secrets.env') + const linkPath = join(dir, '.env') + symlinkSync(targetPath, linkPath) + + expect(upsertEnvVar(linkPath, 'SEAM_API_KEY', 'seam_new')).toBe( + 'symlink-refused', + ) + expect(existsSync(targetPath)).toBe(false) +}) + +test('saveProjectApiKey reports the refusal and still ignores .env', () => { + mkdirSync(join(dir, '.git')) + const targetPath = join(dir, 'shared-secrets.env') + writeFileSync(targetPath, 'OTHER=1\n') + symlinkSync(targetPath, join(dir, '.env')) + + const result = saveProjectApiKey(dir, 'seam_new_key') + + expect(result.env).toBe('symlink-refused') + expect(result.gitignore).toBe('added') + expect(readFileSync(targetPath, 'utf8')).not.toContain('seam_new_key') +}) + +test('ENV_SYMLINK_REFUSAL_MESSAGE tells the developer what to do, without a key', () => { + expect(ENV_SYMLINK_REFUSAL_MESSAGE).toContain('SEAM_API_KEY') + expect(ENV_SYMLINK_REFUSAL_MESSAGE).toContain('symlink') + expect(ENV_SYMLINK_REFUSAL_MESSAGE).not.toMatch(/seam_[A-Za-z0-9]/) +}) +``` + +Append to `src/lib/steps/authenticate.test.ts` (add `symlinkSync` and `writeFileSync` to its `node:fs` import): + +```ts +// The refusal is only useful if it reaches the Ink app, which reads it off the +// result of the save. +test('verifyAndSaveKey reports a symlinked .env instead of writing through it', async () => { + get.mockResolvedValue(workspace) + const targetPath = join(dir, 'shared-secrets.env') + writeFileSync(targetPath, 'OTHER=1\n') + symlinkSync(targetPath, join(dir, '.env')) + + const result = await verifyAndSaveKey(dir, 'seam_pasted_key') + + expect(result.env.env).toBe('symlink-refused') + expect(readFileSync(targetPath, 'utf8')).toBe('OTHER=1\n') +}) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npx vitest run src/lib/env-file.test.ts src/lib/steps/authenticate.test.ts` + +Expected: FAIL — the `upsertEnvVar` symlink tests report `'updated'`/`'added'` instead of `'symlink-refused'` and show the target file rewritten; `ENV_SYMLINK_REFUSAL_MESSAGE` is undefined; `result.env` is undefined in the authenticate test. + +- [ ] **Step 3: Add the guard to `env-file.ts`** + +Change the imports on line 1 and the type on line 4: + +```ts +import { existsSync, lstatSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +export type EnvWriteResult = + | 'created' + | 'updated' + | 'added' + | 'symlink-refused' + +export const ENV_SYMLINK_REFUSAL_MESSAGE = + '.env is a symlink — the wizard did not write through it. Add SEAM_API_KEY to the real file yourself.' +``` + +Add the symlink check as the first thing `upsertEnvVar` does, above the `existsSync` branch (`existsSync` resolves the link, so it must not run first): + +```ts +export function upsertEnvVar( + filePath: string, + key: string, + value: string, +): EnvWriteResult { + const line = `${key}=${value}` + + const link = lstatSync(filePath, { throwIfNoEntry: false }) + if (link?.isSymbolicLink() === true) { + return 'symlink-refused' + } + + if (!existsSync(filePath)) { +``` + +The rest of the function is unchanged. + +- [ ] **Step 4: Propagate the result through the save paths** + +In `src/lib/steps/authenticate.ts`, import the result type and add the field: + +```ts +import { + findExistingApiKey, + type ProjectEnvResult, + saveProjectApiKey, +} from 'lib/env-file.js' + +export interface AuthResult { + workspace: SeamWorkspace + api_key: string + env: ProjectEnvResult +} +``` + +and change the two functions at the bottom: + +```ts +export async function verifyAndSaveKey( + root: string, + apiKey: string, +): Promise { + const trimmed = apiKey.trim() + const workspace = await getWorkspaceForApiKey(trimmed) + return { workspace, api_key: trimmed, env: saveProjectApiKey(root, trimmed) } +} + +export function saveVerifiedKey( + root: string, + apiKey: string, +): ProjectEnvResult { + return saveProjectApiKey(root, apiKey) +} +``` + +In `src/lib/steps/connect-web.ts`, extend the import and the result type, and return the env result: + +```ts +import { type ProjectEnvResult, saveProjectApiKey } from 'lib/env-file.js' + +export interface WebConnectResult { + workspace: SeamWorkspace + api_key: string + env: ProjectEnvResult +} +``` + +```ts + const workspace = await getWorkspaceForApiKey(payload.api_key) + return { + workspace, + api_key: payload.api_key, + env: saveProjectApiKey(root, payload.api_key), + } +``` + +- [ ] **Step 5: Surface the refusal in `app.tsx`** + +Extend the `./env-file.js` import at line 29: + +```tsx +import { + ensureProjectEnvConventions, + ENV_SYMLINK_REFUSAL_MESSAGE, + findExistingApiKey, + type ProjectEnvResult, +} from './env-file.js' +``` + +Add this helper immediately below `addMessage` (`src/lib/app.tsx:205-206`): + +```tsx + const reportEnvWrite = (result: ProjectEnvResult): void => { + if (result.env === 'symlink-refused') { + addMessage({ tone: 'warn', text: ENV_SYMLINK_REFUSAL_MESSAGE }) + } + } +``` + +Then call it at the four save sites. `useCliKey` (~line 429): + +```tsx + const useCliKey = (found: CliKeyResult): void => { + try { + reportEnvWrite(saveVerifiedKey(root, found.api_key)) + addMessage({ + tone: 'ok', + text: `Using your Seam CLI login · workspace ${found.workspace.name} · saved to .env`, + }) + } catch { +``` + +`useProjectKey` (~line 445) — only the `environment` branch writes a key: + +```tsx + if (found.source === 'environment') { + reportEnvWrite(saveVerifiedKey(root, found.api_key)) + } else { + ensureProjectEnvConventions(root) + } +``` + +The `verify-paste` effect (~line 636): + +```tsx + const result = await verifyAndSaveKey(root, apiKey) + if (cancelled) return + reportEnvWrite(result.env) + addMessage({ tone: 'ok', text: `Workspace: ${result.workspace.name}` }) +``` + +The `browser` effect (~line 595), right after the `if (cancelled) return`: + +```tsx + if (cancelled) return + reportEnvWrite(result.env) + addMessage({ + tone: 'ok', + text: `Connected · workspace ${result.workspace.name}`, + }) +``` + +- [ ] **Step 6: Run the affected tests** + +Run: `npx vitest run src/lib/env-file.test.ts src/lib/steps/authenticate.test.ts test/app.test.tsx` + +Expected: PASS — the four new `env-file` tests, the new authenticate test, every pre-existing `.env` preservation / `.env.example` / `.gitignore` test, and the app tests. No pre-existing assertion may be edited to make this pass: `saveProjectApiKey`'s `toEqual({ env: 'created', example: 'created', gitignore: 'added' })` must still hold for a plain file. + +- [ ] **Step 7: Lint and typecheck** + +Run: `npm run lint && npm run typecheck` + +Expected: both exit 0. `tsc` is what proves the three widened result types have no un-updated caller. + +- [ ] **Step 8: Commit** + +```bash +git add src/lib/env-file.ts src/lib/env-file.test.ts src/lib/steps/authenticate.ts src/lib/steps/authenticate.test.ts src/lib/steps/connect-web.ts src/lib/app.tsx +git commit -m "fix(env): refuse to write SEAM_API_KEY through a symlinked .env + +existsSync resolves a symlink, so the wizard would happily write the key +into whatever the link pointed at — typically a shared secrets file outside +the repo that .gitignore does not cover. upsertEnvVar now lstats first and +returns 'symlink-refused' without writing; the save paths carry that result +up so the app tells the developer to add SEAM_API_KEY by hand. The +created/updated/added report and ensureGitignored are unchanged, and there +is no destination-approval prompt. + +PLA-2951 + +Co-Authored-By: Claude Fable 5.1 " +``` + +--- + +### Task 5: Final verification and PR + +**Files:** none new. + +- [ ] **Step 1: Run the whole suite** + +Run: `npm test` + +Expected: every test file passes. Coverage is reported but not gated. + +- [ ] **Step 2: Lint, format check, and typecheck** + +Run: `npm run lint && npm run typecheck` + +Expected: both exit 0. If prettier reports a file, run `npm run format`, re-run, and amend the commit that introduced it. + +- [ ] **Step 3: Confirm the invariant held** + +Run: + +```bash +git diff main...HEAD | grep -nE '^\+.*(SEAM_API_KEY|seam_[A-Za-z0-9]|apiKey)' +``` + +Expected: the only additions naming `SEAM_API_KEY` are the refusal message, the `env-file` tests, and the `AGENT_CONSENT_NOTICE`-adjacent test literals — never a value interpolated into a message, a log line, an argv, or a subprocess `env`. Then: + +```bash +git diff main...HEAD -- src/lib/steps/harness/ +``` + +Expected: no output. The harnesses keep the anonymous `https://mcp.seam.co/mcp`. + +- [ ] **Step 4: Review the comments the branch added** + +Run: `git diff main...HEAD -U0 -- src | grep -E '^\+\s*(//|/\*|\*)'` + +For each: would it read identically at every similar site (generality)? Is a test already the explanation (test coverage)? Is it aimed at a reviewer (audience)? Delete any that fail and amend. The ones written here are meant to survive: each records a *why* the code cannot state — why the authenticated URL differs from the plugin's, why a spawn failure is a fallback rather than an error, why `lstat` has to precede `existsSync`. + +- [ ] **Step 5: Push and open the PR** + +```bash +git push -u origin phil/pla-2951-agent-grant-registration +gh pr create --title "feat(wizard): register the authenticated Seam MCP and harden .env (PLA-2951)" --body "$(cat <<'EOF' +## Summary + +A developer's coding agent that arrives via `seam wizard` had no Seam credential of its own, so in practice it borrowed the app's durable key out of `.env`. After this change the wizard registers the **authenticated** Seam MCP (`https://mcp.seam.co/mcp/authenticated`) with the agent, which answers an anonymous request with `401 + WWW-Authenticate` — so the agent runs the consent flow and ends up on its own short-lived, revocable delegated grant instead. + +Workstream C of the merge design (`seam-connect: docs/superpowers/specs/2026-09-01-api-key-bootstrap-delegated-agent-merge-design.md`, §5.C). Workstream A shipped as seamapi/seam-connect#17512. + +## Changes + +- **New `src/lib/steps/register-seam-mcp.ts`** — the exact `claude mcp add --transport http --scope project seam https://mcp.seam.co/mcp/authenticated` argv, the equivalent `.mcp.json` snippet, one-line Cursor / Codex / OpenCode hints, and a runner that spawns the argv in the project root through the existing `runInstall` seam. A missing `claude` binary (ENOENT) or a non-zero exit falls back to printing the snippet. +- **`install-plugin` phase** — runs registration after the skills install; `wizard_install_finished` now carries `mcp_registration: 'claude_cli' | 'printed' | 'failed'` alongside `plugin_target`. The `/plugin` slash-command lines stay as the optional docs-plugin path. +- **Done screen** — "Your coding agent will be asked to sign in to Seam and choose permissions the first time it uses a Seam tool." +- **`env-file.ts`** — `upsertEnvVar` `lstat`s the target first and returns `'symlink-refused'` without writing, so the key never lands in a shared secrets file the link pointed at. The `created | updated | added` report and `ensureGitignored` are unchanged, and there is no destination-approval prompt. The refusal is surfaced by the app, which tells the developer to add `SEAM_API_KEY` by hand. +- **Unchanged on purpose** — the embedded agent harnesses keep the anonymous `https://mcp.seam.co/mcp` (they only need docs), and `seamapi/seam-plugin`'s registered URL is untouched. + +The invariant holds throughout: the API key is never printed and never enters the agent's context. Only the `seam_wiz_` inference token reaches a subprocess env, and the new spawn passes no env at all. + +## Test plan + +- [x] `npm test` — new unit tests for the argv, the snippet's JSON, the hints, the three runner outcomes with an injected runner, the composed notices, the symlink refusal (live and dangling links, target byte-identical afterwards), and the propagation up through `verifyAndSaveKey` +- [x] `npm run lint && npm run typecheck` +- [ ] Manual: `npm run wizard` in a scratch project with `.claude/` present (expect a registered `.mcp.json`) and in one without (expect the printed snippet plus hints) + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +Expected: PR URL printed. From c930a2d6fffe6a02c199e8fe619335756fbef3b4 Mon Sep 17 00:00:00 2001 From: Philip Chmalts Date: Wed, 2 Sep 2026 11:17:00 -0700 Subject: [PATCH 02/10] feat(mcp): add the authenticated Seam MCP registration constants The claude mcp add argv, the equivalent .mcp.json snippet, and one-line Cursor/Codex/OpenCode hints, all built from a single authenticated-URL constant. Tests pin the exact argv and assert nothing emitted here points at the anonymous /mcp or could carry an API key. PLA-2951 Co-Authored-By: Claude Fable 5.1 --- src/lib/steps/register-seam-mcp.test.ts | 74 +++++++++++++++++++++++++ src/lib/steps/register-seam-mcp.ts | 47 ++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 src/lib/steps/register-seam-mcp.test.ts create mode 100644 src/lib/steps/register-seam-mcp.ts diff --git a/src/lib/steps/register-seam-mcp.test.ts b/src/lib/steps/register-seam-mcp.test.ts new file mode 100644 index 0000000..e6e148e --- /dev/null +++ b/src/lib/steps/register-seam-mcp.test.ts @@ -0,0 +1,74 @@ +import { expect, test } from 'vitest' + +import { + AUTHENTICATED_SEAM_MCP_URL, + CLAUDE_MCP_ADD_COMMAND, + mcpJsonSnippet, + SEAM_MCP_SERVER_NAME, + UNIVERSAL_MCP_HINTS, +} from './register-seam-mcp.js' + +test('CLAUDE_MCP_ADD_COMMAND is the exact non-interactive argv', () => { + expect(CLAUDE_MCP_ADD_COMMAND).toEqual([ + 'claude', + 'mcp', + 'add', + '--transport', + 'http', + '--scope', + 'project', + 'seam', + 'https://mcp.seam.co/mcp/authenticated', + ]) +}) + +test('mcpJsonSnippet is valid JSON registering the authenticated URL', () => { + const snippet = JSON.parse(mcpJsonSnippet()) as { + mcpServers: Record + } + + expect(Object.keys(snippet.mcpServers)).toEqual([SEAM_MCP_SERVER_NAME]) + expect(snippet.mcpServers[SEAM_MCP_SERVER_NAME]).toEqual({ + type: 'http', + url: AUTHENTICATED_SEAM_MCP_URL, + }) +}) + +test('UNIVERSAL_MCP_HINTS names one config file per supported tool', () => { + expect(UNIVERSAL_MCP_HINTS).toHaveLength(3) + expect(UNIVERSAL_MCP_HINTS[0]).toContain('Cursor') + expect(UNIVERSAL_MCP_HINTS[0]).toContain('.cursor/mcp.json') + expect(UNIVERSAL_MCP_HINTS[1]).toContain('Codex') + expect(UNIVERSAL_MCP_HINTS[1]).toContain('.codex/config.toml') + expect(UNIVERSAL_MCP_HINTS[2]).toContain('OpenCode') + expect(UNIVERSAL_MCP_HINTS[2]).toContain('opencode.json') + for (const hint of UNIVERSAL_MCP_HINTS) { + expect(hint.split('\n')).toHaveLength(1) + } +}) + +// The anonymous /mcp is the plugin's and the embedded harnesses' server. Every +// URL this module hands the developer's own agent must be the authenticated one, +// or the agent gets docs instead of a delegated grant. +test('nothing this module emits points at the anonymous MCP', () => { + const anonymousUrl = /mcp\.seam\.co\/mcp(?!\/authenticated)/ + + for (const emitted of [ + CLAUDE_MCP_ADD_COMMAND.join(' '), + mcpJsonSnippet(), + ...UNIVERSAL_MCP_HINTS, + ]) { + expect(emitted).not.toMatch(anonymousUrl) + } +}) + +test('nothing this module emits could carry an API key', () => { + for (const emitted of [ + CLAUDE_MCP_ADD_COMMAND.join(' '), + mcpJsonSnippet(), + ...UNIVERSAL_MCP_HINTS, + ]) { + expect(emitted).not.toContain('SEAM_API_KEY') + expect(emitted).not.toMatch(/seam_[A-Za-z0-9]/) + } +}) diff --git a/src/lib/steps/register-seam-mcp.ts b/src/lib/steps/register-seam-mcp.ts new file mode 100644 index 0000000..e736fa5 --- /dev/null +++ b/src/lib/steps/register-seam-mcp.ts @@ -0,0 +1,47 @@ +// The authenticated Seam MCP. Unlike the anonymous https://mcp.seam.co/mcp the +// plugin and the embedded harnesses use, this endpoint answers an unauthenticated +// request with 401 + WWW-Authenticate, which is what makes a coding agent start +// the OAuth consent flow and end up on its own delegated grant instead of +// borrowing the app's key from .env. +export const AUTHENTICATED_SEAM_MCP_URL = + 'https://mcp.seam.co/mcp/authenticated' + +export const SEAM_MCP_SERVER_NAME = 'seam' + +// Project scope writes .mcp.json in the project root, so the registration +// travels with the repo the wizard just set up. No flag here prompts, so the +// wizard can spawn it with stdin ignored like every other install. +export const CLAUDE_MCP_ADD_COMMAND = [ + 'claude', + 'mcp', + 'add', + '--transport', + 'http', + '--scope', + 'project', + SEAM_MCP_SERVER_NAME, + AUTHENTICATED_SEAM_MCP_URL, +] + +// What `claude mcp add` would have written, for the developer to paste when the +// CLI is missing or another agent is in use. +export function mcpJsonSnippet(): string { + return JSON.stringify( + { + mcpServers: { + [SEAM_MCP_SERVER_NAME]: { + type: 'http', + url: AUTHENTICATED_SEAM_MCP_URL, + }, + }, + }, + null, + 2, + ) +} + +export const UNIVERSAL_MCP_HINTS = [ + `Cursor — add the same mcpServers block to .cursor/mcp.json`, + `Codex — add [mcp_servers.${SEAM_MCP_SERVER_NAME}] with url = "${AUTHENTICATED_SEAM_MCP_URL}" to ~/.codex/config.toml`, + `OpenCode — add "${SEAM_MCP_SERVER_NAME}": { "type": "remote", "url": "${AUTHENTICATED_SEAM_MCP_URL}" } under "mcp" in opencode.json`, +] as const From ea5f7953b67d4be5d6b316e4434ccf7b7de3619c Mon Sep 17 00:00:00 2001 From: Philip Chmalts Date: Wed, 2 Sep 2026 11:18:00 -0700 Subject: [PATCH 03/10] docs(plans): prettier-format the PLA-2951 plan Co-Authored-By: Claude Fable 5.1 --- ...09-02-pla-2951-agent-grant-registration.md | 263 +++++++++--------- 1 file changed, 135 insertions(+), 128 deletions(-) diff --git a/docs/superpowers/plans/2026-09-02-pla-2951-agent-grant-registration.md b/docs/superpowers/plans/2026-09-02-pla-2951-agent-grant-registration.md index 0165056..1fe167a 100644 --- a/docs/superpowers/plans/2026-09-02-pla-2951-agent-grant-registration.md +++ b/docs/superpowers/plans/2026-09-02-pla-2951-agent-grant-registration.md @@ -2,7 +2,7 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** After the wizard installs the Seam plugin skills, register the *authenticated* Seam MCP (`https://mcp.seam.co/mcp/authenticated`) with the developer's coding agent — via `claude mcp add` when Claude Code is detected, by printing the equivalent `.mcp.json` snippet otherwise — and refuse to write `SEAM_API_KEY` through a symlinked `.env`. +**Goal:** After the wizard installs the Seam plugin skills, register the _authenticated_ Seam MCP (`https://mcp.seam.co/mcp/authenticated`) with the developer's coding agent — via `claude mcp add` when Claude Code is detected, by printing the equivalent `.mcp.json` snippet otherwise — and refuse to write `SEAM_API_KEY` through a symlinked `.env`. **Architecture:** One new module, `src/lib/steps/register-seam-mcp.ts`, holds every constant and string this feature needs (the `claude mcp add` argv, the `.mcp.json` snippet, the three per-tool hints, the printed-notice composer) plus a thin runner that spawns the argv through the existing `runInstall` seam and maps failure to a printed fallback. `src/lib/app.tsx`'s `install-plugin` phase calls the runner, reports the outcome on `wizard_install_finished`, and renders the composed notices through the existing `addMessage`. `src/lib/env-file.ts` gains an `lstat` guard and a fourth `EnvWriteResult` variant that the three save paths propagate so the Ink app can tell the developer to add the key by hand. @@ -27,18 +27,18 @@ ## File Structure -| File | Responsibility | Change | -|---|---|---| -| `src/lib/steps/register-seam-mcp.ts` | Every string and decision for authenticated-MCP registration: the `claude mcp add` argv, the `.mcp.json` snippet, the Cursor/Codex/OpenCode hints, the notice composer, and the runner that spawns the argv | **Create** | -| `src/lib/steps/register-seam-mcp.test.ts` | Unit tests for that module: exact argv, snippet JSON, hints, runner outcomes (injected runner), composed notices | **Create** | -| `src/lib/app.tsx` | The Ink app. `install-plugin` phase (lines 725-780) runs the skills install, then registration; reports `wizard_install_finished`; renders notices | Modify the `install-plugin` effect, the import block, and the four env-write call sites | -| `src/lib/screens/done.tsx` | Final screen | Add the exported `AGENT_CONSENT_NOTICE` copy line below the card | -| `src/lib/screens/done.test.tsx` | Done-screen render tests | Add one test for the consent copy | -| `src/lib/env-file.ts` | dotenv read/write helpers | `lstat` guard in `upsertEnvVar`, new `'symlink-refused'` variant, exported refusal message | -| `src/lib/env-file.test.ts` | Unit tests for those helpers | Add symlink tests | -| `src/lib/steps/authenticate.ts` | Pure auth logic | `saveVerifiedKey` returns the env result; `AuthResult` carries it | -| `src/lib/steps/authenticate.test.ts` | Unit tests for auth | Add one propagation test | -| `src/lib/steps/connect-web.ts` | Browser → CLI key handoff | `WebConnectResult` carries the env result | +| File | Responsibility | Change | +| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| `src/lib/steps/register-seam-mcp.ts` | Every string and decision for authenticated-MCP registration: the `claude mcp add` argv, the `.mcp.json` snippet, the Cursor/Codex/OpenCode hints, the notice composer, and the runner that spawns the argv | **Create** | +| `src/lib/steps/register-seam-mcp.test.ts` | Unit tests for that module: exact argv, snippet JSON, hints, runner outcomes (injected runner), composed notices | **Create** | +| `src/lib/app.tsx` | The Ink app. `install-plugin` phase (lines 725-780) runs the skills install, then registration; reports `wizard_install_finished`; renders notices | Modify the `install-plugin` effect, the import block, and the four env-write call sites | +| `src/lib/screens/done.tsx` | Final screen | Add the exported `AGENT_CONSENT_NOTICE` copy line below the card | +| `src/lib/screens/done.test.tsx` | Done-screen render tests | Add one test for the consent copy | +| `src/lib/env-file.ts` | dotenv read/write helpers | `lstat` guard in `upsertEnvVar`, new `'symlink-refused'` variant, exported refusal message | +| `src/lib/env-file.test.ts` | Unit tests for those helpers | Add symlink tests | +| `src/lib/steps/authenticate.ts` | Pure auth logic | `saveVerifiedKey` returns the env result; `AuthResult` carries it | +| `src/lib/steps/authenticate.test.ts` | Unit tests for auth | Add one propagation test | +| `src/lib/steps/connect-web.ts` | Browser → CLI key handoff | `WebConnectResult` carries the env result | Task order: 1 (pure strings) → 2 (runner) → 3 (app wiring + analytics + done copy) → 4 (`.env` hardening, independent of 1-3) → 5 (verify + PR). @@ -47,10 +47,12 @@ Task order: 1 (pure strings) → 2 (runner) → 3 (app wiring + analytics + done ### Task 1: The registration constants, snippet, and hints **Files:** + - Create: `src/lib/steps/register-seam-mcp.ts` - Create: `src/lib/steps/register-seam-mcp.test.ts` **Interfaces:** + - Consumes: `type PluginTarget = 'claude-code' | 'universal'` from `./install-seam-plugin.js` (already exported at `src/lib/steps/install-seam-plugin.ts:4`). - Produces (all used by Tasks 2 and 3): - `const AUTHENTICATED_SEAM_MCP_URL: string` @@ -233,10 +235,12 @@ Co-Authored-By: Claude Fable 5.1 " ### Task 2: The registration runner **Files:** + - Modify: `src/lib/steps/register-seam-mcp.ts` (append the runner below the constants from Task 1) - Modify: `src/lib/steps/register-seam-mcp.test.ts` (append the runner tests) **Interfaces:** + - Consumes: `runInstall(command: string[], cwd: string, onLine: (line: string) => void): Promise` from `lib/run-install.js`. It spawns with `stdio: ['ignore', 'pipe', 'pipe']`, `shell: false`, no `env` override (so the child inherits the wizard's environment and nothing key-bearing is added); it rejects with the spawn `error` event — an `Error` whose `code` is `'ENOENT'` when the binary is missing — and with `new Error("claude exited with code ")` on a non-zero close. Also `CLAUDE_MCP_ADD_COMMAND` from Task 1. - Produces: - `type McpRegistration = 'claude_cli' | 'printed' | 'failed'` @@ -411,6 +415,7 @@ Co-Authored-By: Claude Fable 5.1 " ### Task 3: Wire registration into the install-plugin phase, analytics, and the done screen **Files:** + - Modify: `src/lib/steps/register-seam-mcp.ts` (add the notice composer) - Modify: `src/lib/steps/register-seam-mcp.test.ts` (composer tests) - Modify: `src/lib/app.tsx` — the import block (lines 74-79) and the `install-plugin` effect (lines 725-780) @@ -418,6 +423,7 @@ Co-Authored-By: Claude Fable 5.1 " - Modify: `src/lib/screens/done.test.tsx` **Interfaces:** + - Consumes from Tasks 1-2: `CLAUDE_MCP_ADD_COMMAND`, `mcpJsonSnippet()`, `UNIVERSAL_MCP_HINTS`, `type McpRegistration`, `registerSeamMcpWithClaudeCli`. From existing code: `detectPluginTarget(root): PluginTarget`, `SEAM_PLUGIN_NPX_COMMAND`, `CLAUDE_CODE_COMMANDS` (`src/lib/steps/install-seam-plugin.ts`); `addMessage(message: { tone: 'ok' | 'info' | 'warn' | 'plain'; text: string }): void` (`app.tsx:205`); `trackInstallFinished(target: 'sdk' | 'plugin', ok: boolean, properties: Record): void` (`app.tsx:288`) — its third parameter is already `Record`, so adding `mcp_registration` needs no signature change; typing comes from declaring the value as `McpRegistration` at the call site. - Produces: - `interface McpNotice { tone: 'info' | 'warn' | 'plain'; text: string }` @@ -544,7 +550,10 @@ export function buildMcpRegistrationNotices({ const hintLines: McpNotice[] = target === 'universal' - ? UNIVERSAL_MCP_HINTS.map((hint) => ({ tone: 'plain', text: ` ${hint}` })) + ? UNIVERSAL_MCP_HINTS.map((hint) => ({ + tone: 'plain', + text: ` ${hint}`, + })) : [] return [heading, ...snippetLines, ...hintLines] @@ -572,89 +581,89 @@ import { Replace the whole body of the `install-plugin` effect (`src/lib/app.tsx:725-780`, the block whose comment begins `// install the official Seam plugin skills, then finish.`) with: ```tsx - // install the official Seam plugin skills, then register the authenticated - // Seam MCP so the developer's own agent gets a delegated grant instead of - // reading the app's key out of .env. For Claude Code we additionally point at - // the native /plugin path, which wires up the anonymous docs MCP. - useEffect(() => { - if (phase.t !== 'install-plugin') return - const target = detectPluginTarget(root) - - let cancelled = false - const streamLine = (line: string): void => { - if (!cancelled) { - setInstallLines((previous) => [...previous.slice(-3), line]) - } +// install the official Seam plugin skills, then register the authenticated +// Seam MCP so the developer's own agent gets a delegated grant instead of +// reading the app's key out of .env. For Claude Code we additionally point at +// the native /plugin path, which wires up the anonymous docs MCP. +useEffect(() => { + if (phase.t !== 'install-plugin') return + const target = detectPluginTarget(root) + + let cancelled = false + const streamLine = (line: string): void => { + if (!cancelled) { + setInstallLines((previous) => [...previous.slice(-3), line]) + } + } + const run = async (): Promise => { + installStartedAtRef.current = Date.now() + let skillsInstalled = true + try { + await runInstall(SEAM_PLUGIN_NPX_COMMAND, root, streamLine) + } catch { + skillsInstalled = false } - const run = async (): Promise => { - installStartedAtRef.current = Date.now() - let skillsInstalled = true + if (cancelled) return + + addMessage( + skillsInstalled + ? { tone: 'ok', text: 'Installed the Seam plugin skills' } + : { + tone: 'warn', + text: `Couldn't install the plugin — run it yourself: ${SEAM_PLUGIN_NPX_COMMAND.join(' ')}`, + }, + ) + + let registration: McpRegistration = 'printed' + if (target === 'claude-code') { try { - await runInstall(SEAM_PLUGIN_NPX_COMMAND, root, streamLine) + registration = await registerSeamMcpWithClaudeCli({ + root, + onLine: streamLine, + }) } catch { - skillsInstalled = false + registration = 'failed' } - if (cancelled) return - - addMessage( - skillsInstalled - ? { tone: 'ok', text: 'Installed the Seam plugin skills' } - : { - tone: 'warn', - text: `Couldn't install the plugin — run it yourself: ${SEAM_PLUGIN_NPX_COMMAND.join(' ')}`, - }, - ) - - let registration: McpRegistration = 'printed' - if (target === 'claude-code') { - try { - registration = await registerSeamMcpWithClaudeCli({ - root, - onLine: streamLine, - }) - } catch { - registration = 'failed' - } - } - if (cancelled) return + } + if (cancelled) return - trackInstallFinished('plugin', skillsInstalled, { - plugin_target: target, - mcp_registration: registration, - }) - for (const notice of buildMcpRegistrationNotices({ - target, - registration, - })) { - addMessage(notice) - } + trackInstallFinished('plugin', skillsInstalled, { + plugin_target: target, + mcp_registration: registration, + }) + for (const notice of buildMcpRegistrationNotices({ + target, + registration, + })) { + addMessage(notice) + } - setInstallLines([]) - if (target === 'claude-code') { - addMessage({ - tone: 'info', - text: 'Claude Code: for the native plugin + seam-docs MCP, you can also run:', - }) - for (const command of CLAUDE_CODE_COMMANDS) { - addMessage({ tone: 'plain', text: ` ${command}` }) - } + setInstallLines([]) + if (target === 'claude-code') { + addMessage({ + tone: 'info', + text: 'Claude Code: for the native plugin + seam-docs MCP, you can also run:', + }) + for (const command of CLAUDE_CODE_COMMANDS) { + addMessage({ tone: 'plain', text: ` ${command}` }) } - setPhase({ t: 'offer-integrate' }) } - run().catch((error: unknown) => { - if (cancelled) return - setPhase({ - t: 'error', - message: - error instanceof Error - ? error.message - : 'The wizard hit an unexpected error.', - }) + setPhase({ t: 'offer-integrate' }) + } + run().catch((error: unknown) => { + if (cancelled) return + setPhase({ + t: 'error', + message: + error instanceof Error + ? error.message + : 'The wizard hit an unexpected error.', }) - return () => { - cancelled = true - } - }, [phase.t]) + }) + return () => { + cancelled = true + } +}, [phase.t]) ``` Three things changed beyond the new registration: the streamed-line closure is hoisted so both spawns share it, the skills-install messages moved out of the `try`/`catch` so registration can run before the analytics event, and `trackInstallFinished` fires once with both properties. @@ -673,15 +682,15 @@ export const AGENT_CONSENT_NOTICE = Render it in the outer column, between the assistant-link block and the "Press any key to exit" margin box (it sits outside the bordered card, which is too narrow for a sentence this long): ```tsx - - {AGENT_CONSENT_NOTICE} - + + {AGENT_CONSENT_NOTICE} + ``` - [ ] **Step 7: Write the done-screen test** @@ -749,6 +758,7 @@ Co-Authored-By: Claude Fable 5.1 " ### Task 4: Refuse to write `SEAM_API_KEY` through a symlink **Files:** + - Modify: `src/lib/env-file.ts:1` (imports), `:4` (`EnvWriteResult`), `:103-126` (`upsertEnvVar`) - Modify: `src/lib/env-file.test.ts` - Modify: `src/lib/steps/authenticate.ts:5-19` (`AuthResult`), `:62-64` (`saveVerifiedKey`) @@ -757,6 +767,7 @@ Co-Authored-By: Claude Fable 5.1 " - Modify: `src/lib/app.tsx` — `useCliKey` (~line 429), `useProjectKey` (~line 445), the `verify-paste` effect (~line 636), the `browser` effect (~line 584) **Interfaces:** + - Consumes: `existsSync`, `readFileSync`, `writeFileSync` from `node:fs` (already imported in `env-file.ts`); `type ProjectEnvResult { env: EnvWriteResult; example: EnvWriteResult | 'unchanged'; gitignore: 'added' | 'unchanged' }` and `saveProjectApiKey(root: string, apiKey: string): ProjectEnvResult` (already exported). - Produces: - `type EnvWriteResult = 'created' | 'updated' | 'added' | 'symlink-refused'` (fourth variant added) @@ -850,11 +861,7 @@ Change the imports on line 1 and the type on line 4: import { existsSync, lstatSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' -export type EnvWriteResult = - | 'created' - | 'updated' - | 'added' - | 'symlink-refused' +export type EnvWriteResult = 'created' | 'updated' | 'added' | 'symlink-refused' export const ENV_SYMLINK_REFUSAL_MESSAGE = '.env is a symlink — the wizard did not write through it. Add SEAM_API_KEY to the real file yourself.' @@ -931,12 +938,12 @@ export interface WebConnectResult { ``` ```ts - const workspace = await getWorkspaceForApiKey(payload.api_key) - return { - workspace, - api_key: payload.api_key, - env: saveProjectApiKey(root, payload.api_key), - } +const workspace = await getWorkspaceForApiKey(payload.api_key) +return { + workspace, + api_key: payload.api_key, + env: saveProjectApiKey(root, payload.api_key), +} ``` - [ ] **Step 5: Surface the refusal in `app.tsx`** @@ -955,11 +962,11 @@ import { Add this helper immediately below `addMessage` (`src/lib/app.tsx:205-206`): ```tsx - const reportEnvWrite = (result: ProjectEnvResult): void => { - if (result.env === 'symlink-refused') { - addMessage({ tone: 'warn', text: ENV_SYMLINK_REFUSAL_MESSAGE }) - } +const reportEnvWrite = (result: ProjectEnvResult): void => { + if (result.env === 'symlink-refused') { + addMessage({ tone: 'warn', text: ENV_SYMLINK_REFUSAL_MESSAGE }) } +} ``` Then call it at the four save sites. `useCliKey` (~line 429): @@ -978,31 +985,31 @@ Then call it at the four save sites. `useCliKey` (~line 429): `useProjectKey` (~line 445) — only the `environment` branch writes a key: ```tsx - if (found.source === 'environment') { - reportEnvWrite(saveVerifiedKey(root, found.api_key)) - } else { - ensureProjectEnvConventions(root) - } +if (found.source === 'environment') { + reportEnvWrite(saveVerifiedKey(root, found.api_key)) +} else { + ensureProjectEnvConventions(root) +} ``` The `verify-paste` effect (~line 636): ```tsx - const result = await verifyAndSaveKey(root, apiKey) - if (cancelled) return - reportEnvWrite(result.env) - addMessage({ tone: 'ok', text: `Workspace: ${result.workspace.name}` }) +const result = await verifyAndSaveKey(root, apiKey) +if (cancelled) return +reportEnvWrite(result.env) +addMessage({ tone: 'ok', text: `Workspace: ${result.workspace.name}` }) ``` The `browser` effect (~line 595), right after the `if (cancelled) return`: ```tsx - if (cancelled) return - reportEnvWrite(result.env) - addMessage({ - tone: 'ok', - text: `Connected · workspace ${result.workspace.name}`, - }) +if (cancelled) return +reportEnvWrite(result.env) +addMessage({ + tone: 'ok', + text: `Connected · workspace ${result.workspace.name}`, +}) ``` - [ ] **Step 6: Run the affected tests** @@ -1074,7 +1081,7 @@ Expected: no output. The harnesses keep the anonymous `https://mcp.seam.co/mcp`. Run: `git diff main...HEAD -U0 -- src | grep -E '^\+\s*(//|/\*|\*)'` -For each: would it read identically at every similar site (generality)? Is a test already the explanation (test coverage)? Is it aimed at a reviewer (audience)? Delete any that fail and amend. The ones written here are meant to survive: each records a *why* the code cannot state — why the authenticated URL differs from the plugin's, why a spawn failure is a fallback rather than an error, why `lstat` has to precede `existsSync`. +For each: would it read identically at every similar site (generality)? Is a test already the explanation (test coverage)? Is it aimed at a reviewer (audience)? Delete any that fail and amend. The ones written here are meant to survive: each records a _why_ the code cannot state — why the authenticated URL differs from the plugin's, why a spawn failure is a fallback rather than an error, why `lstat` has to precede `existsSync`. - [ ] **Step 5: Push and open the PR** From 4195dd8f386fd2b0dece6c6617101776e866b0ae Mon Sep 17 00:00:00 2001 From: Philip Chmalts Date: Wed, 2 Sep 2026 11:21:58 -0700 Subject: [PATCH 04/10] feat(mcp): spawn claude mcp add with a printed fallback registerSeamMcpWithClaudeCli runs the argv through the same runInstall spawn the SDK and plugin installs use (stdin ignored, no env override, so no API key can reach the child) and maps a missing binary or non-zero exit to 'printed' so the caller can show the .mcp.json snippet instead. PLA-2951 Co-Authored-By: Claude Fable 5.1 --- src/lib/steps/register-seam-mcp.test.ts | 61 +++++++++++++++++++++++++ src/lib/steps/register-seam-mcp.ts | 36 +++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/src/lib/steps/register-seam-mcp.test.ts b/src/lib/steps/register-seam-mcp.test.ts index e6e148e..9fc8e5a 100644 --- a/src/lib/steps/register-seam-mcp.test.ts +++ b/src/lib/steps/register-seam-mcp.test.ts @@ -4,6 +4,7 @@ import { AUTHENTICATED_SEAM_MCP_URL, CLAUDE_MCP_ADD_COMMAND, mcpJsonSnippet, + registerSeamMcpWithClaudeCli, SEAM_MCP_SERVER_NAME, UNIVERSAL_MCP_HINTS, } from './register-seam-mcp.js' @@ -72,3 +73,63 @@ test('nothing this module emits could carry an API key', () => { expect(emitted).not.toMatch(/seam_[A-Za-z0-9]/) } }) + +test('registerSeamMcpWithClaudeCli reports claude_cli after a clean run', async () => { + const calls: Array<{ command: string[]; cwd: string }> = [] + + const registration = await registerSeamMcpWithClaudeCli({ + root: '/tmp/seam-wizard-project', + onLine: () => {}, + runCommand: async (command, cwd) => { + calls.push({ command, cwd }) + }, + }) + + expect(registration).toBe('claude_cli') + expect(calls).toEqual([ + { + command: CLAUDE_MCP_ADD_COMMAND, + cwd: '/tmp/seam-wizard-project', + }, + ]) +}) + +// The developer may not have the Claude Code CLI on PATH at all: spawn rejects +// with ENOENT before anything runs, and the wizard has to fall back to printing. +test('registerSeamMcpWithClaudeCli reports printed when the binary is missing', async () => { + const registration = await registerSeamMcpWithClaudeCli({ + root: '/tmp/seam-wizard-project', + onLine: () => {}, + runCommand: async () => { + throw Object.assign(new Error('spawn claude ENOENT'), { code: 'ENOENT' }) + }, + }) + + expect(registration).toBe('printed') +}) + +test('registerSeamMcpWithClaudeCli reports printed on a non-zero exit', async () => { + const registration = await registerSeamMcpWithClaudeCli({ + root: '/tmp/seam-wizard-project', + onLine: () => {}, + runCommand: async () => { + throw new Error('claude exited with code 1') + }, + }) + + expect(registration).toBe('printed') +}) + +test('registerSeamMcpWithClaudeCli streams the command output it is given', async () => { + const lines: string[] = [] + + await registerSeamMcpWithClaudeCli({ + root: '/tmp/seam-wizard-project', + onLine: (line) => lines.push(line), + runCommand: async (_command, _cwd, onLine) => { + onLine('Added HTTP MCP server seam') + }, + }) + + expect(lines).toEqual(['Added HTTP MCP server seam']) +}) diff --git a/src/lib/steps/register-seam-mcp.ts b/src/lib/steps/register-seam-mcp.ts index e736fa5..13c2967 100644 --- a/src/lib/steps/register-seam-mcp.ts +++ b/src/lib/steps/register-seam-mcp.ts @@ -1,3 +1,5 @@ +import { runInstall } from 'lib/run-install.js' + // The authenticated Seam MCP. Unlike the anonymous https://mcp.seam.co/mcp the // plugin and the embedded harnesses use, this endpoint answers an unauthenticated // request with 401 + WWW-Authenticate, which is what makes a coding agent start @@ -6,6 +8,19 @@ export const AUTHENTICATED_SEAM_MCP_URL = 'https://mcp.seam.co/mcp/authenticated' +// What the run did about MCP registration, as reported on +// wizard_install_finished. 'printed' covers both fallbacks — a missing or +// failing CLI, and a non-Claude-Code project that only gets the snippet. +// 'failed' is the caller's outcome when the registration step itself threw, so +// the developer got neither a registration nor a snippet. +export type McpRegistration = 'claude_cli' | 'printed' | 'failed' + +export type RunCommand = ( + command: string[], + cwd: string, + onLine: (line: string) => void, +) => Promise + export const SEAM_MCP_SERVER_NAME = 'seam' // Project scope writes .mcp.json in the project root, so the registration @@ -45,3 +60,24 @@ export const UNIVERSAL_MCP_HINTS = [ `Codex — add [mcp_servers.${SEAM_MCP_SERVER_NAME}] with url = "${AUTHENTICATED_SEAM_MCP_URL}" to ~/.codex/config.toml`, `OpenCode — add "${SEAM_MCP_SERVER_NAME}": { "type": "remote", "url": "${AUTHENTICATED_SEAM_MCP_URL}" } under "mcp" in opencode.json`, ] as const + +// Register the authenticated MCP with the Claude Code CLI, in the project the +// wizard is setting up. Any spawn failure — no `claude` on PATH (ENOENT), or a +// non-zero exit — is a fallback, not an error: the caller prints the snippet +// instead. `runCommand` is injected so a test can drive both outcomes. +export async function registerSeamMcpWithClaudeCli({ + root, + onLine, + runCommand = runInstall, +}: { + root: string + onLine: (line: string) => void + runCommand?: RunCommand +}): Promise<'claude_cli' | 'printed'> { + try { + await runCommand(CLAUDE_MCP_ADD_COMMAND, root, onLine) + return 'claude_cli' + } catch { + return 'printed' + } +} From adc26fc6c1ced2ca59789f5bba2d59f4088b4875 Mon Sep 17 00:00:00 2001 From: Philip Chmalts Date: Wed, 2 Sep 2026 11:27:51 -0700 Subject: [PATCH 05/10] feat(wizard): register the authenticated Seam MCP after the skills install The install-plugin phase now runs claude mcp add in the project root when Claude Code is detected, and prints the .mcp.json snippet (plus Cursor, Codex, and OpenCode hints for a universal project) when it cannot. The outcome rides on wizard_install_finished as mcp_registration, and the done screen tells the developer their agent will sign in and pick permissions on first use. The /plugin lines stay as the optional docs-plugin path. PLA-2951 Co-Authored-By: Claude Fable 5.1 --- src/lib/app.tsx | 86 +++++++++++++++++-------- src/lib/screens/done.test.tsx | 26 +++++++- src/lib/screens/done.tsx | 14 ++++ src/lib/steps/register-seam-mcp.test.ts | 54 ++++++++++++++++ src/lib/steps/register-seam-mcp.ts | 51 +++++++++++++++ 5 files changed, 203 insertions(+), 28 deletions(-) diff --git a/src/lib/app.tsx b/src/lib/app.tsx index 5531c34..9334c60 100644 --- a/src/lib/app.tsx +++ b/src/lib/app.tsx @@ -77,6 +77,11 @@ import { SEAM_PLUGIN_NPX_COMMAND, } from './steps/install-seam-plugin.js' import { type IntegrateEvent, runIntegration } from './steps/integrate.js' +import { + buildMcpRegistrationNotices, + type McpRegistration, + registerSeamMcpWithClaudeCli, +} from './steps/register-seam-mcp.js' import { type ProjectPlan, readPreferredSdk, @@ -722,47 +727,74 @@ export function App({ } }, [phase.t, sdk]) - // install the official Seam plugin skills, then finish. We always run the - // universal installer (works everywhere); for Claude Code we additionally - // point at the native /plugin path, which also wires up the seam-docs MCP. + // install the official Seam plugin skills, then register the authenticated + // Seam MCP so the developer's own agent gets a delegated grant instead of + // reading the app's key out of .env. For Claude Code we additionally point at + // the native /plugin path, which wires up the anonymous docs MCP. useEffect(() => { if (phase.t !== 'install-plugin') return const target = detectPluginTarget(root) let cancelled = false + const streamLine = (line: string): void => { + if (!cancelled) { + setInstallLines((previous) => [...previous.slice(-3), line]) + } + } const run = async (): Promise => { installStartedAtRef.current = Date.now() + let skillsInstalled = true try { - await runInstall(SEAM_PLUGIN_NPX_COMMAND, root, (line) => { - if (!cancelled) { - setInstallLines((previous) => [...previous.slice(-3), line]) - } - }) - if (!cancelled) { - trackInstallFinished('plugin', true, { plugin_target: target }) - addMessage({ tone: 'ok', text: 'Installed the Seam plugin skills' }) - } + await runInstall(SEAM_PLUGIN_NPX_COMMAND, root, streamLine) } catch { - if (!cancelled) { - trackInstallFinished('plugin', false, { plugin_target: target }) - addMessage({ - tone: 'warn', - text: `Couldn't install the plugin — run it yourself: ${SEAM_PLUGIN_NPX_COMMAND.join(' ')}`, + skillsInstalled = false + } + if (cancelled) return + + addMessage( + skillsInstalled + ? { tone: 'ok', text: 'Installed the Seam plugin skills' } + : { + tone: 'warn', + text: `Couldn't install the plugin — run it yourself: ${SEAM_PLUGIN_NPX_COMMAND.join(' ')}`, + }, + ) + + let registration: McpRegistration = 'printed' + if (target === 'claude-code') { + try { + registration = await registerSeamMcpWithClaudeCli({ + root, + onLine: streamLine, }) + } catch { + registration = 'failed' } } - if (!cancelled) { - if (target === 'claude-code') { - addMessage({ - tone: 'info', - text: 'Claude Code: for the native plugin + seam-docs MCP, you can also run:', - }) - for (const command of CLAUDE_CODE_COMMANDS) { - addMessage({ tone: 'plain', text: ` ${command}` }) - } + if (cancelled) return + + trackInstallFinished('plugin', skillsInstalled, { + plugin_target: target, + mcp_registration: registration, + }) + for (const notice of buildMcpRegistrationNotices({ + target, + registration, + })) { + addMessage(notice) + } + + setInstallLines([]) + if (target === 'claude-code') { + addMessage({ + tone: 'info', + text: 'Claude Code: for the native plugin + seam-docs MCP, you can also run:', + }) + for (const command of CLAUDE_CODE_COMMANDS) { + addMessage({ tone: 'plain', text: ` ${command}` }) } - setPhase({ t: 'offer-integrate' }) } + setPhase({ t: 'offer-integrate' }) } run().catch((error: unknown) => { if (cancelled) return diff --git a/src/lib/screens/done.test.tsx b/src/lib/screens/done.test.tsx index f6bdaed..9a28952 100644 --- a/src/lib/screens/done.test.tsx +++ b/src/lib/screens/done.test.tsx @@ -1,7 +1,7 @@ import { render } from 'ink-testing-library' import { afterEach, expect, test, vi } from 'vitest' -import { DoneScreen } from './done.js' +import { AGENT_CONSENT_NOTICE, DoneScreen } from './done.js' const WORKSPACE_ID = '0004449d-b669-46ac-b094-d530bda66641' @@ -67,3 +67,27 @@ test('DoneScreen: omits the assistant link without a workspace', () => { unmount() } }) + +test('DoneScreen: says the agent will sign in and choose permissions', () => { + const { lastFrame, unmount } = render( + , + ) + try { + // Ink wraps the sentence across rows, so compare on collapsed whitespace. + const frame = (lastFrame() ?? '').replace(/\s+/g, ' ') + expect(frame).toContain(AGENT_CONSENT_NOTICE) + } finally { + unmount() + } +}) + +test('AGENT_CONSENT_NOTICE is the copy the merge design specifies', () => { + expect(AGENT_CONSENT_NOTICE).toBe( + 'Your coding agent will be asked to sign in to Seam and choose permissions the first time it uses a Seam tool.', + ) +}) diff --git a/src/lib/screens/done.tsx b/src/lib/screens/done.tsx index b18121b..1e2e8f3 100644 --- a/src/lib/screens/done.tsx +++ b/src/lib/screens/done.tsx @@ -12,6 +12,11 @@ export interface IntegrationOutcome { totalSteps: number } +// Verbatim from the merge design: the developer is told, before they leave, that +// the agent authenticates itself rather than reusing the app's key. +export const AGENT_CONSENT_NOTICE = + 'Your coding agent will be asked to sign in to Seam and choose permissions the first time it uses a Seam tool.' + // The last screen — a centered celebration of what just shipped. Shows how the // run went and how long it took; the cost is only shown with --show-cost. // `outcome` is null when the wizard finished without running the agent (e.g. @@ -119,6 +124,15 @@ export function DoneScreen({ )} + + {AGENT_CONSENT_NOTICE} + {/* A margin, not a blank : this column is vertically centered, and when that offset lands on a half row Ink overlaps the rows it paints. A blank Text paints a real space, which lands mid-URL diff --git a/src/lib/steps/register-seam-mcp.test.ts b/src/lib/steps/register-seam-mcp.test.ts index 9fc8e5a..984d820 100644 --- a/src/lib/steps/register-seam-mcp.test.ts +++ b/src/lib/steps/register-seam-mcp.test.ts @@ -2,6 +2,7 @@ import { expect, test } from 'vitest' import { AUTHENTICATED_SEAM_MCP_URL, + buildMcpRegistrationNotices, CLAUDE_MCP_ADD_COMMAND, mcpJsonSnippet, registerSeamMcpWithClaudeCli, @@ -133,3 +134,56 @@ test('registerSeamMcpWithClaudeCli streams the command output it is given', asyn expect(lines).toEqual(['Added HTTP MCP server seam']) }) + +test('buildMcpRegistrationNotices confirms a CLI registration without reprinting it', () => { + const notices = buildMcpRegistrationNotices({ + target: 'claude-code', + registration: 'claude_cli', + }) + + expect(notices).toHaveLength(1) + expect(notices[0]?.tone).toBe('info') + expect(notices[0]?.text).toContain('Registered the Seam MCP') + expect(notices.map((notice) => notice.text).join('\n')).not.toContain( + 'mcpServers', + ) +}) + +test('buildMcpRegistrationNotices prints the snippet when the CLI could not register', () => { + const notices = buildMcpRegistrationNotices({ + target: 'claude-code', + registration: 'printed', + }) + const text = notices.map((notice) => notice.text).join('\n') + + expect(text).toContain('.mcp.json') + expect(text).toContain('https://mcp.seam.co/mcp/authenticated') + expect(JSON.parse(mcpJsonSnippet())).toBeTruthy() + // A Claude Code project does not need another agent's config file named at it. + expect(text).not.toContain('opencode.json') +}) + +test('buildMcpRegistrationNotices adds the per-tool hints for a universal project', () => { + const text = buildMcpRegistrationNotices({ + target: 'universal', + registration: 'printed', + }) + .map((notice) => notice.text) + .join('\n') + + for (const hint of UNIVERSAL_MCP_HINTS) { + expect(text).toContain(hint) + } +}) + +test('buildMcpRegistrationNotices warns and prints when registration failed', () => { + const notices = buildMcpRegistrationNotices({ + target: 'claude-code', + registration: 'failed', + }) + const text = notices.map((notice) => notice.text).join('\n') + + expect(notices[0]?.tone).toBe('warn') + expect(text).toContain(CLAUDE_MCP_ADD_COMMAND.join(' ')) + expect(text).toContain('https://mcp.seam.co/mcp/authenticated') +}) diff --git a/src/lib/steps/register-seam-mcp.ts b/src/lib/steps/register-seam-mcp.ts index 13c2967..21fbbe9 100644 --- a/src/lib/steps/register-seam-mcp.ts +++ b/src/lib/steps/register-seam-mcp.ts @@ -1,5 +1,7 @@ import { runInstall } from 'lib/run-install.js' +import type { PluginTarget } from './install-seam-plugin.js' + // The authenticated Seam MCP. Unlike the anonymous https://mcp.seam.co/mcp the // plugin and the embedded harnesses use, this endpoint answers an unauthenticated // request with 401 + WWW-Authenticate, which is what makes a coding agent start @@ -15,6 +17,13 @@ export const AUTHENTICATED_SEAM_MCP_URL = // the developer got neither a registration nor a snippet. export type McpRegistration = 'claude_cli' | 'printed' | 'failed' +// A line for the Ink app to render. The tones are the app's own Msg tones minus +// 'ok', which is reserved there for a step that actually succeeded. +export interface McpNotice { + tone: 'info' | 'warn' | 'plain' + text: string +} + export type RunCommand = ( command: string[], cwd: string, @@ -81,3 +90,45 @@ export async function registerSeamMcpWithClaudeCli({ return 'printed' } } + +export function buildMcpRegistrationNotices({ + target, + registration, +}: { + target: PluginTarget + registration: McpRegistration +}): McpNotice[] { + if (registration === 'claude_cli') { + return [ + { + tone: 'info', + text: 'Registered the Seam MCP for Claude Code in .mcp.json (project scope)', + }, + ] + } + + const heading: McpNotice = + registration === 'failed' + ? { + tone: 'warn', + text: `Couldn't register the Seam MCP — run it yourself: ${CLAUDE_MCP_ADD_COMMAND.join(' ')}`, + } + : { + tone: 'info', + text: 'Add the Seam MCP to your coding agent — put this in .mcp.json:', + } + + const snippetLines: McpNotice[] = mcpJsonSnippet() + .split('\n') + .map((line) => ({ tone: 'plain', text: ` ${line}` })) + + const hintLines: McpNotice[] = + target === 'universal' + ? UNIVERSAL_MCP_HINTS.map((hint) => ({ + tone: 'plain', + text: ` ${hint}`, + })) + : [] + + return [heading, ...snippetLines, ...hintLines] +} From 152d2504ebd5ffacb8bf8849aba94f6876aa8feb Mon Sep 17 00:00:00 2001 From: Philip Chmalts Date: Wed, 2 Sep 2026 11:34:19 -0700 Subject: [PATCH 06/10] fix(env): refuse to write SEAM_API_KEY through a symlinked .env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit existsSync resolves a symlink, so the wizard would happily write the key into whatever the link pointed at — typically a shared secrets file outside the repo that .gitignore does not cover. upsertEnvVar now lstats first and returns 'symlink-refused' without writing; the save paths carry that result up so the app tells the developer to add SEAM_API_KEY by hand. The created/updated/added report and ensureGitignored are unchanged, and there is no destination-approval prompt. PLA-2951 Co-Authored-By: Claude Fable 5.1 --- src/lib/app.tsx | 19 ++++++++++-- src/lib/env-file.test.ts | 49 ++++++++++++++++++++++++++++++ src/lib/env-file.ts | 12 ++++++-- src/lib/steps/authenticate.test.ts | 22 +++++++++++++- src/lib/steps/authenticate.ts | 17 ++++++++--- src/lib/steps/connect-web.ts | 10 ++++-- 6 files changed, 115 insertions(+), 14 deletions(-) diff --git a/src/lib/app.tsx b/src/lib/app.tsx index 9334c60..b90c495 100644 --- a/src/lib/app.tsx +++ b/src/lib/app.tsx @@ -26,7 +26,12 @@ import { type SeamWorkspace, type WizardInferenceSession, } from './api.js' -import { ensureProjectEnvConventions, findExistingApiKey } from './env-file.js' +import { + ensureProjectEnvConventions, + ENV_SYMLINK_REFUSAL_MESSAGE, + findExistingApiKey, + type ProjectEnvResult, +} from './env-file.js' import { runInstall } from './run-install.js' import { AnalyzeScreen } from './screens/analyze.js' import { DoneScreen, type IntegrationOutcome } from './screens/done.js' @@ -210,6 +215,12 @@ export function App({ const addMessage = (message: Msg): void => setMessages((previous) => [...previous, message]) + const reportEnvWrite = (result: ProjectEnvResult): void => { + if (result.env === 'symlink-refused') { + addMessage({ tone: 'warn', text: ENV_SYMLINK_REFUSAL_MESSAGE }) + } + } + // How far the run got with the agent, for the event that closes the run. Read // from the ref, so the unmount cleanup — which closes over the first render — // reports the same thing a clean finish does. @@ -433,7 +444,7 @@ export function App({ const useCliKey = (found: CliKeyResult): void => { try { - saveVerifiedKey(root, found.api_key) + reportEnvWrite(saveVerifiedKey(root, found.api_key)) addMessage({ tone: 'ok', text: `Using your Seam CLI login · workspace ${found.workspace.name} · saved to .env`, @@ -450,7 +461,7 @@ export function App({ const useProjectKey = (found: ExistingKeyResult): void => { try { if (found.source === 'environment') { - saveVerifiedKey(root, found.api_key) + reportEnvWrite(saveVerifiedKey(root, found.api_key)) } else { ensureProjectEnvConventions(root) } @@ -597,6 +608,7 @@ export function App({ }, }) if (cancelled) return + reportEnvWrite(result.env) addMessage({ tone: 'ok', text: `Connected · workspace ${result.workspace.name}`, @@ -640,6 +652,7 @@ export function App({ try { const result = await verifyAndSaveKey(root, apiKey) if (cancelled) return + reportEnvWrite(result.env) addMessage({ tone: 'ok', text: `Workspace: ${result.workspace.name}` }) settleOn(result.workspace, result.api_key, 'pasted', '.env') } catch (error) { diff --git a/src/lib/env-file.test.ts b/src/lib/env-file.test.ts index ac3bd0f..adefb89 100644 --- a/src/lib/env-file.test.ts +++ b/src/lib/env-file.test.ts @@ -4,6 +4,7 @@ import { mkdtempSync, readFileSync, rmSync, + symlinkSync, writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' @@ -14,6 +15,7 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest' import { ensureEnvExample, ensureGitignored, + ENV_SYMLINK_REFUSAL_MESSAGE, findExistingApiKey, saveProjectApiKey, upsertEnvVar, @@ -260,3 +262,50 @@ test('ensureGitignored: writes no git files outside a repository', () => { expect(ensureGitignored(dir, '.env')).toBe('unchanged') expect(existsSync(join(dir, '.gitignore'))).toBe(false) }) + +// A symlinked .env usually points at a shared secrets file outside the repo. +// Writing through it would edit that file — and it is exactly the case where +// the wizard cannot know the destination is the developer's to change. +test('upsertEnvVar refuses a symlinked file and leaves the target untouched', () => { + const targetPath = join(dir, 'shared-secrets.env') + const linkPath = join(dir, '.env') + writeFileSync(targetPath, 'OTHER=1\n') + symlinkSync(targetPath, linkPath) + + expect(upsertEnvVar(linkPath, 'SEAM_API_KEY', 'seam_new')).toBe( + 'symlink-refused', + ) + expect(readFileSync(targetPath, 'utf8')).toBe('OTHER=1\n') +}) + +// existsSync follows the link, so a dangling one would otherwise look absent +// and get created at the far end. +test('upsertEnvVar refuses a dangling symlink without creating its target', () => { + const targetPath = join(dir, 'missing-secrets.env') + const linkPath = join(dir, '.env') + symlinkSync(targetPath, linkPath) + + expect(upsertEnvVar(linkPath, 'SEAM_API_KEY', 'seam_new')).toBe( + 'symlink-refused', + ) + expect(existsSync(targetPath)).toBe(false) +}) + +test('saveProjectApiKey reports the refusal and still ignores .env', () => { + mkdirSync(join(dir, '.git')) + const targetPath = join(dir, 'shared-secrets.env') + writeFileSync(targetPath, 'OTHER=1\n') + symlinkSync(targetPath, join(dir, '.env')) + + const result = saveProjectApiKey(dir, 'seam_new_key') + + expect(result.env).toBe('symlink-refused') + expect(result.gitignore).toBe('added') + expect(readFileSync(targetPath, 'utf8')).not.toContain('seam_new_key') +}) + +test('ENV_SYMLINK_REFUSAL_MESSAGE tells the developer what to do, without a key', () => { + expect(ENV_SYMLINK_REFUSAL_MESSAGE).toContain('SEAM_API_KEY') + expect(ENV_SYMLINK_REFUSAL_MESSAGE).toContain('symlink') + expect(ENV_SYMLINK_REFUSAL_MESSAGE).not.toMatch(/seam_[A-Za-z0-9]/) +}) diff --git a/src/lib/env-file.ts b/src/lib/env-file.ts index bf048e3..4666792 100644 --- a/src/lib/env-file.ts +++ b/src/lib/env-file.ts @@ -1,7 +1,10 @@ -import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { existsSync, lstatSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' -export type EnvWriteResult = 'created' | 'updated' | 'added' +export type EnvWriteResult = 'created' | 'updated' | 'added' | 'symlink-refused' + +export const ENV_SYMLINK_REFUSAL_MESSAGE = + '.env is a symlink — the wizard did not write through it. Add SEAM_API_KEY to the real file yourself.' // dotenv files we look at for an existing key, in priority order. const ENV_FILE_NAMES = [ @@ -107,6 +110,11 @@ export function upsertEnvVar( ): EnvWriteResult { const line = `${key}=${value}` + const link = lstatSync(filePath, { throwIfNoEntry: false }) + if (link?.isSymbolicLink() === true) { + return 'symlink-refused' + } + if (!existsSync(filePath)) { writeFileSync(filePath, `${line}\n`) return 'created' diff --git a/src/lib/steps/authenticate.test.ts b/src/lib/steps/authenticate.test.ts index 2869166..328afa4 100644 --- a/src/lib/steps/authenticate.test.ts +++ b/src/lib/steps/authenticate.test.ts @@ -1,4 +1,10 @@ -import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -56,3 +62,17 @@ test('verifyAndSaveKey returns the trimmed key it saved, not the raw input', asy 'SEAM_API_KEY=seam_padded_key', ) }) + +// The refusal is only useful if it reaches the Ink app, which reads it off the +// result of the save. +test('verifyAndSaveKey reports a symlinked .env instead of writing through it', async () => { + get.mockResolvedValue(workspace) + const targetPath = join(dir, 'shared-secrets.env') + writeFileSync(targetPath, 'OTHER=1\n') + symlinkSync(targetPath, join(dir, '.env')) + + const result = await verifyAndSaveKey(dir, 'seam_pasted_key') + + expect(result.env.env).toBe('symlink-refused') + expect(readFileSync(targetPath, 'utf8')).toBe('OTHER=1\n') +}) diff --git a/src/lib/steps/authenticate.ts b/src/lib/steps/authenticate.ts index e8d807e..83914fb 100644 --- a/src/lib/steps/authenticate.ts +++ b/src/lib/steps/authenticate.ts @@ -1,10 +1,15 @@ import { getAuth } from 'lib/adapter.js' import { getWorkspaceForApiKey, type SeamWorkspace } from 'lib/api.js' -import { findExistingApiKey, saveProjectApiKey } from 'lib/env-file.js' +import { + findExistingApiKey, + type ProjectEnvResult, + saveProjectApiKey, +} from 'lib/env-file.js' export interface AuthResult { workspace: SeamWorkspace api_key: string + env: ProjectEnvResult } export interface ExistingKeyResult { @@ -55,10 +60,12 @@ export async function verifyAndSaveKey( ): Promise { const trimmed = apiKey.trim() const workspace = await getWorkspaceForApiKey(trimmed) - saveProjectApiKey(root, trimmed) - return { workspace, api_key: trimmed } + return { workspace, api_key: trimmed, env: saveProjectApiKey(root, trimmed) } } -export function saveVerifiedKey(root: string, apiKey: string): void { - saveProjectApiKey(root, apiKey) +export function saveVerifiedKey( + root: string, + apiKey: string, +): ProjectEnvResult { + return saveProjectApiKey(root, apiKey) } diff --git a/src/lib/steps/connect-web.ts b/src/lib/steps/connect-web.ts index 6201946..007a26e 100644 --- a/src/lib/steps/connect-web.ts +++ b/src/lib/steps/connect-web.ts @@ -8,7 +8,7 @@ import { getWorkspaceForApiKey, type SeamWorkspace, } from 'lib/api.js' -import { saveProjectApiKey } from 'lib/env-file.js' +import { type ProjectEnvResult, saveProjectApiKey } from 'lib/env-file.js' // The dashboard "wizard" page mints a key and posts it back to the local // callback. The console host itself comes from getConsoleUrl(). @@ -18,6 +18,7 @@ const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 export interface WebConnectResult { workspace: SeamWorkspace api_key: string + env: ProjectEnvResult } // Progress callbacks so the Ink UI can render the handoff without any logging @@ -113,8 +114,11 @@ export async function connectViaWeb( }) const workspace = await getWorkspaceForApiKey(payload.api_key) - saveProjectApiKey(root, payload.api_key) - return { workspace, api_key: payload.api_key } + return { + workspace, + api_key: payload.api_key, + env: saveProjectApiKey(root, payload.api_key), + } } function respondJson( From a4ea58fa8fd8b9a3d843115d961f4ccb16685317 Mon Sep 17 00:00:00 2001 From: Philip Chmalts Date: Wed, 2 Sep 2026 11:54:15 -0700 Subject: [PATCH 07/10] fix(wizard): make the .env refusal and the MCP fallback survive into the exit report The alternate screen is discarded on exit and only buildExitReport is reprinted, so a symlink refusal and the printed .mcp.json fallback were lost, and the exit report still claimed the key was in .env. The report now carries the refusal, a truthful env hint, and the MCP registration outcome; a Claude Code registration that fell back reads as a fallback; the CLI-login line no longer says "saved to .env" when nothing was written; per-tool hints state the URL and config file rather than another tool's schema. PLA-2951 Co-Authored-By: Claude Fable 5.1 --- src/lib/app.tsx | 77 +++++++++++++++++++++---- src/lib/env-file.test.ts | 3 +- src/lib/env-file.ts | 3 + src/lib/steps/register-seam-mcp.test.ts | 17 +++--- src/lib/steps/register-seam-mcp.ts | 10 ++-- 5 files changed, 86 insertions(+), 24 deletions(-) diff --git a/src/lib/app.tsx b/src/lib/app.tsx index b90c495..4689122 100644 --- a/src/lib/app.tsx +++ b/src/lib/app.tsx @@ -28,6 +28,7 @@ import { } from './api.js' import { ensureProjectEnvConventions, + ENV_EXAMPLE_SYMLINK_REFUSAL_MESSAGE, ENV_SYMLINK_REFUSAL_MESSAGE, findExistingApiKey, type ProjectEnvResult, @@ -79,11 +80,13 @@ import { import { CLAUDE_CODE_COMMANDS, detectPluginTarget, + type PluginTarget, SEAM_PLUGIN_NPX_COMMAND, } from './steps/install-seam-plugin.js' import { type IntegrateEvent, runIntegration } from './steps/integrate.js' import { buildMcpRegistrationNotices, + mcpJsonSnippet, type McpRegistration, registerSeamMcpWithClaudeCli, } from './steps/register-seam-mcp.js' @@ -211,13 +214,31 @@ export function App({ // the exit report reads them directly (state is batched/async at that point). const finalResultRef = useRef(null) const finalSummaryRef = useRef('') + // Set when a symlinked .env or .env.example refused a write, so the exit + // report can carry the refusal and a truthful env hint instead of the + // transcript line the alternate screen discards. + const envRefusedRef = useRef(false) + // The install-plugin effect's MCP outcome, read by the exit report so a + // printed/failed registration's fallback survives past the alternate screen. + const mcpRegistrationRef = useRef<{ + target: PluginTarget + registration: McpRegistration + } | null>(null) const addMessage = (message: Msg): void => setMessages((previous) => [...previous, message]) - const reportEnvWrite = (result: ProjectEnvResult): void => { + const reportEnvWrite = ( + result: Pick & + Partial>, + ): void => { if (result.env === 'symlink-refused') { addMessage({ tone: 'warn', text: ENV_SYMLINK_REFUSAL_MESSAGE }) + envRefusedRef.current = true + } + if (result.example === 'symlink-refused') { + addMessage({ tone: 'warn', text: ENV_EXAMPLE_SYMLINK_REFUSAL_MESSAGE }) + envRefusedRef.current = true } } @@ -444,10 +465,13 @@ export function App({ const useCliKey = (found: CliKeyResult): void => { try { - reportEnvWrite(saveVerifiedKey(root, found.api_key)) + const envResult = saveVerifiedKey(root, found.api_key) + reportEnvWrite(envResult) + const savedClause = + envResult.env === 'symlink-refused' ? '' : ' · saved to .env' addMessage({ tone: 'ok', - text: `Using your Seam CLI login · workspace ${found.workspace.name} · saved to .env`, + text: `Using your Seam CLI login · workspace ${found.workspace.name}${savedClause}`, }) } catch { addMessage({ @@ -463,7 +487,7 @@ export function App({ if (found.source === 'environment') { reportEnvWrite(saveVerifiedKey(root, found.api_key)) } else { - ensureProjectEnvConventions(root) + reportEnvWrite(ensureProjectEnvConventions(root)) } } catch { // The project keeps working with the key it already has. @@ -613,7 +637,12 @@ export function App({ tone: 'ok', text: `Connected · workspace ${result.workspace.name}`, }) - settleOn(result.workspace, result.api_key, 'browser', '.env') + settleOn( + result.workspace, + result.api_key, + 'browser', + result.env.env === 'symlink-refused' ? null : '.env', + ) } catch (error) { if (!cancelled) { const message = @@ -654,7 +683,12 @@ export function App({ if (cancelled) return reportEnvWrite(result.env) addMessage({ tone: 'ok', text: `Workspace: ${result.workspace.name}` }) - settleOn(result.workspace, result.api_key, 'pasted', '.env') + settleOn( + result.workspace, + result.api_key, + 'pasted', + result.env.env === 'symlink-refused' ? null : '.env', + ) } catch (error) { if (cancelled) return const message = @@ -786,6 +820,7 @@ export function App({ } if (cancelled) return + mcpRegistrationRef.current = { target, registration } trackInstallFinished('plugin', skillsInstalled, { plugin_target: target, mcp_registration: registration, @@ -1074,7 +1109,24 @@ export function App({ : '') lines.push(stats) } - lines.push('', ...nextStepsLines(sdk, workspaceName)) + lines.push('', ...nextStepsLines(sdk, workspaceName, envRefusedRef.current)) + if (envRefusedRef.current) { + lines.push('', ENV_SYMLINK_REFUSAL_MESSAGE) + } + + const mcpOutcome = mcpRegistrationRef.current + if (mcpOutcome?.registration === 'claude_cli') { + lines.push( + '', + 'Seam MCP registered for Claude Code in .mcp.json (project scope).', + ) + } else if (mcpOutcome != null) { + lines.push('', 'Connect your coding agent to Seam') + for (const line of mcpJsonSnippet().split('\n')) { + lines.push(` ${line}`) + } + } + if (showChanges && finalSummaryRef.current.length > 0) { lines.push('', 'What changed:') for (const line of finalSummaryRef.current.split('\n')) { @@ -1536,9 +1588,14 @@ function projectKeyLabel( // Said only of what the login actually is: a token the wizard cannot hand to // a project, or one it could not verify. -function nextStepsLines(sdk: Sdk | null, workspaceName: string): string[] { - const envHint = - sdk === 'python' +function nextStepsLines( + sdk: Sdk | null, + workspaceName: string, + envRefused: boolean, +): string[] { + const envHint = envRefused + ? 'Add SEAM_API_KEY to your real env file yourself; the wizard did not write through the symlinked .env.' + : sdk === 'python' ? "Make sure SEAM_API_KEY is exported (it's in .env)." : 'Your key is in .env (git ignored); .env.example tells the rest of your team what to set.' return [ diff --git a/src/lib/env-file.test.ts b/src/lib/env-file.test.ts index adefb89..d2c9e10 100644 --- a/src/lib/env-file.test.ts +++ b/src/lib/env-file.test.ts @@ -264,8 +264,7 @@ test('ensureGitignored: writes no git files outside a repository', () => { }) // A symlinked .env usually points at a shared secrets file outside the repo. -// Writing through it would edit that file — and it is exactly the case where -// the wizard cannot know the destination is the developer's to change. +// Writing through it would edit that file. test('upsertEnvVar refuses a symlinked file and leaves the target untouched', () => { const targetPath = join(dir, 'shared-secrets.env') const linkPath = join(dir, '.env') diff --git a/src/lib/env-file.ts b/src/lib/env-file.ts index 4666792..64caaab 100644 --- a/src/lib/env-file.ts +++ b/src/lib/env-file.ts @@ -6,6 +6,9 @@ export type EnvWriteResult = 'created' | 'updated' | 'added' | 'symlink-refused' export const ENV_SYMLINK_REFUSAL_MESSAGE = '.env is a symlink — the wizard did not write through it. Add SEAM_API_KEY to the real file yourself.' +export const ENV_EXAMPLE_SYMLINK_REFUSAL_MESSAGE = + '.env.example is a symlink — the wizard did not write through it. Add SEAM_API_KEY to the real file yourself.' + // dotenv files we look at for an existing key, in priority order. const ENV_FILE_NAMES = [ '.env.local', diff --git a/src/lib/steps/register-seam-mcp.test.ts b/src/lib/steps/register-seam-mcp.test.ts index 984d820..d36a9b3 100644 --- a/src/lib/steps/register-seam-mcp.test.ts +++ b/src/lib/steps/register-seam-mcp.test.ts @@ -36,7 +36,7 @@ test('mcpJsonSnippet is valid JSON registering the authenticated URL', () => { }) }) -test('UNIVERSAL_MCP_HINTS names one config file per supported tool', () => { +test('UNIVERSAL_MCP_HINTS names the URL and config file per supported tool', () => { expect(UNIVERSAL_MCP_HINTS).toHaveLength(3) expect(UNIVERSAL_MCP_HINTS[0]).toContain('Cursor') expect(UNIVERSAL_MCP_HINTS[0]).toContain('.cursor/mcp.json') @@ -45,6 +45,7 @@ test('UNIVERSAL_MCP_HINTS names one config file per supported tool', () => { expect(UNIVERSAL_MCP_HINTS[2]).toContain('OpenCode') expect(UNIVERSAL_MCP_HINTS[2]).toContain('opencode.json') for (const hint of UNIVERSAL_MCP_HINTS) { + expect(hint).toContain(AUTHENTICATED_SEAM_MCP_URL) expect(hint.split('\n')).toHaveLength(1) } }) @@ -149,28 +150,30 @@ test('buildMcpRegistrationNotices confirms a CLI registration without reprinting ) }) -test('buildMcpRegistrationNotices prints the snippet when the CLI could not register', () => { +test('buildMcpRegistrationNotices warns when the Claude Code CLI attempt fell back', () => { const notices = buildMcpRegistrationNotices({ target: 'claude-code', registration: 'printed', }) const text = notices.map((notice) => notice.text).join('\n') - expect(text).toContain('.mcp.json') + expect(notices[0]?.tone).toBe('warn') + expect(text).toContain(CLAUDE_MCP_ADD_COMMAND.join(' ')) expect(text).toContain('https://mcp.seam.co/mcp/authenticated') expect(JSON.parse(mcpJsonSnippet())).toBeTruthy() // A Claude Code project does not need another agent's config file named at it. expect(text).not.toContain('opencode.json') }) -test('buildMcpRegistrationNotices adds the per-tool hints for a universal project', () => { - const text = buildMcpRegistrationNotices({ +test('buildMcpRegistrationNotices uses an info heading for a universal project', () => { + const notices = buildMcpRegistrationNotices({ target: 'universal', registration: 'printed', }) - .map((notice) => notice.text) - .join('\n') + const text = notices.map((notice) => notice.text).join('\n') + expect(notices[0]?.tone).toBe('info') + expect(text).toContain('.mcp.json') for (const hint of UNIVERSAL_MCP_HINTS) { expect(text).toContain(hint) } diff --git a/src/lib/steps/register-seam-mcp.ts b/src/lib/steps/register-seam-mcp.ts index 21fbbe9..156a20c 100644 --- a/src/lib/steps/register-seam-mcp.ts +++ b/src/lib/steps/register-seam-mcp.ts @@ -65,15 +65,15 @@ export function mcpJsonSnippet(): string { } export const UNIVERSAL_MCP_HINTS = [ - `Cursor — add the same mcpServers block to .cursor/mcp.json`, - `Codex — add [mcp_servers.${SEAM_MCP_SERVER_NAME}] with url = "${AUTHENTICATED_SEAM_MCP_URL}" to ~/.codex/config.toml`, - `OpenCode — add "${SEAM_MCP_SERVER_NAME}": { "type": "remote", "url": "${AUTHENTICATED_SEAM_MCP_URL}" } under "mcp" in opencode.json`, + `Cursor — add a remote MCP server named "${SEAM_MCP_SERVER_NAME}" with URL ${AUTHENTICATED_SEAM_MCP_URL} in .cursor/mcp.json (see Cursor's MCP docs for the exact fields)`, + `Codex — add a remote MCP server named "${SEAM_MCP_SERVER_NAME}" with URL ${AUTHENTICATED_SEAM_MCP_URL} in ~/.codex/config.toml (see Codex's MCP docs for the exact fields)`, + `OpenCode — add a remote MCP server named "${SEAM_MCP_SERVER_NAME}" with URL ${AUTHENTICATED_SEAM_MCP_URL} in opencode.json (see OpenCode's MCP docs for the exact fields)`, ] as const // Register the authenticated MCP with the Claude Code CLI, in the project the // wizard is setting up. Any spawn failure — no `claude` on PATH (ENOENT), or a // non-zero exit — is a fallback, not an error: the caller prints the snippet -// instead. `runCommand` is injected so a test can drive both outcomes. +// instead. export async function registerSeamMcpWithClaudeCli({ root, onLine, @@ -108,7 +108,7 @@ export function buildMcpRegistrationNotices({ } const heading: McpNotice = - registration === 'failed' + target === 'claude-code' ? { tone: 'warn', text: `Couldn't register the Seam MCP — run it yourself: ${CLAUDE_MCP_ADD_COMMAND.join(' ')}`, From 92980f387f789de9b2c49f392700de5da9188b39 Mon Sep 17 00:00:00 2001 From: Philip Chmalts Date: Wed, 2 Sep 2026 12:02:37 -0700 Subject: [PATCH 08/10] fix(wizard): report .env and .env.example symlink refusals separately One merged flag made the exit report say .env was not written whenever only .env.example was the symlink. The two refusals are now tracked and reported independently. PLA-2951 Co-Authored-By: Claude Fable 5.1 --- src/lib/app.tsx | 38 ++++++++++++++++++++++++++++++-------- src/lib/env-file.test.ts | 19 +++++++++++++++++++ test/app.test.tsx | 34 +++++++++++++++++++++++++++++++++- 3 files changed, 82 insertions(+), 9 deletions(-) diff --git a/src/lib/app.tsx b/src/lib/app.tsx index 4689122..7fec49e 100644 --- a/src/lib/app.tsx +++ b/src/lib/app.tsx @@ -214,10 +214,14 @@ export function App({ // the exit report reads them directly (state is batched/async at that point). const finalResultRef = useRef(null) const finalSummaryRef = useRef('') - // Set when a symlinked .env or .env.example refused a write, so the exit - // report can carry the refusal and a truthful env hint instead of the - // transcript line the alternate screen discards. + // Set when a symlinked .env refused a write, so the exit report can carry + // the refusal and a truthful env hint instead of the transcript line the + // alternate screen discards. const envRefusedRef = useRef(false) + // Set when a symlinked .env.example refused a write. Tracked separately from + // envRefusedRef: .env and .env.example are written independently, so a + // refusal on one must not make the exit report lie about the other. + const envExampleRefusedRef = useRef(false) // The install-plugin effect's MCP outcome, read by the exit report so a // printed/failed registration's fallback survives past the alternate screen. const mcpRegistrationRef = useRef<{ @@ -238,7 +242,7 @@ export function App({ } if (result.example === 'symlink-refused') { addMessage({ tone: 'warn', text: ENV_EXAMPLE_SYMLINK_REFUSAL_MESSAGE }) - envRefusedRef.current = true + envExampleRefusedRef.current = true } } @@ -1109,10 +1113,21 @@ export function App({ : '') lines.push(stats) } - lines.push('', ...nextStepsLines(sdk, workspaceName, envRefusedRef.current)) + lines.push( + '', + ...nextStepsLines( + sdk, + workspaceName, + envRefusedRef.current, + envExampleRefusedRef.current, + ), + ) if (envRefusedRef.current) { lines.push('', ENV_SYMLINK_REFUSAL_MESSAGE) } + if (envExampleRefusedRef.current) { + lines.push('', ENV_EXAMPLE_SYMLINK_REFUSAL_MESSAGE) + } const mcpOutcome = mcpRegistrationRef.current if (mcpOutcome?.registration === 'claude_cli') { @@ -1588,16 +1603,23 @@ function projectKeyLabel( // Said only of what the login actually is: a token the wizard cannot hand to // a project, or one it could not verify. -function nextStepsLines( +export function nextStepsLines( sdk: Sdk | null, workspaceName: string, envRefused: boolean, + envExampleRefused: boolean, ): string[] { const envHint = envRefused - ? 'Add SEAM_API_KEY to your real env file yourself; the wizard did not write through the symlinked .env.' + ? `Add SEAM_API_KEY to your real env file yourself; the wizard did not write through the symlinked .env.${ + envExampleRefused + ? ' .env.example is a symlink, so the wizard did not update it either.' + : '' + }` : sdk === 'python' ? "Make sure SEAM_API_KEY is exported (it's in .env)." - : 'Your key is in .env (git ignored); .env.example tells the rest of your team what to set.' + : envExampleRefused + ? 'Your key is in .env (git ignored); .env.example is a symlink, so the wizard did not update it.' + : 'Your key is in .env (git ignored); .env.example tells the rest of your team what to set.' return [ `You're set up in ${workspaceName}`, 'Next steps:', diff --git a/src/lib/env-file.test.ts b/src/lib/env-file.test.ts index d2c9e10..4f3187d 100644 --- a/src/lib/env-file.test.ts +++ b/src/lib/env-file.test.ts @@ -303,6 +303,25 @@ test('saveProjectApiKey reports the refusal and still ignores .env', () => { expect(readFileSync(targetPath, 'utf8')).not.toContain('seam_new_key') }) +// .env and .env.example are written independently: a symlinked .env.example +// must not stop the key from landing in .env, and the result must say so per +// file rather than merging the two into one verdict. +test('saveProjectApiKey writes .env even when only .env.example is a symlink', () => { + mkdirSync(join(dir, '.git')) + const exampleTargetPath = join(dir, 'shared-example.env') + writeFileSync(exampleTargetPath, 'OTHER=1\n') + symlinkSync(exampleTargetPath, join(dir, '.env.example')) + + const result = saveProjectApiKey(dir, 'seam_new_key') + + expect(result.env).toBe('created') + expect(result.example).toBe('symlink-refused') + expect(readFileSync(join(dir, '.env'), 'utf8')).toBe( + 'SEAM_API_KEY=seam_new_key\n', + ) + expect(readFileSync(exampleTargetPath, 'utf8')).not.toContain('seam_new_key') +}) + test('ENV_SYMLINK_REFUSAL_MESSAGE tells the developer what to do, without a key', () => { expect(ENV_SYMLINK_REFUSAL_MESSAGE).toContain('SEAM_API_KEY') expect(ENV_SYMLINK_REFUSAL_MESSAGE).toContain('symlink') diff --git a/test/app.test.tsx b/test/app.test.tsx index fc56cea..40afe32 100644 --- a/test/app.test.tsx +++ b/test/app.test.tsx @@ -7,7 +7,7 @@ import { resetAnalytics, startAnalytics, } from 'lib/analytics.js' -import { App } from 'lib/app.js' +import { App, nextStepsLines } from 'lib/app.js' // A project root that does not exist, with no key in the environment, keeps the // render offline: the wizard opens on the welcome splash and only leaves it on a @@ -73,3 +73,35 @@ test('App: reports the run it started and the screen it stopped on', async () => reached_integration: false, }) }) + +// nextStepsLines feeds both the live message log and the exit report's env +// hint. .env and .env.example are written independently by saveProjectApiKey, +// so a refusal on one must not make the hint lie about the other — this was +// the bug in the merged envRefusedRef (round 2 of the PLA-2951 review). +test('nextStepsLines: reports .env and .env.example refusals independently', () => { + const neither = nextStepsLines('javascript', 'Acme', false, false) + expect(neither.join('\n')).toContain( + 'Your key is in .env (git ignored); .env.example tells the rest of your team what to set.', + ) + + const envOnly = nextStepsLines('javascript', 'Acme', true, false) + expect(envOnly.join('\n')).toContain( + 'Add SEAM_API_KEY to your real env file yourself; the wizard did not write through the symlinked .env.', + ) + expect(envOnly.join('\n')).not.toContain('.env.example') + + const exampleOnly = nextStepsLines('javascript', 'Acme', false, true) + expect(exampleOnly.join('\n')).toContain('Your key is in .env (git ignored)') + expect(exampleOnly.join('\n')).toContain( + '.env.example is a symlink, so the wizard did not update it.', + ) + expect(exampleOnly.join('\n')).not.toContain('did not write through') + + const both = nextStepsLines('javascript', 'Acme', true, true) + expect(both.join('\n')).toContain( + 'the wizard did not write through the symlinked .env.', + ) + expect(both.join('\n')).toContain( + '.env.example is a symlink, so the wizard did not update it', + ) +}) From ea8bba025451034e0fad52e91adf406dd4a8430c Mon Sep 17 00:00:00 2001 From: Philip Chmalts Date: Wed, 2 Sep 2026 12:19:13 -0700 Subject: [PATCH 09/10] fix(wizard): keep per-tool MCP hints in the exit report; show consent notice only after registration Addresses Codex review on #70. PLA-2951 Co-Authored-By: Claude Fable 5.1 --- src/lib/app.tsx | 15 ++----- src/lib/screens/debug-screen.tsx | 1 + src/lib/screens/done.test.tsx | 26 ++++++++++++ src/lib/screens/done.tsx | 25 +++++++---- src/lib/steps/register-seam-mcp.test.ts | 55 +++++++++++++++++++++++++ src/lib/steps/register-seam-mcp.ts | 27 ++++++++++++ 6 files changed, 129 insertions(+), 20 deletions(-) diff --git a/src/lib/app.tsx b/src/lib/app.tsx index 7fec49e..c524800 100644 --- a/src/lib/app.tsx +++ b/src/lib/app.tsx @@ -85,8 +85,8 @@ import { } from './steps/install-seam-plugin.js' import { type IntegrateEvent, runIntegration } from './steps/integrate.js' import { + buildMcpExitReportLines, buildMcpRegistrationNotices, - mcpJsonSnippet, type McpRegistration, registerSeamMcpWithClaudeCli, } from './steps/register-seam-mcp.js' @@ -1130,16 +1130,8 @@ export function App({ } const mcpOutcome = mcpRegistrationRef.current - if (mcpOutcome?.registration === 'claude_cli') { - lines.push( - '', - 'Seam MCP registered for Claude Code in .mcp.json (project scope).', - ) - } else if (mcpOutcome != null) { - lines.push('', 'Connect your coding agent to Seam') - for (const line of mcpJsonSnippet().split('\n')) { - lines.push(` ${line}`) - } + if (mcpOutcome != null) { + lines.push('', ...buildMcpExitReportLines(mcpOutcome)) } if (showChanges && finalSummaryRef.current.length > 0) { @@ -1322,6 +1314,7 @@ export function App({ workspaceId={workspace?.workspace_id ?? null} outcome={finalResult} showCost={showCost} + mcpRegistrationAttempted={mcpRegistrationRef.current != null} /> ) } diff --git a/src/lib/screens/debug-screen.tsx b/src/lib/screens/debug-screen.tsx index 29c5096..43b91c4 100644 --- a/src/lib/screens/debug-screen.tsx +++ b/src/lib/screens/debug-screen.tsx @@ -65,6 +65,7 @@ const SCREENS: Record ReactElement> = { totalSteps: 4, }} showCost + mcpRegistrationAttempted /> ), tasks: () => , diff --git a/src/lib/screens/done.test.tsx b/src/lib/screens/done.test.tsx index 9a28952..1c36d4c 100644 --- a/src/lib/screens/done.test.tsx +++ b/src/lib/screens/done.test.tsx @@ -16,6 +16,7 @@ test('DoneScreen: points at the workspace assistant', () => { workspaceId={WORKSPACE_ID} outcome={null} showCost={false} + mcpRegistrationAttempted />, ) try { @@ -38,6 +39,7 @@ test('DoneScreen: honors SEAM_CONSOLE_URL for the assistant link', () => { workspaceId={WORKSPACE_ID} outcome={null} showCost={false} + mcpRegistrationAttempted />, ) try { @@ -57,6 +59,7 @@ test('DoneScreen: omits the assistant link without a workspace', () => { workspaceId={null} outcome={null} showCost={false} + mcpRegistrationAttempted />, ) try { @@ -75,6 +78,7 @@ test('DoneScreen: says the agent will sign in and choose permissions', () => { workspaceId={WORKSPACE_ID} outcome={null} showCost={false} + mcpRegistrationAttempted />, ) try { @@ -86,6 +90,28 @@ test('DoneScreen: says the agent will sign in and choose permissions', () => { } }) +// A run that quit (e.g. "Quit, and leave everything as it is" on the drift +// screen) before the install-plugin phase never attempted MCP registration — +// the notice describes that step, so it must not show for a run that never +// reached it. +test('DoneScreen: omits the consent notice when MCP registration was never attempted', () => { + const { lastFrame, unmount } = render( + , + ) + try { + const frame = (lastFrame() ?? '').replace(/\s+/g, ' ') + expect(frame).not.toContain(AGENT_CONSENT_NOTICE) + } finally { + unmount() + } +}) + test('AGENT_CONSENT_NOTICE is the copy the merge design specifies', () => { expect(AGENT_CONSENT_NOTICE).toBe( 'Your coding agent will be asked to sign in to Seam and choose permissions the first time it uses a Seam tool.', diff --git a/src/lib/screens/done.tsx b/src/lib/screens/done.tsx index 1e2e8f3..75ef090 100644 --- a/src/lib/screens/done.tsx +++ b/src/lib/screens/done.tsx @@ -30,11 +30,16 @@ export function DoneScreen({ workspaceId, outcome, showCost, + mcpRegistrationAttempted, }: { workspaceName: string workspaceId: string | null outcome: IntegrationOutcome | null showCost: boolean + // The consent notice describes what the MCP registration step does, so it + // would mislead a run that quit (e.g. from the drift screen) before that + // step ever ran. + mcpRegistrationAttempted: boolean }): ReactElement { const { stdout } = useStdout() const rows = stdout?.rows ?? 24 @@ -124,15 +129,17 @@ export function DoneScreen({ )} - - {AGENT_CONSENT_NOTICE} - + {mcpRegistrationAttempted && ( + + {AGENT_CONSENT_NOTICE} + + )} {/* A margin, not a blank : this column is vertically centered, and when that offset lands on a half row Ink overlaps the rows it paints. A blank Text paints a real space, which lands mid-URL diff --git a/src/lib/steps/register-seam-mcp.test.ts b/src/lib/steps/register-seam-mcp.test.ts index d36a9b3..c798436 100644 --- a/src/lib/steps/register-seam-mcp.test.ts +++ b/src/lib/steps/register-seam-mcp.test.ts @@ -2,6 +2,7 @@ import { expect, test } from 'vitest' import { AUTHENTICATED_SEAM_MCP_URL, + buildMcpExitReportLines, buildMcpRegistrationNotices, CLAUDE_MCP_ADD_COMMAND, mcpJsonSnippet, @@ -190,3 +191,57 @@ test('buildMcpRegistrationNotices warns and prints when registration failed', () expect(text).toContain(CLAUDE_MCP_ADD_COMMAND.join(' ')) expect(text).toContain('https://mcp.seam.co/mcp/authenticated') }) + +// The exit report survives past the alternate screen, so it has to carry the +// same per-tool hints the live transcript showed a universal project — a +// printed Claude Code project only ever needed the .mcp.json snippet, so it +// gets that alone. +test('buildMcpExitReportLines confirms a CLI registration without reprinting it', () => { + const lines = buildMcpExitReportLines({ + target: 'claude-code', + registration: 'claude_cli', + }) + + expect(lines).toEqual([ + 'Seam MCP registered for Claude Code in .mcp.json (project scope).', + ]) +}) + +test('buildMcpExitReportLines gives a printed Claude Code project the snippet only', () => { + const text = buildMcpExitReportLines({ + target: 'claude-code', + registration: 'printed', + }).join('\n') + + expect(text).toContain('Connect your coding agent to Seam') + expect(JSON.parse(mcpJsonSnippet())).toBeTruthy() + expect(text).toContain(AUTHENTICATED_SEAM_MCP_URL) + for (const hint of UNIVERSAL_MCP_HINTS) { + expect(text).not.toContain(hint) + } +}) + +test('buildMcpExitReportLines keeps the per-tool hints for a printed universal project', () => { + const text = buildMcpExitReportLines({ + target: 'universal', + registration: 'printed', + }).join('\n') + + expect(text).toContain('Connect your coding agent to Seam') + expect(text).toContain(AUTHENTICATED_SEAM_MCP_URL) + for (const hint of UNIVERSAL_MCP_HINTS) { + expect(text).toContain(hint) + } +}) + +test('buildMcpExitReportLines keeps the snippet for a universal project when registration failed', () => { + const text = buildMcpExitReportLines({ + target: 'universal', + registration: 'failed', + }).join('\n') + + expect(text).toContain(AUTHENTICATED_SEAM_MCP_URL) + for (const hint of UNIVERSAL_MCP_HINTS) { + expect(text).toContain(hint) + } +}) diff --git a/src/lib/steps/register-seam-mcp.ts b/src/lib/steps/register-seam-mcp.ts index 156a20c..1302680 100644 --- a/src/lib/steps/register-seam-mcp.ts +++ b/src/lib/steps/register-seam-mcp.ts @@ -91,6 +91,33 @@ export async function registerSeamMcpWithClaudeCli({ } } +// The exit report's version of the above: plain lines (no tone), because the +// alternate screen that carries buildMcpRegistrationNotices's messages is +// discarded on exit — a printed 'universal' fallback would otherwise lose the +// per-tool hints naming where Codex/Cursor/OpenCode keep their MCP config. +export function buildMcpExitReportLines({ + target, + registration, +}: { + target: PluginTarget + registration: McpRegistration +}): string[] { + if (registration === 'claude_cli') { + return ['Seam MCP registered for Claude Code in .mcp.json (project scope).'] + } + + const lines = ['Connect your coding agent to Seam'] + for (const line of mcpJsonSnippet().split('\n')) { + lines.push(` ${line}`) + } + if (target === 'universal') { + for (const hint of UNIVERSAL_MCP_HINTS) { + lines.push(` ${hint}`) + } + } + return lines +} + export function buildMcpRegistrationNotices({ target, registration, From d77872190ba8b7b80812510928c5088cbdd59d95 Mon Sep 17 00:00:00 2001 From: Philip Chmalts Date: Wed, 2 Sep 2026 12:26:23 -0700 Subject: [PATCH 10/10] docs: drop the committed PLA-2951 plan; the PR description is the record Co-Authored-By: Claude Fable 5.1 --- ...09-02-pla-2951-agent-grant-registration.md | 1118 ----------------- 1 file changed, 1118 deletions(-) delete mode 100644 docs/superpowers/plans/2026-09-02-pla-2951-agent-grant-registration.md diff --git a/docs/superpowers/plans/2026-09-02-pla-2951-agent-grant-registration.md b/docs/superpowers/plans/2026-09-02-pla-2951-agent-grant-registration.md deleted file mode 100644 index 1fe167a..0000000 --- a/docs/superpowers/plans/2026-09-02-pla-2951-agent-grant-registration.md +++ /dev/null @@ -1,1118 +0,0 @@ -# Agent Grant Registration and `.env` Hardening — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** After the wizard installs the Seam plugin skills, register the _authenticated_ Seam MCP (`https://mcp.seam.co/mcp/authenticated`) with the developer's coding agent — via `claude mcp add` when Claude Code is detected, by printing the equivalent `.mcp.json` snippet otherwise — and refuse to write `SEAM_API_KEY` through a symlinked `.env`. - -**Architecture:** One new module, `src/lib/steps/register-seam-mcp.ts`, holds every constant and string this feature needs (the `claude mcp add` argv, the `.mcp.json` snippet, the three per-tool hints, the printed-notice composer) plus a thin runner that spawns the argv through the existing `runInstall` seam and maps failure to a printed fallback. `src/lib/app.tsx`'s `install-plugin` phase calls the runner, reports the outcome on `wizard_install_finished`, and renders the composed notices through the existing `addMessage`. `src/lib/env-file.ts` gains an `lstat` guard and a fourth `EnvWriteResult` variant that the three save paths propagate so the Ink app can tell the developer to add the key by hand. - -**Tech Stack:** TypeScript (ESM, `type: module`), React + Ink 7 for the TUI, vitest 4 (`npm test`), eslint 9 / neostandard + prettier, Node >= 22.12. - -**Spec:** `/Users/philchmalts/Documents/development/seam-connect/docs/superpowers/specs/2026-09-01-api-key-bootstrap-delegated-agent-merge-design.md` — this plan is **Workstream C** (`seamapi/wizard`). Workstream A shipped as seam-connect#17512; Workstream B (seam-ai) has its own plan in its own repo. Read spec §2 (Invariant), §3 (as-is facts), and §5.C before starting. - -## Global Constraints - -- Branch: `phil/pla-2951-agent-grant-registration` in `/Users/philchmalts/Documents/development/wizard` (already checked out, off `main` at tag `0.44.2`). Do not `cd` outside it. Dependencies are already installed — do **not** run `npm ci` or `npm install`. -- Tests: `npm test` (`vitest run --coverage`, whole suite, fast) or a single file with `npx vitest run `. Never call a live service from a test. -- Lint: `npm run lint` (`eslint .` then `prettier --check`). Format: `npm run format`. Typecheck: `npm run typecheck` (`tsc`). All three are project-wide and fast enough to run per task. -- **Invariant (spec §2):** the API key is never printed to the terminal and never enters the agent's context; only the `seam_wiz_` inference token reaches the subprocess env. Nothing in this PR may log, echo, or pass `SEAM_API_KEY` — the new `claude mcp add` spawn passes no `env` and no key, and the symlink-refusal message names the variable, never a value. -- **No change to the embedded agent harnesses.** `src/lib/steps/harness/anthropic.ts:10` and `src/lib/steps/harness/pi.ts:16` keep `const SEAM_MCP_URL = 'https://mcp.seam.co/mcp'` (anonymous). They only need docs (spec §5.C.4). Do not touch those files. -- No change to `seamapi/seam-plugin`'s registered URL, and keep `CLAUDE_CODE_COMMANDS` (the `/plugin` lines) as the optional docs-plugin path (spec §6). -- Style, per the existing code: `camelCase` locals and functions; `snake_case` only for analytics property keys and existing interface fields (`api_key`); prettier with `semi: false`, `singleQuote: true`, `jsxSingleQuote: true`; `no-console` is an eslint error — all output goes through `addMessage`; `@typescript-eslint/no-non-null-assertion` is an error; relative imports of `..`/`../**` are **forbidden** — reach across directories with the `lib/` path alias (e.g. `import { runInstall } from 'lib/run-install.js'`), same-directory `./x.js` is fine. -- Imports are sorted by `simple-import-sort` in these groups: `node:` · packages · `@seamapi/wizard` · `eval|lib|test` aliases · other · `./` relative. Run `npm run format` if the order is ever in doubt. -- Every comment added must earn its place: delete it if it would read identically at every similar call site, if a test already states the behavior, or if it is addressed to a reviewer. -- Every commit message mentions `PLA-2951` and ends with the trailer `Co-Authored-By: Claude Fable 5.1 `. - ---- - -## File Structure - -| File | Responsibility | Change | -| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | -| `src/lib/steps/register-seam-mcp.ts` | Every string and decision for authenticated-MCP registration: the `claude mcp add` argv, the `.mcp.json` snippet, the Cursor/Codex/OpenCode hints, the notice composer, and the runner that spawns the argv | **Create** | -| `src/lib/steps/register-seam-mcp.test.ts` | Unit tests for that module: exact argv, snippet JSON, hints, runner outcomes (injected runner), composed notices | **Create** | -| `src/lib/app.tsx` | The Ink app. `install-plugin` phase (lines 725-780) runs the skills install, then registration; reports `wizard_install_finished`; renders notices | Modify the `install-plugin` effect, the import block, and the four env-write call sites | -| `src/lib/screens/done.tsx` | Final screen | Add the exported `AGENT_CONSENT_NOTICE` copy line below the card | -| `src/lib/screens/done.test.tsx` | Done-screen render tests | Add one test for the consent copy | -| `src/lib/env-file.ts` | dotenv read/write helpers | `lstat` guard in `upsertEnvVar`, new `'symlink-refused'` variant, exported refusal message | -| `src/lib/env-file.test.ts` | Unit tests for those helpers | Add symlink tests | -| `src/lib/steps/authenticate.ts` | Pure auth logic | `saveVerifiedKey` returns the env result; `AuthResult` carries it | -| `src/lib/steps/authenticate.test.ts` | Unit tests for auth | Add one propagation test | -| `src/lib/steps/connect-web.ts` | Browser → CLI key handoff | `WebConnectResult` carries the env result | - -Task order: 1 (pure strings) → 2 (runner) → 3 (app wiring + analytics + done copy) → 4 (`.env` hardening, independent of 1-3) → 5 (verify + PR). - ---- - -### Task 1: The registration constants, snippet, and hints - -**Files:** - -- Create: `src/lib/steps/register-seam-mcp.ts` -- Create: `src/lib/steps/register-seam-mcp.test.ts` - -**Interfaces:** - -- Consumes: `type PluginTarget = 'claude-code' | 'universal'` from `./install-seam-plugin.js` (already exported at `src/lib/steps/install-seam-plugin.ts:4`). -- Produces (all used by Tasks 2 and 3): - - `const AUTHENTICATED_SEAM_MCP_URL: string` - - `const SEAM_MCP_SERVER_NAME: string` - - `const CLAUDE_MCP_ADD_COMMAND: string[]` - - `function mcpJsonSnippet(): string` - - `const UNIVERSAL_MCP_HINTS: readonly string[]` - -- [ ] **Step 1: Write the failing tests** - -Create `src/lib/steps/register-seam-mcp.test.ts`: - -```ts -import { expect, test } from 'vitest' - -import { - AUTHENTICATED_SEAM_MCP_URL, - CLAUDE_MCP_ADD_COMMAND, - mcpJsonSnippet, - SEAM_MCP_SERVER_NAME, - UNIVERSAL_MCP_HINTS, -} from './register-seam-mcp.js' - -test('CLAUDE_MCP_ADD_COMMAND is the exact non-interactive argv', () => { - expect(CLAUDE_MCP_ADD_COMMAND).toEqual([ - 'claude', - 'mcp', - 'add', - '--transport', - 'http', - '--scope', - 'project', - 'seam', - 'https://mcp.seam.co/mcp/authenticated', - ]) -}) - -test('mcpJsonSnippet is valid JSON registering the authenticated URL', () => { - const snippet = JSON.parse(mcpJsonSnippet()) as { - mcpServers: Record - } - - expect(Object.keys(snippet.mcpServers)).toEqual([SEAM_MCP_SERVER_NAME]) - expect(snippet.mcpServers[SEAM_MCP_SERVER_NAME]).toEqual({ - type: 'http', - url: AUTHENTICATED_SEAM_MCP_URL, - }) -}) - -test('UNIVERSAL_MCP_HINTS names one config file per supported tool', () => { - expect(UNIVERSAL_MCP_HINTS).toHaveLength(3) - expect(UNIVERSAL_MCP_HINTS[0]).toContain('Cursor') - expect(UNIVERSAL_MCP_HINTS[0]).toContain('.cursor/mcp.json') - expect(UNIVERSAL_MCP_HINTS[1]).toContain('Codex') - expect(UNIVERSAL_MCP_HINTS[1]).toContain('.codex/config.toml') - expect(UNIVERSAL_MCP_HINTS[2]).toContain('OpenCode') - expect(UNIVERSAL_MCP_HINTS[2]).toContain('opencode.json') - for (const hint of UNIVERSAL_MCP_HINTS) { - expect(hint.split('\n')).toHaveLength(1) - } -}) - -// The anonymous /mcp is the plugin's and the embedded harnesses' server. Every -// URL this module hands the developer's own agent must be the authenticated one, -// or the agent gets docs instead of a delegated grant. -test('nothing this module emits points at the anonymous MCP', () => { - const anonymousUrl = /mcp\.seam\.co\/mcp(?!\/authenticated)/ - - for (const emitted of [ - CLAUDE_MCP_ADD_COMMAND.join(' '), - mcpJsonSnippet(), - ...UNIVERSAL_MCP_HINTS, - ]) { - expect(emitted).not.toMatch(anonymousUrl) - } -}) - -test('nothing this module emits could carry an API key', () => { - for (const emitted of [ - CLAUDE_MCP_ADD_COMMAND.join(' '), - mcpJsonSnippet(), - ...UNIVERSAL_MCP_HINTS, - ]) { - expect(emitted).not.toContain('SEAM_API_KEY') - expect(emitted).not.toMatch(/seam_[A-Za-z0-9]/) - } -}) -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `npx vitest run src/lib/steps/register-seam-mcp.test.ts` - -Expected: FAIL — the suite cannot resolve `./register-seam-mcp.js` ("Failed to load url ./register-seam-mcp.js"). - -- [ ] **Step 3: Write the module** - -Create `src/lib/steps/register-seam-mcp.ts`: - -```ts -// The authenticated Seam MCP. Unlike the anonymous https://mcp.seam.co/mcp the -// plugin and the embedded harnesses use, this endpoint answers an unauthenticated -// request with 401 + WWW-Authenticate, which is what makes a coding agent start -// the OAuth consent flow and end up on its own delegated grant instead of -// borrowing the app's key from .env. -export const AUTHENTICATED_SEAM_MCP_URL = - 'https://mcp.seam.co/mcp/authenticated' - -export const SEAM_MCP_SERVER_NAME = 'seam' - -// Project scope writes .mcp.json in the project root, so the registration -// travels with the repo the wizard just set up. No flag here prompts, so the -// wizard can spawn it with stdin ignored like every other install. -export const CLAUDE_MCP_ADD_COMMAND = [ - 'claude', - 'mcp', - 'add', - '--transport', - 'http', - '--scope', - 'project', - SEAM_MCP_SERVER_NAME, - AUTHENTICATED_SEAM_MCP_URL, -] - -// What `claude mcp add` would have written, for the developer to paste when the -// CLI is missing or another agent is in use. -export function mcpJsonSnippet(): string { - return JSON.stringify( - { - mcpServers: { - [SEAM_MCP_SERVER_NAME]: { - type: 'http', - url: AUTHENTICATED_SEAM_MCP_URL, - }, - }, - }, - null, - 2, - ) -} - -export const UNIVERSAL_MCP_HINTS = [ - `Cursor — add the same mcpServers block to .cursor/mcp.json`, - `Codex — add [mcp_servers.${SEAM_MCP_SERVER_NAME}] with url = "${AUTHENTICATED_SEAM_MCP_URL}" to ~/.codex/config.toml`, - `OpenCode — add "${SEAM_MCP_SERVER_NAME}": { "type": "remote", "url": "${AUTHENTICATED_SEAM_MCP_URL}" } under "mcp" in opencode.json`, -] as const -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `npx vitest run src/lib/steps/register-seam-mcp.test.ts` - -Expected: PASS — 5 tests. - -- [ ] **Step 5: Lint and typecheck** - -Run: `npm run lint && npm run typecheck` - -Expected: both exit 0 with no findings. If prettier complains about the new file, run `npm run format` and re-run. - -- [ ] **Step 6: Commit** - -```bash -git add src/lib/steps/register-seam-mcp.ts src/lib/steps/register-seam-mcp.test.ts -git commit -m "feat(mcp): add the authenticated Seam MCP registration constants - -The claude mcp add argv, the equivalent .mcp.json snippet, and one-line -Cursor/Codex/OpenCode hints, all built from a single authenticated-URL -constant. Tests pin the exact argv and assert nothing emitted here points -at the anonymous /mcp or could carry an API key. - -PLA-2951 - -Co-Authored-By: Claude Fable 5.1 " -``` - ---- - -### Task 2: The registration runner - -**Files:** - -- Modify: `src/lib/steps/register-seam-mcp.ts` (append the runner below the constants from Task 1) -- Modify: `src/lib/steps/register-seam-mcp.test.ts` (append the runner tests) - -**Interfaces:** - -- Consumes: `runInstall(command: string[], cwd: string, onLine: (line: string) => void): Promise` from `lib/run-install.js`. It spawns with `stdio: ['ignore', 'pipe', 'pipe']`, `shell: false`, no `env` override (so the child inherits the wizard's environment and nothing key-bearing is added); it rejects with the spawn `error` event — an `Error` whose `code` is `'ENOENT'` when the binary is missing — and with `new Error("claude exited with code ")` on a non-zero close. Also `CLAUDE_MCP_ADD_COMMAND` from Task 1. -- Produces: - - `type McpRegistration = 'claude_cli' | 'printed' | 'failed'` - - `type RunCommand = (command: string[], cwd: string, onLine: (line: string) => void) => Promise` - - `function registerSeamMcpWithClaudeCli(args: { root: string; onLine: (line: string) => void; runCommand?: RunCommand }): Promise<'claude_cli' | 'printed'>` - -- [ ] **Step 1: Write the failing tests** - -Append to `src/lib/steps/register-seam-mcp.test.ts`: - -```ts -test('registerSeamMcpWithClaudeCli reports claude_cli after a clean run', async () => { - const calls: Array<{ command: string[]; cwd: string }> = [] - - const registration = await registerSeamMcpWithClaudeCli({ - root: '/tmp/seam-wizard-project', - onLine: () => {}, - runCommand: async (command, cwd) => { - calls.push({ command, cwd }) - }, - }) - - expect(registration).toBe('claude_cli') - expect(calls).toEqual([ - { - command: CLAUDE_MCP_ADD_COMMAND, - cwd: '/tmp/seam-wizard-project', - }, - ]) -}) - -// The developer may not have the Claude Code CLI on PATH at all: spawn rejects -// with ENOENT before anything runs, and the wizard has to fall back to printing. -test('registerSeamMcpWithClaudeCli reports printed when the binary is missing', async () => { - const registration = await registerSeamMcpWithClaudeCli({ - root: '/tmp/seam-wizard-project', - onLine: () => {}, - runCommand: async () => { - throw Object.assign(new Error('spawn claude ENOENT'), { code: 'ENOENT' }) - }, - }) - - expect(registration).toBe('printed') -}) - -test('registerSeamMcpWithClaudeCli reports printed on a non-zero exit', async () => { - const registration = await registerSeamMcpWithClaudeCli({ - root: '/tmp/seam-wizard-project', - onLine: () => {}, - runCommand: async () => { - throw new Error('claude exited with code 1') - }, - }) - - expect(registration).toBe('printed') -}) - -test('registerSeamMcpWithClaudeCli streams the command output it is given', async () => { - const lines: string[] = [] - - await registerSeamMcpWithClaudeCli({ - root: '/tmp/seam-wizard-project', - onLine: (line) => lines.push(line), - runCommand: async (_command, _cwd, onLine) => { - onLine('Added HTTP MCP server seam') - }, - }) - - expect(lines).toEqual(['Added HTTP MCP server seam']) -}) -``` - -Extend the existing import in that file so it also pulls `registerSeamMcpWithClaudeCli` (keep the named imports alphabetical for `simple-import-sort`): - -```ts -import { - AUTHENTICATED_SEAM_MCP_URL, - CLAUDE_MCP_ADD_COMMAND, - mcpJsonSnippet, - registerSeamMcpWithClaudeCli, - SEAM_MCP_SERVER_NAME, - UNIVERSAL_MCP_HINTS, -} from './register-seam-mcp.js' -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `npx vitest run src/lib/steps/register-seam-mcp.test.ts` - -Expected: FAIL — `registerSeamMcpWithClaudeCli is not a function` (or a TS/resolve error on the missing export) on the four new tests; the five from Task 1 still pass. - -- [ ] **Step 3: Implement the runner** - -In `src/lib/steps/register-seam-mcp.ts`, add the import at the top (packages/aliases group, above nothing else — this is the file's only import): - -```ts -import { runInstall } from 'lib/run-install.js' -``` - -Add the types directly under `AUTHENTICATED_SEAM_MCP_URL`'s block: - -```ts -// What the run did about MCP registration, as reported on -// wizard_install_finished. 'printed' covers both fallbacks — a missing or -// failing CLI, and a non-Claude-Code project that only gets the snippet. -// 'failed' is the caller's outcome when the registration step itself threw, so -// the developer got neither a registration nor a snippet. -export type McpRegistration = 'claude_cli' | 'printed' | 'failed' - -export type RunCommand = ( - command: string[], - cwd: string, - onLine: (line: string) => void, -) => Promise -``` - -Add the runner at the bottom of the file, below `UNIVERSAL_MCP_HINTS`: - -```ts -// Register the authenticated MCP with the Claude Code CLI, in the project the -// wizard is setting up. Any spawn failure — no `claude` on PATH (ENOENT), or a -// non-zero exit — is a fallback, not an error: the caller prints the snippet -// instead. `runCommand` is injected so a test can drive both outcomes. -export async function registerSeamMcpWithClaudeCli({ - root, - onLine, - runCommand = runInstall, -}: { - root: string - onLine: (line: string) => void - runCommand?: RunCommand -}): Promise<'claude_cli' | 'printed'> { - try { - await runCommand(CLAUDE_MCP_ADD_COMMAND, root, onLine) - return 'claude_cli' - } catch { - return 'printed' - } -} -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `npx vitest run src/lib/steps/register-seam-mcp.test.ts` - -Expected: PASS — 9 tests. - -- [ ] **Step 5: Lint and typecheck** - -Run: `npm run lint && npm run typecheck` - -Expected: both exit 0. In particular there must be no `no-restricted-imports` error: `runInstall` is imported as `lib/run-install.js`, never `../run-install.js`. - -- [ ] **Step 6: Commit** - -```bash -git add src/lib/steps/register-seam-mcp.ts src/lib/steps/register-seam-mcp.test.ts -git commit -m "feat(mcp): spawn claude mcp add with a printed fallback - -registerSeamMcpWithClaudeCli runs the argv through the same runInstall -spawn the SDK and plugin installs use (stdin ignored, no env override, so -no API key can reach the child) and maps a missing binary or non-zero exit -to 'printed' so the caller can show the .mcp.json snippet instead. - -PLA-2951 - -Co-Authored-By: Claude Fable 5.1 " -``` - ---- - -### Task 3: Wire registration into the install-plugin phase, analytics, and the done screen - -**Files:** - -- Modify: `src/lib/steps/register-seam-mcp.ts` (add the notice composer) -- Modify: `src/lib/steps/register-seam-mcp.test.ts` (composer tests) -- Modify: `src/lib/app.tsx` — the import block (lines 74-79) and the `install-plugin` effect (lines 725-780) -- Modify: `src/lib/screens/done.tsx` -- Modify: `src/lib/screens/done.test.tsx` - -**Interfaces:** - -- Consumes from Tasks 1-2: `CLAUDE_MCP_ADD_COMMAND`, `mcpJsonSnippet()`, `UNIVERSAL_MCP_HINTS`, `type McpRegistration`, `registerSeamMcpWithClaudeCli`. From existing code: `detectPluginTarget(root): PluginTarget`, `SEAM_PLUGIN_NPX_COMMAND`, `CLAUDE_CODE_COMMANDS` (`src/lib/steps/install-seam-plugin.ts`); `addMessage(message: { tone: 'ok' | 'info' | 'warn' | 'plain'; text: string }): void` (`app.tsx:205`); `trackInstallFinished(target: 'sdk' | 'plugin', ok: boolean, properties: Record): void` (`app.tsx:288`) — its third parameter is already `Record`, so adding `mcp_registration` needs no signature change; typing comes from declaring the value as `McpRegistration` at the call site. -- Produces: - - `interface McpNotice { tone: 'info' | 'warn' | 'plain'; text: string }` - - `function buildMcpRegistrationNotices(args: { target: PluginTarget; registration: McpRegistration }): McpNotice[]` - - `const AGENT_CONSENT_NOTICE: string` exported from `src/lib/screens/done.js` - -- [ ] **Step 1: Write the failing composer tests** - -Append to `src/lib/steps/register-seam-mcp.test.ts` (and add `buildMcpRegistrationNotices` to that file's existing import list, keeping it alphabetical — it sorts first, before `CLAUDE_MCP_ADD_COMMAND`): - -```ts -test('buildMcpRegistrationNotices confirms a CLI registration without reprinting it', () => { - const notices = buildMcpRegistrationNotices({ - target: 'claude-code', - registration: 'claude_cli', - }) - - expect(notices).toHaveLength(1) - expect(notices[0]?.tone).toBe('info') - expect(notices[0]?.text).toContain('Registered the Seam MCP') - expect(notices.map((notice) => notice.text).join('\n')).not.toContain( - 'mcpServers', - ) -}) - -test('buildMcpRegistrationNotices prints the snippet when the CLI could not register', () => { - const notices = buildMcpRegistrationNotices({ - target: 'claude-code', - registration: 'printed', - }) - const text = notices.map((notice) => notice.text).join('\n') - - expect(text).toContain('.mcp.json') - expect(text).toContain('https://mcp.seam.co/mcp/authenticated') - expect(JSON.parse(mcpJsonSnippet())).toBeTruthy() - // A Claude Code project does not need another agent's config file named at it. - expect(text).not.toContain('opencode.json') -}) - -test('buildMcpRegistrationNotices adds the per-tool hints for a universal project', () => { - const text = buildMcpRegistrationNotices({ - target: 'universal', - registration: 'printed', - }) - .map((notice) => notice.text) - .join('\n') - - for (const hint of UNIVERSAL_MCP_HINTS) { - expect(text).toContain(hint) - } -}) - -test('buildMcpRegistrationNotices warns and prints when registration failed', () => { - const notices = buildMcpRegistrationNotices({ - target: 'claude-code', - registration: 'failed', - }) - const text = notices.map((notice) => notice.text).join('\n') - - expect(notices[0]?.tone).toBe('warn') - expect(text).toContain(CLAUDE_MCP_ADD_COMMAND.join(' ')) - expect(text).toContain('https://mcp.seam.co/mcp/authenticated') -}) -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `npx vitest run src/lib/steps/register-seam-mcp.test.ts` - -Expected: FAIL — `buildMcpRegistrationNotices is not a function` on the four new tests. - -- [ ] **Step 3: Implement the composer** - -In `src/lib/steps/register-seam-mcp.ts`, add the `PluginTarget` type import (it belongs in the trailing `./` relative group, so it goes below the `lib/run-install.js` import): - -```ts -import type { PluginTarget } from './install-seam-plugin.js' -``` - -Add the interface next to `McpRegistration`: - -```ts -// A line for the Ink app to render. The tones are the app's own Msg tones minus -// 'ok', which is reserved there for a step that actually succeeded. -export interface McpNotice { - tone: 'info' | 'warn' | 'plain' - text: string -} -``` - -Add the composer at the bottom of the file, below `registerSeamMcpWithClaudeCli`: - -```ts -export function buildMcpRegistrationNotices({ - target, - registration, -}: { - target: PluginTarget - registration: McpRegistration -}): McpNotice[] { - if (registration === 'claude_cli') { - return [ - { - tone: 'info', - text: 'Registered the Seam MCP for Claude Code in .mcp.json (project scope)', - }, - ] - } - - const heading: McpNotice = - registration === 'failed' - ? { - tone: 'warn', - text: `Couldn't register the Seam MCP — run it yourself: ${CLAUDE_MCP_ADD_COMMAND.join(' ')}`, - } - : { - tone: 'info', - text: 'Add the Seam MCP to your coding agent — put this in .mcp.json:', - } - - const snippetLines: McpNotice[] = mcpJsonSnippet() - .split('\n') - .map((line) => ({ tone: 'plain', text: ` ${line}` })) - - const hintLines: McpNotice[] = - target === 'universal' - ? UNIVERSAL_MCP_HINTS.map((hint) => ({ - tone: 'plain', - text: ` ${hint}`, - })) - : [] - - return [heading, ...snippetLines, ...hintLines] -} -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `npx vitest run src/lib/steps/register-seam-mcp.test.ts` - -Expected: PASS — 13 tests. - -- [ ] **Step 5: Wire the install-plugin phase in `app.tsx`** - -Add the new import to `src/lib/app.tsx` after the `./steps/integrate.js` import at line 79 (alphabetically `install-seam-plugin` < `integrate` < `register-seam-mcp`): - -```tsx -import { - buildMcpRegistrationNotices, - type McpRegistration, - registerSeamMcpWithClaudeCli, -} from './steps/register-seam-mcp.js' -``` - -Replace the whole body of the `install-plugin` effect (`src/lib/app.tsx:725-780`, the block whose comment begins `// install the official Seam plugin skills, then finish.`) with: - -```tsx -// install the official Seam plugin skills, then register the authenticated -// Seam MCP so the developer's own agent gets a delegated grant instead of -// reading the app's key out of .env. For Claude Code we additionally point at -// the native /plugin path, which wires up the anonymous docs MCP. -useEffect(() => { - if (phase.t !== 'install-plugin') return - const target = detectPluginTarget(root) - - let cancelled = false - const streamLine = (line: string): void => { - if (!cancelled) { - setInstallLines((previous) => [...previous.slice(-3), line]) - } - } - const run = async (): Promise => { - installStartedAtRef.current = Date.now() - let skillsInstalled = true - try { - await runInstall(SEAM_PLUGIN_NPX_COMMAND, root, streamLine) - } catch { - skillsInstalled = false - } - if (cancelled) return - - addMessage( - skillsInstalled - ? { tone: 'ok', text: 'Installed the Seam plugin skills' } - : { - tone: 'warn', - text: `Couldn't install the plugin — run it yourself: ${SEAM_PLUGIN_NPX_COMMAND.join(' ')}`, - }, - ) - - let registration: McpRegistration = 'printed' - if (target === 'claude-code') { - try { - registration = await registerSeamMcpWithClaudeCli({ - root, - onLine: streamLine, - }) - } catch { - registration = 'failed' - } - } - if (cancelled) return - - trackInstallFinished('plugin', skillsInstalled, { - plugin_target: target, - mcp_registration: registration, - }) - for (const notice of buildMcpRegistrationNotices({ - target, - registration, - })) { - addMessage(notice) - } - - setInstallLines([]) - if (target === 'claude-code') { - addMessage({ - tone: 'info', - text: 'Claude Code: for the native plugin + seam-docs MCP, you can also run:', - }) - for (const command of CLAUDE_CODE_COMMANDS) { - addMessage({ tone: 'plain', text: ` ${command}` }) - } - } - setPhase({ t: 'offer-integrate' }) - } - run().catch((error: unknown) => { - if (cancelled) return - setPhase({ - t: 'error', - message: - error instanceof Error - ? error.message - : 'The wizard hit an unexpected error.', - }) - }) - return () => { - cancelled = true - } -}, [phase.t]) -``` - -Three things changed beyond the new registration: the streamed-line closure is hoisted so both spawns share it, the skills-install messages moved out of the `try`/`catch` so registration can run before the analytics event, and `trackInstallFinished` fires once with both properties. - -- [ ] **Step 6: Add the done-screen consent copy** - -In `src/lib/screens/done.tsx`, add the exported constant directly above the `DoneScreen` function (below the `IntegrationOutcome` interface): - -```tsx -// Verbatim from the merge design: the developer is told, before they leave, that -// the agent authenticates itself rather than reusing the app's key. -export const AGENT_CONSENT_NOTICE = - 'Your coding agent will be asked to sign in to Seam and choose permissions the first time it uses a Seam tool.' -``` - -Render it in the outer column, between the assistant-link block and the "Press any key to exit" margin box (it sits outside the bordered card, which is too narrow for a sentence this long): - -```tsx - - {AGENT_CONSENT_NOTICE} - -``` - -- [ ] **Step 7: Write the done-screen test** - -Append to `src/lib/screens/done.test.tsx`, and add `AGENT_CONSENT_NOTICE` to its existing `./done.js` import: - -```tsx -test('DoneScreen: says the agent will sign in and choose permissions', () => { - const { lastFrame, unmount } = render( - , - ) - try { - // Ink wraps the sentence across rows, so compare on collapsed whitespace. - const frame = (lastFrame() ?? '').replace(/\s+/g, ' ') - expect(frame).toContain(AGENT_CONSENT_NOTICE) - } finally { - unmount() - } -}) - -test('AGENT_CONSENT_NOTICE is the copy the merge design specifies', () => { - expect(AGENT_CONSENT_NOTICE).toBe( - 'Your coding agent will be asked to sign in to Seam and choose permissions the first time it uses a Seam tool.', - ) -}) -``` - -- [ ] **Step 8: Run the affected tests** - -Run: `npx vitest run src/lib/steps/register-seam-mcp.test.ts src/lib/screens/done.test.tsx test/app.test.tsx` - -Expected: PASS — 13 register-seam-mcp tests, 5 done-screen tests, and the pre-existing app tests unchanged. If the frame assertion fails, print `lastFrame()` to check the notice is not being clipped by the terminal height the test harness reports; widen the assertion to the first clause only if the sentence is genuinely truncated, and keep the exact-copy test as the authority. - -- [ ] **Step 9: Lint and typecheck** - -Run: `npm run lint && npm run typecheck` - -Expected: both exit 0. - -- [ ] **Step 10: Commit** - -```bash -git add src/lib/steps/register-seam-mcp.ts src/lib/steps/register-seam-mcp.test.ts src/lib/app.tsx src/lib/screens/done.tsx src/lib/screens/done.test.tsx -git commit -m "feat(wizard): register the authenticated Seam MCP after the skills install - -The install-plugin phase now runs claude mcp add in the project root when -Claude Code is detected, and prints the .mcp.json snippet (plus Cursor, -Codex, and OpenCode hints for a universal project) when it cannot. The -outcome rides on wizard_install_finished as mcp_registration, and the done -screen tells the developer their agent will sign in and pick permissions on -first use. The /plugin lines stay as the optional docs-plugin path. - -PLA-2951 - -Co-Authored-By: Claude Fable 5.1 " -``` - ---- - -### Task 4: Refuse to write `SEAM_API_KEY` through a symlink - -**Files:** - -- Modify: `src/lib/env-file.ts:1` (imports), `:4` (`EnvWriteResult`), `:103-126` (`upsertEnvVar`) -- Modify: `src/lib/env-file.test.ts` -- Modify: `src/lib/steps/authenticate.ts:5-19` (`AuthResult`), `:62-64` (`saveVerifiedKey`) -- Modify: `src/lib/steps/authenticate.test.ts` -- Modify: `src/lib/steps/connect-web.ts:18-21` (`WebConnectResult`), `:116` -- Modify: `src/lib/app.tsx` — `useCliKey` (~line 429), `useProjectKey` (~line 445), the `verify-paste` effect (~line 636), the `browser` effect (~line 584) - -**Interfaces:** - -- Consumes: `existsSync`, `readFileSync`, `writeFileSync` from `node:fs` (already imported in `env-file.ts`); `type ProjectEnvResult { env: EnvWriteResult; example: EnvWriteResult | 'unchanged'; gitignore: 'added' | 'unchanged' }` and `saveProjectApiKey(root: string, apiKey: string): ProjectEnvResult` (already exported). -- Produces: - - `type EnvWriteResult = 'created' | 'updated' | 'added' | 'symlink-refused'` (fourth variant added) - - `const ENV_SYMLINK_REFUSAL_MESSAGE: string` - - `saveVerifiedKey(root: string, apiKey: string): ProjectEnvResult` (was `void`) - - `interface AuthResult { workspace: SeamWorkspace; api_key: string; env: ProjectEnvResult }` (field added) - - `interface WebConnectResult { workspace: SeamWorkspace; api_key: string; env: ProjectEnvResult }` (field added) - -- [ ] **Step 1: Write the failing tests** - -Append to `src/lib/env-file.test.ts` (and add `symlinkSync` to its `node:fs` import list, and `ENV_SYMLINK_REFUSAL_MESSAGE` to its `./env-file.js` import list): - -```ts -// A symlinked .env usually points at a shared secrets file outside the repo. -// Writing through it would edit that file — and it is exactly the case where -// the wizard cannot know the destination is the developer's to change. -test('upsertEnvVar refuses a symlinked file and leaves the target untouched', () => { - const targetPath = join(dir, 'shared-secrets.env') - const linkPath = join(dir, '.env') - writeFileSync(targetPath, 'OTHER=1\n') - symlinkSync(targetPath, linkPath) - - expect(upsertEnvVar(linkPath, 'SEAM_API_KEY', 'seam_new')).toBe( - 'symlink-refused', - ) - expect(readFileSync(targetPath, 'utf8')).toBe('OTHER=1\n') -}) - -// existsSync follows the link, so a dangling one would otherwise look absent -// and get created at the far end. -test('upsertEnvVar refuses a dangling symlink without creating its target', () => { - const targetPath = join(dir, 'missing-secrets.env') - const linkPath = join(dir, '.env') - symlinkSync(targetPath, linkPath) - - expect(upsertEnvVar(linkPath, 'SEAM_API_KEY', 'seam_new')).toBe( - 'symlink-refused', - ) - expect(existsSync(targetPath)).toBe(false) -}) - -test('saveProjectApiKey reports the refusal and still ignores .env', () => { - mkdirSync(join(dir, '.git')) - const targetPath = join(dir, 'shared-secrets.env') - writeFileSync(targetPath, 'OTHER=1\n') - symlinkSync(targetPath, join(dir, '.env')) - - const result = saveProjectApiKey(dir, 'seam_new_key') - - expect(result.env).toBe('symlink-refused') - expect(result.gitignore).toBe('added') - expect(readFileSync(targetPath, 'utf8')).not.toContain('seam_new_key') -}) - -test('ENV_SYMLINK_REFUSAL_MESSAGE tells the developer what to do, without a key', () => { - expect(ENV_SYMLINK_REFUSAL_MESSAGE).toContain('SEAM_API_KEY') - expect(ENV_SYMLINK_REFUSAL_MESSAGE).toContain('symlink') - expect(ENV_SYMLINK_REFUSAL_MESSAGE).not.toMatch(/seam_[A-Za-z0-9]/) -}) -``` - -Append to `src/lib/steps/authenticate.test.ts` (add `symlinkSync` and `writeFileSync` to its `node:fs` import): - -```ts -// The refusal is only useful if it reaches the Ink app, which reads it off the -// result of the save. -test('verifyAndSaveKey reports a symlinked .env instead of writing through it', async () => { - get.mockResolvedValue(workspace) - const targetPath = join(dir, 'shared-secrets.env') - writeFileSync(targetPath, 'OTHER=1\n') - symlinkSync(targetPath, join(dir, '.env')) - - const result = await verifyAndSaveKey(dir, 'seam_pasted_key') - - expect(result.env.env).toBe('symlink-refused') - expect(readFileSync(targetPath, 'utf8')).toBe('OTHER=1\n') -}) -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `npx vitest run src/lib/env-file.test.ts src/lib/steps/authenticate.test.ts` - -Expected: FAIL — the `upsertEnvVar` symlink tests report `'updated'`/`'added'` instead of `'symlink-refused'` and show the target file rewritten; `ENV_SYMLINK_REFUSAL_MESSAGE` is undefined; `result.env` is undefined in the authenticate test. - -- [ ] **Step 3: Add the guard to `env-file.ts`** - -Change the imports on line 1 and the type on line 4: - -```ts -import { existsSync, lstatSync, readFileSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' - -export type EnvWriteResult = 'created' | 'updated' | 'added' | 'symlink-refused' - -export const ENV_SYMLINK_REFUSAL_MESSAGE = - '.env is a symlink — the wizard did not write through it. Add SEAM_API_KEY to the real file yourself.' -``` - -Add the symlink check as the first thing `upsertEnvVar` does, above the `existsSync` branch (`existsSync` resolves the link, so it must not run first): - -```ts -export function upsertEnvVar( - filePath: string, - key: string, - value: string, -): EnvWriteResult { - const line = `${key}=${value}` - - const link = lstatSync(filePath, { throwIfNoEntry: false }) - if (link?.isSymbolicLink() === true) { - return 'symlink-refused' - } - - if (!existsSync(filePath)) { -``` - -The rest of the function is unchanged. - -- [ ] **Step 4: Propagate the result through the save paths** - -In `src/lib/steps/authenticate.ts`, import the result type and add the field: - -```ts -import { - findExistingApiKey, - type ProjectEnvResult, - saveProjectApiKey, -} from 'lib/env-file.js' - -export interface AuthResult { - workspace: SeamWorkspace - api_key: string - env: ProjectEnvResult -} -``` - -and change the two functions at the bottom: - -```ts -export async function verifyAndSaveKey( - root: string, - apiKey: string, -): Promise { - const trimmed = apiKey.trim() - const workspace = await getWorkspaceForApiKey(trimmed) - return { workspace, api_key: trimmed, env: saveProjectApiKey(root, trimmed) } -} - -export function saveVerifiedKey( - root: string, - apiKey: string, -): ProjectEnvResult { - return saveProjectApiKey(root, apiKey) -} -``` - -In `src/lib/steps/connect-web.ts`, extend the import and the result type, and return the env result: - -```ts -import { type ProjectEnvResult, saveProjectApiKey } from 'lib/env-file.js' - -export interface WebConnectResult { - workspace: SeamWorkspace - api_key: string - env: ProjectEnvResult -} -``` - -```ts -const workspace = await getWorkspaceForApiKey(payload.api_key) -return { - workspace, - api_key: payload.api_key, - env: saveProjectApiKey(root, payload.api_key), -} -``` - -- [ ] **Step 5: Surface the refusal in `app.tsx`** - -Extend the `./env-file.js` import at line 29: - -```tsx -import { - ensureProjectEnvConventions, - ENV_SYMLINK_REFUSAL_MESSAGE, - findExistingApiKey, - type ProjectEnvResult, -} from './env-file.js' -``` - -Add this helper immediately below `addMessage` (`src/lib/app.tsx:205-206`): - -```tsx -const reportEnvWrite = (result: ProjectEnvResult): void => { - if (result.env === 'symlink-refused') { - addMessage({ tone: 'warn', text: ENV_SYMLINK_REFUSAL_MESSAGE }) - } -} -``` - -Then call it at the four save sites. `useCliKey` (~line 429): - -```tsx - const useCliKey = (found: CliKeyResult): void => { - try { - reportEnvWrite(saveVerifiedKey(root, found.api_key)) - addMessage({ - tone: 'ok', - text: `Using your Seam CLI login · workspace ${found.workspace.name} · saved to .env`, - }) - } catch { -``` - -`useProjectKey` (~line 445) — only the `environment` branch writes a key: - -```tsx -if (found.source === 'environment') { - reportEnvWrite(saveVerifiedKey(root, found.api_key)) -} else { - ensureProjectEnvConventions(root) -} -``` - -The `verify-paste` effect (~line 636): - -```tsx -const result = await verifyAndSaveKey(root, apiKey) -if (cancelled) return -reportEnvWrite(result.env) -addMessage({ tone: 'ok', text: `Workspace: ${result.workspace.name}` }) -``` - -The `browser` effect (~line 595), right after the `if (cancelled) return`: - -```tsx -if (cancelled) return -reportEnvWrite(result.env) -addMessage({ - tone: 'ok', - text: `Connected · workspace ${result.workspace.name}`, -}) -``` - -- [ ] **Step 6: Run the affected tests** - -Run: `npx vitest run src/lib/env-file.test.ts src/lib/steps/authenticate.test.ts test/app.test.tsx` - -Expected: PASS — the four new `env-file` tests, the new authenticate test, every pre-existing `.env` preservation / `.env.example` / `.gitignore` test, and the app tests. No pre-existing assertion may be edited to make this pass: `saveProjectApiKey`'s `toEqual({ env: 'created', example: 'created', gitignore: 'added' })` must still hold for a plain file. - -- [ ] **Step 7: Lint and typecheck** - -Run: `npm run lint && npm run typecheck` - -Expected: both exit 0. `tsc` is what proves the three widened result types have no un-updated caller. - -- [ ] **Step 8: Commit** - -```bash -git add src/lib/env-file.ts src/lib/env-file.test.ts src/lib/steps/authenticate.ts src/lib/steps/authenticate.test.ts src/lib/steps/connect-web.ts src/lib/app.tsx -git commit -m "fix(env): refuse to write SEAM_API_KEY through a symlinked .env - -existsSync resolves a symlink, so the wizard would happily write the key -into whatever the link pointed at — typically a shared secrets file outside -the repo that .gitignore does not cover. upsertEnvVar now lstats first and -returns 'symlink-refused' without writing; the save paths carry that result -up so the app tells the developer to add SEAM_API_KEY by hand. The -created/updated/added report and ensureGitignored are unchanged, and there -is no destination-approval prompt. - -PLA-2951 - -Co-Authored-By: Claude Fable 5.1 " -``` - ---- - -### Task 5: Final verification and PR - -**Files:** none new. - -- [ ] **Step 1: Run the whole suite** - -Run: `npm test` - -Expected: every test file passes. Coverage is reported but not gated. - -- [ ] **Step 2: Lint, format check, and typecheck** - -Run: `npm run lint && npm run typecheck` - -Expected: both exit 0. If prettier reports a file, run `npm run format`, re-run, and amend the commit that introduced it. - -- [ ] **Step 3: Confirm the invariant held** - -Run: - -```bash -git diff main...HEAD | grep -nE '^\+.*(SEAM_API_KEY|seam_[A-Za-z0-9]|apiKey)' -``` - -Expected: the only additions naming `SEAM_API_KEY` are the refusal message, the `env-file` tests, and the `AGENT_CONSENT_NOTICE`-adjacent test literals — never a value interpolated into a message, a log line, an argv, or a subprocess `env`. Then: - -```bash -git diff main...HEAD -- src/lib/steps/harness/ -``` - -Expected: no output. The harnesses keep the anonymous `https://mcp.seam.co/mcp`. - -- [ ] **Step 4: Review the comments the branch added** - -Run: `git diff main...HEAD -U0 -- src | grep -E '^\+\s*(//|/\*|\*)'` - -For each: would it read identically at every similar site (generality)? Is a test already the explanation (test coverage)? Is it aimed at a reviewer (audience)? Delete any that fail and amend. The ones written here are meant to survive: each records a _why_ the code cannot state — why the authenticated URL differs from the plugin's, why a spawn failure is a fallback rather than an error, why `lstat` has to precede `existsSync`. - -- [ ] **Step 5: Push and open the PR** - -```bash -git push -u origin phil/pla-2951-agent-grant-registration -gh pr create --title "feat(wizard): register the authenticated Seam MCP and harden .env (PLA-2951)" --body "$(cat <<'EOF' -## Summary - -A developer's coding agent that arrives via `seam wizard` had no Seam credential of its own, so in practice it borrowed the app's durable key out of `.env`. After this change the wizard registers the **authenticated** Seam MCP (`https://mcp.seam.co/mcp/authenticated`) with the agent, which answers an anonymous request with `401 + WWW-Authenticate` — so the agent runs the consent flow and ends up on its own short-lived, revocable delegated grant instead. - -Workstream C of the merge design (`seam-connect: docs/superpowers/specs/2026-09-01-api-key-bootstrap-delegated-agent-merge-design.md`, §5.C). Workstream A shipped as seamapi/seam-connect#17512. - -## Changes - -- **New `src/lib/steps/register-seam-mcp.ts`** — the exact `claude mcp add --transport http --scope project seam https://mcp.seam.co/mcp/authenticated` argv, the equivalent `.mcp.json` snippet, one-line Cursor / Codex / OpenCode hints, and a runner that spawns the argv in the project root through the existing `runInstall` seam. A missing `claude` binary (ENOENT) or a non-zero exit falls back to printing the snippet. -- **`install-plugin` phase** — runs registration after the skills install; `wizard_install_finished` now carries `mcp_registration: 'claude_cli' | 'printed' | 'failed'` alongside `plugin_target`. The `/plugin` slash-command lines stay as the optional docs-plugin path. -- **Done screen** — "Your coding agent will be asked to sign in to Seam and choose permissions the first time it uses a Seam tool." -- **`env-file.ts`** — `upsertEnvVar` `lstat`s the target first and returns `'symlink-refused'` without writing, so the key never lands in a shared secrets file the link pointed at. The `created | updated | added` report and `ensureGitignored` are unchanged, and there is no destination-approval prompt. The refusal is surfaced by the app, which tells the developer to add `SEAM_API_KEY` by hand. -- **Unchanged on purpose** — the embedded agent harnesses keep the anonymous `https://mcp.seam.co/mcp` (they only need docs), and `seamapi/seam-plugin`'s registered URL is untouched. - -The invariant holds throughout: the API key is never printed and never enters the agent's context. Only the `seam_wiz_` inference token reaches a subprocess env, and the new spawn passes no env at all. - -## Test plan - -- [x] `npm test` — new unit tests for the argv, the snippet's JSON, the hints, the three runner outcomes with an injected runner, the composed notices, the symlink refusal (live and dangling links, target byte-identical afterwards), and the propagation up through `verifyAndSaveKey` -- [x] `npm run lint && npm run typecheck` -- [ ] Manual: `npm run wizard` in a scratch project with `.claude/` present (expect a registered `.mcp.json`) and in one without (expect the printed snippet plus hints) - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - -Expected: PR URL printed.