diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 53cea17..214a04c 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -132,7 +132,7 @@ RESOURCES spec {init,list,show,status,generate,approve,validate,test-report,delete} skill {create,list,show,add-reference,add-script,add-asset,validate,delete} agent {create,list,show,delete} - mcp {add,list,show,remove,enable,disable,validate} + mcp {add,install,presets,list,show,remove,enable,disable,validate} export {kiro,codex} Convert the workspace to Kiro / Codex format. web Launch a read-only web dashboard (live spec progress + file viewer). diff --git a/internal/cli/mcp.go b/internal/cli/mcp.go index 4a4ee19..efd56d7 100644 --- a/internal/cli/mcp.go +++ b/internal/cli/mcp.go @@ -46,6 +46,10 @@ func runMCP(args []string) int { switch action { case "add": return mcpAdd(rest) + case "install": + return mcpInstall(rest) + case "presets": + return mcpPresets(rest) case "list": return mcpList(rest) case "show": diff --git a/internal/cli/mcp_presets.go b/internal/cli/mcp_presets.go new file mode 100644 index 0000000..5a90b0e --- /dev/null +++ b/internal/cli/mcp_presets.go @@ -0,0 +1,157 @@ +package cli + +import ( + "flag" + "fmt" + "sort" + "strings" + + "github.com/protonspy/csdd/internal/render" +) + +// Preset is a named, pre-filled MCP server configuration that `mcp install` +// expands into an MCPAddOptions and hands to MCPAdd. Adding a preset is a single +// registry entry — there is no separate write path, so duplicate detection, +// transport validation, and the on-disk shape are identical to a manual add. +type Preset struct { + Name string // server name written to .mcp.json (kebab-case) + Summary string // one-line description shown by `mcp presets` + Transport string // display label: "http" | "sse" | "stdio" + Command string // stdio only + Args []string // stdio only + URL string // remote only + Type string // remote only: "sse" | "http" + Note string // optional hint emitted after install (e.g. auth caveat) +} + +// mcpPresetRegistry holds the known servers `mcp install` can register. Keep the +// entries secret-free: a preset stores only non-sensitive connection details. +var mcpPresetRegistry = map[string]Preset{ + "context7": { + Name: "context7", + Summary: "Up-to-date library/API docs.", + Transport: "http", + URL: "https://mcp.context7.com/mcp", + Type: "http", + }, + "playwright": { + Name: "playwright", + Summary: "Browser automation for frontend e2e/QA.", + Transport: "stdio", + Command: "npx", + Args: []string{"@playwright/mcp@latest"}, + }, + "github": { + Name: "github", + Summary: "GitHub repos, PRs, issues, Actions.", + Transport: "http", + URL: "https://api.githubcopilot.com/mcp/", + Type: "http", + Note: "Requires GitHub auth — authenticate via your client's OAuth on first use (or add a PAT). No token is stored by csdd.", + }, +} + +// MCPPresets returns every preset sorted by name. The CLI listing and the TUI +// preset picker both read this, so neither can drift from the registry. +func MCPPresets() []Preset { + out := make([]Preset, 0, len(mcpPresetRegistry)) + for _, p := range mcpPresetRegistry { + out = append(out, p) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// presetNames returns the preset names, sorted — used in error messages. It +// projects from MCPPresets() so there is a single sort path. +func presetNames() []string { + presets := MCPPresets() + names := make([]string, len(presets)) + for i, p := range presets { + names[i] = p.Name + } + return names +} + +// MCPInstallPresetOptions is the headless input shared by the CLI and the TUI. +type MCPInstallPresetOptions struct { + Root string + Names []string + Force bool +} + +// MCPInstallPreset expands each named preset to an MCPAddOptions and installs it +// via MCPAdd. Every name is validated before any write, so an unknown name in a +// multi-install leaves .mcp.json untouched. Each successful add prints its own +// render.OK; a preset Note is surfaced afterwards. +func MCPInstallPreset(opts MCPInstallPresetOptions) error { + if len(opts.Names) == 0 { + return fmt.Errorf("no preset name given") + } + for _, name := range opts.Names { + if _, ok := mcpPresetRegistry[name]; !ok { + return fmt.Errorf("unknown preset: %s (available: %s)", name, strings.Join(presetNames(), ", ")) + } + } + for _, name := range opts.Names { + p := mcpPresetRegistry[name] + add := MCPAddOptions{ + Root: opts.Root, + Name: p.Name, + Command: p.Command, + Args: p.Args, + URL: p.URL, + Type: p.Type, + Force: opts.Force, + } + if err := MCPAdd(add); err != nil { + return err + } + if p.Note != "" { + render.Info(p.Note) + } + } + return nil +} + +func mcpInstall(args []string) int { + fs := flag.NewFlagSet("mcp install", flag.ContinueOnError) + var opts MCPInstallPresetOptions + addRoot(fs, &opts.Root) + addForce(fs, &opts.Force) + positionals, err := parseFlags(fs, args) + if err != nil { + return failOnFlagParse(err) + } + if len(positionals) < 1 { + render.Err("usage: " + prog() + " mcp install NAME [NAME...] [--force]") + return 1 + } + opts.Names = positionals + if err := MCPInstallPreset(opts); err != nil { + render.Err(err.Error()) + return 1 + } + return 0 +} + +func mcpPresets(args []string) int { + fs := flag.NewFlagSet("mcp presets", flag.ContinueOnError) + var root string + addRoot(fs, &root) // accepted for surface symmetry; the registry is static. + if _, err := parseFlags(fs, args); err != nil { + return failOnFlagParse(err) + } + presets := MCPPresets() + maxName := len("name") + for _, p := range presets { + if len(p.Name) > maxName { + maxName = len(p.Name) + } + } + fmt.Printf(" %-*s %-7s %s\n", maxName, "name", "type", "summary") + for _, p := range presets { + fmt.Printf(" %-*s %-7s %s\n", maxName, p.Name, p.Transport, p.Summary) + } + return 0 +} diff --git a/internal/cli/mcp_presets_test.go b/internal/cli/mcp_presets_test.go new file mode 100644 index 0000000..49fe2df --- /dev/null +++ b/internal/cli/mcp_presets_test.go @@ -0,0 +1,213 @@ +package cli + +import ( + "strings" + "testing" +) + +// ---- registry accessor ---- + +func TestMCPPresetsAccessor(t *testing.T) { + got := MCPPresets() + if len(got) != 3 { + t.Fatalf("expected 3 presets, got %d: %+v", len(got), got) + } + // Name-sorted: context7 < github < playwright. + wantOrder := []string{"context7", "github", "playwright"} + for i, p := range got { + if p.Name != wantOrder[i] { + t.Errorf("preset[%d] = %q, want %q (must be name-sorted)", i, p.Name, wantOrder[i]) + } + if p.Summary == "" || p.Transport == "" { + t.Errorf("preset %q missing summary/transport: %+v", p.Name, p) + } + } +} + +// ---- install: per-preset transport correctness ---- + +func TestMCPInstallContext7(t *testing.T) { + dir := freshWorkspace(t) + if code, _, errOut := run(t, "mcp", "install", "context7", "--root", dir); code != 0 { + t.Fatalf("install context7 should succeed: %s", errOut) + } + srv := loadServer(t, dir, "context7") + if srv.URL != "https://mcp.context7.com/mcp" || srv.Type != "http" { + t.Errorf("context7 remote config wrong: %+v", srv) + } + if srv.Command != "" { + t.Errorf("remote preset must not carry a command: %+v", srv) + } +} + +func TestMCPInstallPlaywright(t *testing.T) { + dir := freshWorkspace(t) + if code, _, errOut := run(t, "mcp", "install", "playwright", "--root", dir); code != 0 { + t.Fatalf("install playwright should succeed: %s", errOut) + } + srv := loadServer(t, dir, "playwright") + if srv.Command != "npx" || len(srv.Args) != 1 || srv.Args[0] != "@playwright/mcp@latest" { + t.Errorf("playwright stdio config wrong: %+v", srv) + } + if srv.URL != "" || srv.Type != "" { + t.Errorf("stdio preset must not carry url/type: %+v", srv) + } +} + +func TestMCPInstallGithub(t *testing.T) { + dir := freshWorkspace(t) + if code, _, errOut := run(t, "mcp", "install", "github", "--root", dir); code != 0 { + t.Fatalf("install github should succeed: %s", errOut) + } + srv := loadServer(t, dir, "github") + if srv.URL != "https://api.githubcopilot.com/mcp/" || srv.Type != "http" { + t.Errorf("github remote config wrong: %+v", srv) + } + if srv.Command != "" || len(srv.Env) != 0 { + t.Errorf("github preset must store no command and no secret env: %+v", srv) + } +} + +func TestMCPInstallMultiple(t *testing.T) { + dir := freshWorkspace(t) + if code, _, errOut := run(t, "mcp", "install", "context7", "playwright", "github", "--root", dir); code != 0 { + t.Fatalf("multi-install should succeed: %s", errOut) + } + cfg, _ := loadMCP(mcpJSONPath(dir)) + for _, name := range []string{"context7", "playwright", "github"} { + if _, ok := cfg.MCPServers[name]; !ok { + t.Errorf("expected %q registered after multi-install", name) + } + } +} + +// ---- install: error paths ---- + +func TestMCPInstallUnknown(t *testing.T) { + dir := freshWorkspace(t) + code, _, errOut := run(t, "mcp", "install", "nope", "--root", dir) + if code != 1 { + t.Fatalf("unknown preset should exit 1, got %d", code) + } + if !strings.Contains(errOut, "unknown preset") { + t.Errorf("error should mention 'unknown preset': %q", errOut) + } + for _, name := range []string{"context7", "playwright", "github"} { + if !strings.Contains(errOut, name) { + t.Errorf("error should list valid preset %q: %q", name, errOut) + } + } + cfg, _ := loadMCP(mcpJSONPath(dir)) + if _, ok := cfg.MCPServers["nope"]; ok { + t.Error("unknown preset must not write any entry") + } +} + +func TestMCPInstallUnknownInMultiWritesNothing(t *testing.T) { + dir := freshWorkspace(t) + if code, _, _ := run(t, "mcp", "install", "context7", "nope", "--root", dir); code != 1 { + t.Fatalf("a single unknown name should reject the whole call (exit 1)") + } + cfg, _ := loadMCP(mcpJSONPath(dir)) + if _, ok := cfg.MCPServers["context7"]; ok { + t.Error("no entry should be written when any name is unknown") + } +} + +func TestMCPInstallDuplicateInMultiIsNotAtomic(t *testing.T) { + // Documented contract: name validation is up front, but installs go through + // MCPAdd per preset — so a *duplicate* later in the list does not roll back + // earlier successful adds (mirrors running two `mcp add` calls). + dir := freshWorkspace(t) + if code, _, _ := run(t, "mcp", "install", "github", "--root", dir); code != 0 { + t.Fatal("seed install of github should succeed") + } + // context7 is new, github already exists -> context7 lands, github errors. + if code, _, _ := run(t, "mcp", "install", "context7", "github", "--root", dir); code != 1 { + t.Fatalf("a duplicate later in the list should exit 1") + } + cfg, _ := loadMCP(mcpJSONPath(dir)) + if _, ok := cfg.MCPServers["context7"]; !ok { + t.Error("the earlier (new) preset should have been written before the duplicate failed") + } +} + +func TestMCPInstallMissingName(t *testing.T) { + dir := freshWorkspace(t) + if code, _, _ := run(t, "mcp", "install", "--root", dir); code != 1 { + t.Error("install without a preset name should exit 1") + } +} + +func TestMCPInstallDuplicateNeedsForce(t *testing.T) { + dir := freshWorkspace(t) + if code, _, _ := run(t, "mcp", "install", "context7", "--root", dir); code != 0 { + t.Fatal("first install should succeed") + } + if code, _, _ := run(t, "mcp", "install", "context7", "--root", dir); code == 0 { + t.Error("duplicate install without --force should fail") + } + if code, _, _ := run(t, "mcp", "install", "context7", "--force", "--root", dir); code != 0 { + t.Error("duplicate install with --force should replace") + } + srv := loadServer(t, dir, "context7") + if srv.URL == "" || srv.Type != "http" { + t.Errorf("forced reinstall should keep the context7 remote config: %+v", srv) + } +} + +func TestMCPInstallForceReplacesManualEntry(t *testing.T) { + dir := freshWorkspace(t) + // Pre-add a *different* (stdio) server under the same name. + if code, _, _ := run(t, "mcp", "add", "context7", "--command", "foo", "--root", dir); code != 0 { + t.Fatal("manual add should succeed") + } + if code, _, _ := run(t, "mcp", "install", "context7", "--force", "--root", dir); code != 0 { + t.Fatal("forced install should replace the manual entry") + } + srv := loadServer(t, dir, "context7") + if srv.URL != "https://mcp.context7.com/mcp" || srv.Type != "http" || srv.Command != "" { + t.Errorf("force install should fully overwrite with the preset config: %+v", srv) + } +} + +// ---- install: note emission ---- + +func TestMCPInstallGithubEmitsAuthNote(t *testing.T) { + dir := freshWorkspace(t) + code, out, _ := run(t, "mcp", "install", "github", "--root", dir) + if code != 0 { + t.Fatal("install github should succeed") + } + if !strings.Contains(strings.ToLower(out), "auth") { + t.Errorf("github install should surface an auth note:\n%s", out) + } +} + +// ---- presets listing ---- + +func TestMCPPresetsLists(t *testing.T) { + code, out, _ := run(t, "mcp", "presets") + if code != 0 { + t.Fatalf("mcp presets should exit 0, got %d", code) + } + for _, want := range []string{"name", "type", "summary", "context7", "playwright", "github", "http", "stdio"} { + if !strings.Contains(out, want) { + t.Errorf("presets listing missing %q:\n%s", want, out) + } + } +} + +// loadServer reads .mcp.json and returns the named server, failing if absent. +func loadServer(t *testing.T, dir, name string) MCPServer { + t.Helper() + cfg, err := loadMCP(mcpJSONPath(dir)) + if err != nil { + t.Fatal(err) + } + srv, ok := cfg.MCPServers[name] + if !ok { + t.Fatalf("server %q not found in %+v", name, cfg.MCPServers) + } + return srv +} diff --git a/internal/templater/templater_test.go b/internal/templater/templater_test.go index c05b705..4a592dc 100644 --- a/internal/templater/templater_test.go +++ b/internal/templater/templater_test.go @@ -189,6 +189,8 @@ func TestShippedWorkflowArtifactsPresent(t *testing.T) { "dev-sprint/SKILL.md", "dev-sprint/assets/sprint-status-template.yaml", "dev-retrospective/SKILL.md", + // frontend QA — Playwright e2e (shipped baseline) + "frontend-e2e-qa/SKILL.md", } for _, name := range requiredSkills { if _, ok := skills[name]; !ok { diff --git a/internal/templater/templates/agents/implementer.md.tmpl b/internal/templater/templates/agents/implementer.md.tmpl index 276b000..43f8d4f 100644 --- a/internal/templater/templates/agents/implementer.md.tmpl +++ b/internal/templater/templates/agents/implementer.md.tmpl @@ -26,6 +26,9 @@ the discipline *around* one task: scope, evidence, task-marking, and reporting. - Which single task? (one leaf ID from `tasks.md`, with its `_Requirements:_` / `_Boundary:_`). - What observable behavior does it add? State it in one sentence before editing. - Which `design.md` boundary owns it, and what is the project's test command? +- Will it call a library/framework API? If so, confirm the signature against current + docs with the Context7 MCP tools (if connected) before writing the test — don't + code against a remembered API that may have changed. ## Workflow diff --git a/internal/templater/templates/skills/dev-architecture/SKILL.md.tmpl b/internal/templater/templates/skills/dev-architecture/SKILL.md.tmpl index 404ba9e..afd3224 100644 --- a/internal/templater/templates/skills/dev-architecture/SKILL.md.tmpl +++ b/internal/templater/templates/skills/dev-architecture/SKILL.md.tmpl @@ -29,6 +29,9 @@ code — `tdd-cycle` writes code later. - What exists already? Read `.claude/steering/tech.md` and `structure.md` and the relevant codebase. Reuse beats green-field. - What are the load-bearing constraints (scale, latency, compliance, team skills)? +- For any library, framework, or SDK you are choosing between or building on, confirm + its **current** API and version with the Context7 MCP tools (if connected) before + committing the decision — training data drifts from released docs. ## Execution Workflow diff --git a/internal/templater/templates/skills/discovery-research/SKILL.md.tmpl b/internal/templater/templates/skills/discovery-research/SKILL.md.tmpl index 8f62262..164d44d 100644 --- a/internal/templater/templates/skills/discovery-research/SKILL.md.tmpl +++ b/internal/templater/templates/skills/discovery-research/SKILL.md.tmpl @@ -34,7 +34,10 @@ unblocks. Drop "nice to know" questions. ### 2) Gather evidence For each question, collect sources. Prefer primary sources. For technical -feasibility, prefer a spike or a doc lookup (use Context7 / web) over opinion. +feasibility, prefer a spike or a doc lookup over opinion — when the question is +about a library, framework, or SDK, use the Context7 MCP tools +(`resolve-library-id` then the docs query) for **current** API/version facts +rather than relying on training data; fall back to web search otherwise. ### 3) Grade every finding Tag each finding: **A** (verified, primary source) / **B** (credible secondary) diff --git a/internal/templater/templates/skills/frontend-e2e-qa/SKILL.md.tmpl b/internal/templater/templates/skills/frontend-e2e-qa/SKILL.md.tmpl new file mode 100644 index 0000000..bb01a72 --- /dev/null +++ b/internal/templater/templates/skills/frontend-e2e-qa/SKILL.md.tmpl @@ -0,0 +1,88 @@ +--- +name: frontend-e2e-qa +description: Drive Playwright through a frontend's golden and top error end-to-end flows for QA. Use only when the project ships a UI and a Playwright MCP server is connected. +--- + +# Frontend e2e QA (Playwright) + +## Goal +Validate a **frontend** change by driving a real browser through the user-facing +golden path and the top error flows, using the **Playwright MCP server** +(`mcp__playwright__*` tools), and report pass/fail with concrete evidence +(what was clicked, what was asserted, what was seen). This is end-to-end QA of a +running UI — not unit tests, not a substitute for the `verify-change` gate. + +## When this applies (and when it does NOT) +- **Frontend only.** Run this only when the change touches a UI the project ships + and serves (e.g. a web app the project builds and runs). If the project has no + frontend, this skill does not apply — say so and stop. +- **Requires the Playwright MCP server.** Quick-install it with + `csdd mcp install playwright` (stdio `npx @playwright/mcp@latest`). If the server + is not connected, report that e2e was skipped because Playwright is unavailable — + never fabricate a browser result. + +## Default Operation Mode +Plan-first: list the flows you will exercise and the running URL, get them +confirmed if ambiguous, then execute. The first deliverable is the flow list, not +a browser session. + +## Collect Inputs First +- Which frontend, and how is it served? (the dev/preview command and the base URL, + e.g. `npm run dev` then `http://localhost:5173`). The app must be running first. +- What is the **golden path** for this change — the one journey a user must be able + to complete? State it as ordered steps with the expected end state. +- What are the **top 1-3 error flows** worth proving (invalid input, empty/permission + state, a failed request) and the expected recovery? +- What selectors/roles identify the elements? Prefer accessible roles/labels over + brittle CSS/XPath. + +## Execution Workflow + +### 1) Bring the app up +Start (or confirm) the frontend is serving at a known URL. Do not point Playwright +at a stale build — rebuild if the change isn't live. Record the URL. + +### 2) Walk the golden path +Use the Playwright MCP tools to navigate to the base URL, then drive the journey +step by step (navigate, interact, assert). After each meaningful step, assert an +observable signal (visible text, URL, an element's state) — a click with no +assertion proves nothing. Capture a snapshot/screenshot at the end state. + +### 3) Walk the top error flows +For each error flow, reproduce the trigger and assert the **recovery**: the right +message appears, the app stays usable, no console error storms. A frontend that +"doesn't crash" is not the same as one that handles the error. + +### 4) Report +Summarize per flow: PASS/FAIL, the steps taken, the assertions checked, and the +evidence (final snapshot/screenshot, key console output). For any FAIL, give the +smallest reproduction. Feed confirmed UI bugs back as new tasks — do not fix them +inside this QA pass. + +## Gotchas +- **App must be running and current.** Playwright drives a live browser; if the + server is down or serving an old bundle, results are meaningless. Verify the URL + responds before driving it. +- **Assert, don't just act.** Each step needs an observable assertion; navigation + without a check is not a test. +- **Prefer role/label selectors** over CSS/XPath — they survive markup churn and + match how users find things. +- **Headless vs headed** changes timing and rendering; note which you used. Flaky + results are usually missing waits, not real bugs — wait on a condition, not a sleep. +- **This is not the gate.** `frontend-e2e-qa` complements the build/lint/typecheck/test + gate (`verify-change`); it never replaces unit tests. +- **No Playwright server, no run.** If `mcp__playwright__*` tools are absent, report + the skip and why — do not fake a browser result. + +## Verification Before Reporting +- Run: the golden path plus each declared error flow through the Playwright MCP tools. +- Check: every flow ends in its expected state with an explicit assertion, and each + result is backed by a snapshot/screenshot or captured output. +- If blocked (app won't start, server missing): report the blocked step explicitly; + do not silently skip or invent a result. + +## Completion Criteria +- [ ] The frontend was served at a recorded URL and confirmed live. +- [ ] The golden path was driven end-to-end with assertions and a final snapshot. +- [ ] Each declared error flow was exercised and its recovery asserted. +- [ ] A per-flow PASS/FAIL report with evidence was produced; failures became new tasks. diff --git a/internal/templater/templates/skills/pr-review/SKILL.md.tmpl b/internal/templater/templates/skills/pr-review/SKILL.md.tmpl index 5455a4d..0afe1b4 100644 --- a/internal/templater/templates/skills/pr-review/SKILL.md.tmpl +++ b/internal/templater/templates/skills/pr-review/SKILL.md.tmpl @@ -45,6 +45,8 @@ commit before review is clean; do not push before the commit exists. ### 5) Open or update the PR - Use the project's PR template (`.github/pull_request_template.md`). - Fill: summary, spec links, tasks completed, the real verification output, risks. +- When a GitHub MCP server is connected, prefer its tools for reading PR/issue/Actions + state (status, review threads, CI results) over shelling to `gh` — same data, typed. ## Gotchas diff --git a/internal/templater/templates/skills/tdd-cycle/SKILL.md.tmpl b/internal/templater/templates/skills/tdd-cycle/SKILL.md.tmpl index ba3fb16..ce6a3c0 100644 --- a/internal/templater/templates/skills/tdd-cycle/SKILL.md.tmpl +++ b/internal/templater/templates/skills/tdd-cycle/SKILL.md.tmpl @@ -18,6 +18,9 @@ test, not production code. - Which task? (a single leaf task ID from `specs//tasks.md`) - What is the expected, observable behavior? (state it in one sentence before editing) - Where do existing tests for this area live, and what is the test command? +- If the behavior calls a library/framework API, confirm that API against its + **current** docs with the Context7 MCP tools (if connected) before writing the + test — so RED fails for the right reason and GREEN targets the real signature. ## Execution Workflow diff --git a/internal/templater/templates/skills/verify-change/SKILL.md.tmpl b/internal/templater/templates/skills/verify-change/SKILL.md.tmpl index 238b59f..b0bbc5a 100644 --- a/internal/templater/templates/skills/verify-change/SKILL.md.tmpl +++ b/internal/templater/templates/skills/verify-change/SKILL.md.tmpl @@ -35,6 +35,13 @@ Run, in order, whichever of these the project defines: ### 3) Capture evidence Record each command and its pass/fail result. Keep output trimmed but real. +### 4) Frontend e2e (only if the project has a UI) +**Skippable.** If — and only if — the change touches a frontend the project ships +(e.g. a web app directory), run the `frontend-e2e-qa` skill to drive the golden +and top error flows through the Playwright MCP server. If the project has no +frontend, or the Playwright MCP server is not connected, state that this step was +skipped and why — do not fabricate a result. + ## Gotchas - Use the project's actual commands; a wrong guessed command that "passes" is worse than none. diff --git a/internal/tui/menu.go b/internal/tui/menu.go index aba8a9e..3ca678d 100644 --- a/internal/tui/menu.go +++ b/internal/tui/menu.go @@ -26,6 +26,7 @@ func newMenu() menuModel { {"Create skill", "Scaffold a SKILL.md bundle in .claude/skills.", cmdOpen(wizSkill)}, {"Create agent", "Define a custom sub-agent with least-privilege tools.", cmdOpen(wizAgent)}, {"Add MCP server", "Register a workspace MCP server in .mcp.json.", cmdOpen(wizMCP)}, + {"Install MCP preset", "Quick-install a known MCP server (context7, playwright, github).", cmdOpen(wizPreset)}, {"Export workspace", "Convert steering/specs/MCP to Kiro or Codex format.", cmdOpen(wizExport)}, {"Browse artifacts", "Navigate steering/specs/skills/agents/mcp with preview.", cmdBrowser()}, {"Quit", "Exit the TUI (CLI remains available for headless use).", tea.Quit}, diff --git a/internal/tui/tui.go b/internal/tui/tui.go index dba354f..b177480 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -35,6 +35,7 @@ const ( screenSkill screenAgent screenMCP + screenPreset screenExport screenBrowser screenResult @@ -85,6 +86,8 @@ func (a *App) switchToWizard(kind wizardKind) { a.screen = screenAgent case wizMCP: a.screen = screenMCP + case wizPreset: + a.screen = screenPreset case wizExport: a.screen = screenExport } @@ -128,7 +131,7 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch a.screen { case screenMenu: a.menu, cmd = a.menu.Update(msg) - case screenSteering, screenSpec, screenSkill, screenAgent, screenMCP, screenExport: + case screenSteering, screenSpec, screenSkill, screenAgent, screenMCP, screenPreset, screenExport: a.wiz, cmd = a.wiz.Update(msg) case screenBrowser: a.browse, cmd = a.browse.Update(msg) @@ -152,7 +155,7 @@ func (a *App) View() string { switch a.screen { case screenMenu: body = a.menu.View() - case screenSteering, screenSpec, screenSkill, screenAgent, screenMCP, screenExport: + case screenSteering, screenSpec, screenSkill, screenAgent, screenMCP, screenPreset, screenExport: body = a.wiz.View() case screenBrowser: body = a.browse.View(a.width, a.height) diff --git a/internal/tui/wizard.go b/internal/tui/wizard.go index beeaa7d..d0113e4 100644 --- a/internal/tui/wizard.go +++ b/internal/tui/wizard.go @@ -22,6 +22,7 @@ const ( wizSkill wizAgent wizMCP + wizPreset wizExport ) @@ -102,6 +103,9 @@ func newWizard(kind wizardKind, templates embed.FS, root string) wizardModel { case wizMCP: w.title = "Add MCP server" w.fields = mcpFields() + case wizPreset: + w.title = "Install MCP preset" + w.fields = presetFields() case wizExport: w.title = "Export workspace" w.fields = exportFields() @@ -415,6 +419,15 @@ func (w wizardModel) submit() tea.Cmd { return resultMsg{text: err.Error(), isErr: true} } return resultMsg{text: "mcp server '" + opts.Name + "' added (" + transport + ")"} + case wizPreset: + name := getStr(w.state, "preset") + if err := cli.MCPInstallPreset(cli.MCPInstallPresetOptions{ + Root: w.root, + Names: []string{name}, + }); err != nil { + return resultMsg{text: err.Error(), isErr: true} + } + return resultMsg{text: "installed mcp preset '" + name + "'"} case wizExport: target := getStr(w.state, "target") opts := cli.ExportOptions{ @@ -625,6 +638,23 @@ func mcpFields() []*field { } } +// presetFields offers a single select over the known MCP presets. The choices +// are sourced from cli.MCPPresets() so the picker can never drift from the +// registry the CLI installs from. +func presetFields() []*field { + presets := cli.MCPPresets() + names := make([]string, 0, len(presets)) + for _, p := range presets { + names = append(names, p.Name) + } + return []*field{ + { + name: "preset", label: "Preset", hint: "known MCP server to quick-install", + kind: fSelect, required: true, choices: names, + }, + } +} + func exportFields() []*field { return []*field{ { diff --git a/internal/tui/wizard_test.go b/internal/tui/wizard_test.go index 9df0e0f..414587b 100644 --- a/internal/tui/wizard_test.go +++ b/internal/tui/wizard_test.go @@ -68,6 +68,7 @@ func TestNewWizardFieldsPerKind(t *testing.T) { {wizSkill, "Create skill", 3}, {wizAgent, "Create agent", 5}, {wizMCP, "Add MCP server", 7}, + {wizPreset, "Install MCP preset", 1}, } for _, c := range cases { w := newWizard(c.kind, templater.FS, t.TempDir()) @@ -300,6 +301,24 @@ func TestWizardSubmitMCPRemote(t *testing.T) { } } +func TestWizardSubmitMCPPreset(t *testing.T) { + dir := freshTUIWorkspace(t) + w := wizardModel{kind: wizPreset, templates: templater.FS, root: dir, state: map[string]any{ + "preset": "context7", + }} + msg := w.submit()() + rm, ok := msg.(resultMsg) + if !ok || rm.isErr { + t.Fatalf("mcp preset submit failed: %#v", msg) + } + data, _ := os.ReadFile(filepath.Join(dir, ".mcp.json")) + for _, want := range []string{"context7", "\"url\"", "\"type\": \"http\""} { + if !strings.Contains(string(data), want) { + t.Errorf("mcp.json missing %q:\n%s", want, data) + } + } +} + func TestWizardSubmitSpecInit(t *testing.T) { dir := freshTUIWorkspace(t) // spec init goes through cli.Run which resolves the workspace from cwd, so