diff --git a/README.md b/README.md index 15d016ef95..bc4c3ab254 100644 --- a/README.md +++ b/README.md @@ -290,6 +290,30 @@ work **offline** against local or pinned sources. | Ship reusable domain-specific templates | Either — presets for template overrides, extensions for templates bundled with new commands | | Provision a complete role-based setup in one command | Bundle | +## 🎨 Canvas: Spec Kit Board + +If you use **GitHub Copilot CLI** (the `github-app`), Spec Kit ships an opt-in +canvas extension — `speckit-board` — that turns the side panel into a live +Spec-Driven Development dashboard: + +- A **portfolio view** of every feature in `specs/` with its pipeline state + (`Specify → Clarify → Plan → Tasks → Analyze → Implement`). +- A **feature drill-in** with an artifact grid and stage-specific buttons that + send `/speckit.*` commands straight to the chat. +- A **constitution strip** at the top so principles are always one glance away. +- Native theming via GitHub canvas tokens (light, dark, and high-contrast). + +```bash +specify canvas install # copy into ~/.copilot/extensions +specify canvas install --dev # or symlink for active development +specify canvas list +specify canvas uninstall +``` + +After installing, restart Copilot CLI and ask the chat to **open the +speckit-board canvas**. Source lives at +[`extensions/speckit-board/`](extensions/speckit-board/). + ## 📚 Core Philosophy Spec-Driven Development is a structured process that emphasizes: diff --git a/extensions/speckit-board/README.md b/extensions/speckit-board/README.md new file mode 100644 index 0000000000..d2db0526b3 --- /dev/null +++ b/extensions/speckit-board/README.md @@ -0,0 +1,112 @@ +# speckit-board — Spec Kit canvas extension + +A Copilot CLI canvas extension that turns any Spec Kit project into a live +Spec-Driven Development (SDD) dashboard in the side panel. + +## What you get + +Two canvases registered by one extension: + +- **`speckit-board`** — portfolio view of every feature in `specs/`, each + rendered as a row with its pipeline status + (`Specify → Clarify → Plan → Tasks → Analyze → Implement`) and a one-click + next-step action. Constitution sits at the top in a collapsible strip. +- **`speckit-feature`** — focused drill-in for a single feature: pipeline + state, an artifact grid (`spec.md`, `plan.md`, `tasks.md`, checklists, + research, data model, analyze report), and stage-specific action buttons + that send `/speckit.*` slash commands to the chat. + +Both canvases are **always openable** — empty states gently guide the user +through `specify init` or kicking off their first spec. + +## Install + +From a checkout of the `github/spec-kit` repo: + +```bash +# Copy into ~/.copilot/extensions/speckit-board (production install) +specify canvas install + +# Or symlink for active development on the extension itself +specify canvas install --dev + +# List what's installed +specify canvas list + +# Remove +specify canvas uninstall +``` + +After installing, restart Copilot CLI (or ask the agent to reload extensions) +and tell the chat: **"open the speckit-board canvas"**. + +## Native theming + +The iframes use the canvas-provided CSS custom properties +(`--background-color-default`, `--text-color-default`, `--text-color-muted`, +`--border-color-default`, `--color-focus-outline`, `--font-sans`, +`--font-mono`, `--text-body-medium`, `--leading-body-medium`, +`--font-weight-semibold`, and the GitHub Primer ramps `--b-11-10`, `--g-11-10`, +`--n-1-10`, etc.) so the UI follows the host theme — light, dark, and +high-contrast — with no hardcoded colors. + +## How actions work + +The canvases drive the session through `session.send()`: + +1. User clicks a stage button in the iframe (e.g. **/speckit.plan**). +2. The iframe POSTs `/api/send` to its loopback HTTP server. +3. The extension calls `SESSION.send({ prompt: "/speckit.plan ..." })`. +4. The agent runs the command; the canvas re-scans on focus + after a short + delay so newly created artifacts appear automatically. + +Two arg-taking commands (`/speckit.specify`, `/speckit.plan`) expose an inline +text input next to the button so users can type the description without +leaving the canvas. + +## Layout + +``` +extensions/speckit-board/ +├── extension.mjs # joinSession + per-canvas wiring + loopback servers +├── renderer.mjs # HTML/CSS for both canvases (native theming) +└── scanner.mjs # pure filesystem scan → state JSON +``` + +No `package.json`, no `node_modules` — `@github/copilot-sdk/extension` is +auto-resolved by the Copilot CLI host. Logs go through `session.log()` only +(stdout is reserved for JSON-RPC). + +## Agent-callable actions + +Both canvases expose actions the agent can invoke directly: + +| Canvas | Action | Purpose | +| ------------------- | --------------- | ------------------------------------------------------ | +| `speckit-board` | `refresh` | Re-scan the project. | +| `speckit-board` | `open_feature` | Validate a slug and hand off to `speckit-feature`. | +| `speckit-feature` | `refresh` | Re-scan the feature. | +| `speckit-feature` | `run_command` | Send any `/speckit.*` slash command (validates input). | + +## Scope (v1) + +- **In**: read-only dashboard, slash-command launching, "Open in editor" + handoff to the built-in `editor` canvas, focus-based + manual refresh, + inline text inputs for arg-taking commands. +- **Out**: editing markdown, toggling task checkboxes, multi-workspace, + filesystem watching (refresh is on focus / on-demand only). + +## Mock UX scenarios + +Both canvases include a **Mock** picker in the top-right toolbar that +swaps the live project scan for a synthetic project. Useful for design +review and previewing every pipeline state without seeding a real repo. + +- `speckit-board` scenarios: `not-initialized`, `initialized-empty`, + `early`, `mixed`, `mature`. +- `speckit-feature` scenarios: `not-found`, `spec-only`, `with-clarify`, + `planned`, `tasks-started`, `tasks-done`, `analyzed`, `implemented`. + +Selecting a scenario adds `?scenario=` to the iframe URL; pick +**Live (real project)** to return to the actual scan. Mock scenarios +never read or write files and never send prompts to the session. diff --git a/extensions/speckit-board/extension.mjs b/extensions/speckit-board/extension.mjs new file mode 100644 index 0000000000..9bcd8b0475 --- /dev/null +++ b/extensions/speckit-board/extension.mjs @@ -0,0 +1,320 @@ +// speckit-board — Spec-Driven Development dashboard for the Copilot CLI. +// +// Registers two canvases on the same session: +// - speckit-board : portfolio view of all features in this Spec Kit project +// - speckit-feature : focused drill-in for a single feature +// +// Each open canvas instance gets its own loopback HTTP server. The iframe +// in the canvas talks back to its server with POST /api/* to invoke session +// actions (sending slash commands, opening other canvases, opening files in +// the built-in editor canvas). All canvas → session communication is driven +// by `session.send()` from the SDK. + +import { createServer } from "node:http"; +import path from "node:path"; +import { joinSession, createCanvas, CanvasError } from "@github/copilot-sdk/extension"; +import { scanProject } from "./scanner.mjs"; +import { renderBoard, renderFeatureView } from "./renderer.mjs"; +import { + getBoardScenario, + getFeatureScenario, + BOARD_SCENARIO_NAMES, + FEATURE_SCENARIO_NAMES, +} from "./mocks.mjs"; + +// instanceId → { server, url, kind, slug?, cwd } +const servers = new Map(); + +// Captured after joinSession returns, used by HTTP handlers. +let SESSION = null; + +function logSafe(msg) { + if (SESSION && typeof SESSION.log === "function") { + try { + SESSION.log(msg); + } catch { + // noop + } + } +} + +async function readBody(req) { + return new Promise((resolve, reject) => { + let data = ""; + req.on("data", (c) => { + data += c; + if (data.length > 64 * 1024) reject(new Error("Body too large")); + }); + req.on("end", () => resolve(data)); + req.on("error", reject); + }); +} + +function jsonResponse(res, status, body) { + res.writeHead(status, { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "no-store", + }); + res.end(JSON.stringify(body)); +} + +function htmlResponse(res, status, body) { + res.writeHead(status, { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", + }); + res.end(body); +} + +async function handleBoardRequest(req, res, entry) { + const url = new URL(req.url, "http://127.0.0.1"); + const scenario = url.searchParams.get("scenario") || ""; + + if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/index.html")) { + const state = scenario ? getBoardScenario(scenario) : await scanProject(entry.cwd); + if (!state) return htmlResponse(res, 404, `

Unknown scenario: ${scenario}

`); + return htmlResponse(res, 200, renderBoard(state, { scenarios: BOARD_SCENARIO_NAMES, activeScenario: scenario })); + } + if (req.method === "GET" && url.pathname === "/api/state") { + const state = scenario ? getBoardScenario(scenario) : await scanProject(entry.cwd); + if (!state) return jsonResponse(res, 404, { error: "unknown scenario" }); + return jsonResponse(res, 200, state); + } + return handleCommonRequest(req, res, entry, url); +} + +async function handleFeatureRequest(req, res, entry) { + const url = new URL(req.url, "http://127.0.0.1"); + const scenario = url.searchParams.get("scenario") || ""; + + if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/index.html")) { + let state, feature; + if (scenario) { + const mock = getFeatureScenario(scenario); + if (!mock) return htmlResponse(res, 404, `

Unknown scenario: ${scenario}

`); + state = mock.wrapper; + feature = mock.feature; + } else { + state = await scanProject(entry.cwd); + feature = state.features.find((f) => f.slug === entry.slug) || null; + } + return htmlResponse(res, 200, renderFeatureView(state, feature, { + scenarios: FEATURE_SCENARIO_NAMES, + activeScenario: scenario, + requestedSlug: entry.slug, + })); + } + return handleCommonRequest(req, res, entry, url); +} + +async function handleCommonRequest(req, res, entry, url) { + if (req.method === "POST" && url.pathname === "/api/send") { + try { + const body = JSON.parse(await readBody(req)); + const prompt = String(body.prompt || "").trim(); + if (!prompt) return jsonResponse(res, 400, { error: "prompt required" }); + if (!SESSION) return jsonResponse(res, 500, { error: "session not ready" }); + await SESSION.send({ prompt }); + logSafe(`[speckit-board] sent: ${prompt.slice(0, 100)}`); + return jsonResponse(res, 200, { ok: true }); + } catch (e) { + logSafe(`[speckit-board] send failed: ${e.message}`); + return jsonResponse(res, 500, { error: e.message }); + } + } + + if (req.method === "POST" && url.pathname === "/api/open-feature") { + try { + const body = JSON.parse(await readBody(req)); + const slug = String(body.slug || "").trim(); + if (!slug) return jsonResponse(res, 400, { error: "slug required" }); + const prompt = `Open the "speckit-feature" canvas for the "${slug}" feature (use open_canvas with canvasId "speckit-feature" and input { "slug": "${slug}" }).`; + await SESSION.send({ prompt }); + return jsonResponse(res, 200, { ok: true }); + } catch (e) { + return jsonResponse(res, 500, { error: e.message }); + } + } + + if (req.method === "POST" && url.pathname === "/api/open-editor") { + try { + const body = JSON.parse(await readBody(req)); + const file = String(body.file || "").trim(); + if (!file) return jsonResponse(res, 400, { error: "file required" }); + // Ask the agent to open the built-in editor canvas on the file. + const prompt = `Open the built-in "editor" canvas on the file at \`${file}\` (use open_canvas with canvasId "editor").`; + await SESSION.send({ prompt }); + return jsonResponse(res, 200, { ok: true }); + } catch (e) { + return jsonResponse(res, 500, { error: e.message }); + } + } + + if (req.method === "POST" && url.pathname === "/api/refresh") { + // Refresh is handled client-side by location.reload() which re-hits GET /. + return jsonResponse(res, 200, { ok: true }); + } + + return jsonResponse(res, 404, { error: "not found" }); +} + +async function startServer(kind, instanceId, cwd, slug = null) { + const server = createServer(async (req, res) => { + const entry = servers.get(instanceId); + if (!entry) { + return jsonResponse(res, 410, { error: "instance gone" }); + } + try { + if (kind === "board") return await handleBoardRequest(req, res, entry); + if (kind === "feature") return await handleFeatureRequest(req, res, entry); + return jsonResponse(res, 404, { error: "unknown canvas" }); + } catch (e) { + logSafe(`[speckit-board] handler error: ${e.message}`); + return jsonResponse(res, 500, { error: e.message }); + } + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve()); + }); + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + return { server, url: `http://127.0.0.1:${port}/`, kind, slug, cwd }; +} + +function resolveCwd(ctx) { + return ctx?.session?.workingDirectory || process.cwd(); +} + +SESSION = await joinSession({ + canvases: [ + createCanvas({ + id: "speckit-board", + displayName: "Spec Kit Board", + description: + "Portfolio dashboard for Spec-Driven Development: every feature, its pipeline stage, and one-click slash-command actions to drive the next step.", + actions: [ + { + name: "refresh", + description: "Re-scan the project and refresh the board.", + handler: async (ctx) => { + const state = await scanProject(resolveCwd(ctx)); + return { ok: true, featureCount: state.features.length }; + }, + }, + { + name: "open_feature", + description: + "Identify a feature by slug. The agent should call open_canvas for canvasId 'speckit-feature' with the same slug after this returns.", + inputSchema: { + type: "object", + properties: { slug: { type: "string", description: "Feature directory name under specs/" } }, + required: ["slug"], + }, + handler: async (ctx) => { + const slug = String(ctx.input?.slug || "").trim(); + if (!slug) throw new CanvasError("invalid_input", "slug is required"); + return { ok: true, slug, hint: "Now call open_canvas with canvasId 'speckit-feature' and input { slug }." }; + }, + }, + ], + open: async (ctx) => { + const cwd = resolveCwd(ctx); + let entry = servers.get(ctx.instanceId); + if (!entry || entry.kind !== "board") { + if (entry) await closeEntry(entry, ctx.instanceId); + entry = await startServer("board", ctx.instanceId, cwd); + servers.set(ctx.instanceId, entry); + } + logSafe(`[speckit-board] open board instance=${ctx.instanceId} cwd=${cwd}`); + return { title: "Spec Kit Board", url: entry.url }; + }, + onClose: async (ctx) => { + const entry = servers.get(ctx.instanceId); + if (entry) await closeEntry(entry, ctx.instanceId); + }, + }), + + createCanvas({ + id: "speckit-feature", + displayName: "Spec Kit Feature", + description: + "Focused drill-in for a single Spec Kit feature: pipeline status, artifact files, stage-specific actions. Open with input { slug }.", + inputSchema: { + type: "object", + properties: { + slug: { + type: "string", + description: "Feature directory name under specs/ (e.g. '001-payments-dashboard').", + }, + }, + required: ["slug"], + }, + actions: [ + { + name: "refresh", + description: "Re-scan and refresh the feature view.", + handler: async (ctx) => { + const state = await scanProject(resolveCwd(ctx)); + const entry = servers.get(ctx.instanceId); + const slug = entry?.slug; + const feature = state.features.find((f) => f.slug === slug); + return { ok: true, slug, found: !!feature }; + }, + }, + { + name: "run_command", + description: + "Send a Spec Kit slash command to the chat session for the current feature.", + inputSchema: { + type: "object", + properties: { + command: { type: "string", description: "Slash command, e.g. '/speckit.plan'" }, + args: { type: "string", description: "Optional arguments to append" }, + }, + required: ["command"], + }, + handler: async (ctx) => { + const cmd = String(ctx.input?.command || "").trim(); + if (!cmd.startsWith("/speckit.")) { + throw new CanvasError("invalid_input", "command must be a /speckit.* slash command"); + } + const args = String(ctx.input?.args || "").trim(); + const prompt = args ? `${cmd} ${args}` : cmd; + if (!SESSION) throw new CanvasError("internal", "session not ready"); + await SESSION.send({ prompt }); + return { ok: true, sent: prompt }; + }, + }, + ], + open: async (ctx) => { + const cwd = resolveCwd(ctx); + const slug = String(ctx.input?.slug || "").trim(); + if (!slug) throw new CanvasError("invalid_input", "slug is required in input"); + let entry = servers.get(ctx.instanceId); + if (!entry || entry.kind !== "feature" || entry.slug !== slug) { + if (entry) await closeEntry(entry, ctx.instanceId); + entry = await startServer("feature", ctx.instanceId, cwd, slug); + servers.set(ctx.instanceId, entry); + } + logSafe(`[speckit-board] open feature instance=${ctx.instanceId} slug=${slug}`); + return { title: `Spec Kit · ${slug}`, url: entry.url }; + }, + onClose: async (ctx) => { + const entry = servers.get(ctx.instanceId); + if (entry) await closeEntry(entry, ctx.instanceId); + }, + }), + ], +}); + +async function closeEntry(entry, instanceId) { + servers.delete(instanceId); + try { + await new Promise((resolve) => entry.server.close(() => resolve())); + } catch { + // ignore + } +} + +logSafe("[speckit-board] joined session, 2 canvases registered"); diff --git a/extensions/speckit-board/mocks.mjs b/extensions/speckit-board/mocks.mjs new file mode 100644 index 0000000000..04ddb0f739 --- /dev/null +++ b/extensions/speckit-board/mocks.mjs @@ -0,0 +1,274 @@ +// Mock state scenarios for UX previews. +// Each scenario returns the same shape as scanner.mjs `scanProject()`. + +import { STAGES } from "./scanner.mjs"; + +const MOCK_CONSTITUTION = { + exists: true, + path: "/mock/.specify/memory/constitution.md", + principles: [ + { title: "I. User-First", body: "Every change MUST be driven by an observable user outcome with a measurable signal — anchored to a usage metric, not an internal milestone." }, + { title: "II. Test-Backed Change (NON-NEGOTIABLE)", body: "Every behavioral change MUST ship with automated tests and a passing CI gate across the supported matrix." }, + { title: "III. Offline-First Performance", body: "Core flows MUST work offline; cold-start budget is under 250 ms on a mid-tier laptop, measured in CI." }, + { title: "IV. Minimal Dependencies", body: "Zero new runtime deps by default; additions require a written justification in plan.md and an owner." }, + { title: "V. Cross-Platform Parity", body: "Linux, macOS, and Windows ship the same behavior; CI proves it on every PR." }, + ], +}; + +function emptyFiles(slug) { + return { + spec: { exists: false }, + plan: { exists: false }, + tasks: { exists: false }, + checklists: [], + analyze: null, + research: null, + dataModel: null, + quickstart: null, + contracts: null, + }; +} + +function mockFeature(num, slug, name) { + return { + slug, + name, + dir: `specs/${slug}`, + absDir: `/Users/me/projects/launch-pad/specs/${slug}`, + files: emptyFiles(slug), + stages: { specify: false, clarify: false, plan: false, tasks: false, analyze: false, implement: false }, + tasks: { total: 0, done: 0 }, + next: { stage: "specify", command: "/speckit.specify", requiresArgs: true }, + }; +} + +function withSpec(f) { + f.files.spec = { path: `${f.absDir}/spec.md`, exists: true }; + f.stages.specify = true; + f.next = { stage: "plan", command: "/speckit.plan", requiresArgs: true }; + return f; +} + +function withClarify(f) { + f.stages.clarify = true; + return f; +} + +function withPlan(f) { + f.files.plan = { path: `${f.absDir}/plan.md`, exists: true }; + f.files.research = { path: `${f.absDir}/research.md` }; + f.files.dataModel = { path: `${f.absDir}/data-model.md` }; + f.files.contracts = { path: `${f.absDir}/contracts` }; + f.stages.plan = true; + f.next = { stage: "tasks", command: "/speckit.tasks", requiresArgs: false }; + return f; +} + +function withTasks(f, total = 24, done = 0) { + f.files.tasks = { path: `${f.absDir}/tasks.md`, exists: true }; + f.files.checklists = [ + { name: "checklist-ux.md", path: `${f.absDir}/checklist-ux.md` }, + { name: "checklist-security.md", path: `${f.absDir}/checklist-security.md` }, + ]; + f.stages.tasks = true; + f.tasks = { total, done }; + if (done > 0) f.stages.implement = true; + if (done < total) { + f.next = { stage: "implement", command: "/speckit.implement", requiresArgs: false }; + } else { + f.next = { stage: "analyze", command: "/speckit.analyze", requiresArgs: false }; + } + return f; +} + +function withAnalyze(f) { + f.files.analyze = { name: "analyze-report.md", path: `${f.absDir}/analyze-report.md` }; + f.stages.analyze = true; + return f; +} + +// ---------- Board scenarios -------------------------------------------------- + +const BOARD_SCENARIOS = { + "not-initialized": () => ({ + isSpecKit: false, + cwd: "/Users/me/projects/blank-repo", + constitution: { exists: false, path: null, principles: [] }, + features: [], + currentBranch: "main", + currentFeatureSlug: null, + stages: STAGES, + }), + + "initialized-empty": () => ({ + isSpecKit: true, + cwd: "/Users/me/projects/new-spec-kit-project", + constitution: MOCK_CONSTITUTION, + features: [], + currentBranch: "main", + currentFeatureSlug: null, + stages: STAGES, + }), + + "early": () => ({ + isSpecKit: true, + cwd: "/Users/me/projects/launch-pad", + constitution: MOCK_CONSTITUTION, + features: [ + withSpec(mockFeature(1, "001-onboarding-redesign", "Redesign first-run onboarding")), + ], + currentBranch: "001-onboarding-redesign", + currentFeatureSlug: "001-onboarding-redesign", + stages: STAGES, + }), + + "mixed": () => { + const features = [ + withTasks(withPlan(withClarify(withSpec(mockFeature(1, "001-onboarding-redesign", "Redesign first-run onboarding")))), 18, 18), + withTasks(withPlan(withClarify(withSpec(mockFeature(2, "002-billing-portal", "Self-serve billing portal")))), 24, 11), + withPlan(withClarify(withSpec(mockFeature(3, "003-auth-refresh", "Rotate refresh tokens silently")))), + withClarify(withSpec(mockFeature(4, "004-search-rewrite", "Replace ES with hybrid vector search"))), + withSpec(mockFeature(5, "005-audit-export", "Tenant-scoped audit log export")), + ]; + return { + isSpecKit: true, + cwd: "/Users/me/projects/launch-pad", + constitution: MOCK_CONSTITUTION, + features, + currentBranch: "002-billing-portal", + currentFeatureSlug: "002-billing-portal", + stages: STAGES, + }; + }, + + "mature": () => { + const features = [ + withTasks(withPlan(withClarify(withSpec(mockFeature(1, "001-onboarding-redesign", "Redesign first-run onboarding")))), 18, 18), + withAnalyze(withTasks(withPlan(withClarify(withSpec(mockFeature(2, "002-billing-portal", "Self-serve billing portal")))), 24, 24)), + withAnalyze(withTasks(withPlan(withClarify(withSpec(mockFeature(3, "003-auth-refresh", "Rotate refresh tokens silently")))), 16, 12)), + withTasks(withPlan(withClarify(withSpec(mockFeature(4, "004-search-rewrite", "Replace ES with hybrid vector search")))), 30, 6), + withPlan(withClarify(withSpec(mockFeature(5, "005-audit-export", "Tenant-scoped audit log export")))), + withClarify(withSpec(mockFeature(6, "006-mobile-share", "Native iOS/Android share sheet"))), + withSpec(mockFeature(7, "007-cli-telemetry", "Opt-in CLI usage telemetry")), + ]; + return { + isSpecKit: true, + cwd: "/Users/me/projects/launch-pad", + constitution: MOCK_CONSTITUTION, + features, + currentBranch: "003-auth-refresh", + currentFeatureSlug: "003-auth-refresh", + stages: STAGES, + }; + }, +}; + +// ---------- Feature scenarios ------------------------------------------------ + +function buildFeatureScenario(builder, slug = "002-billing-portal", name = "Self-serve billing portal") { + const f = builder(mockFeature(2, slug, name)); + return f; +} + +const FEATURE_SCENARIOS = { + "not-found": () => ({ wrapper: emptyProject(), feature: null }), + + "spec-only": () => { + const f = buildFeatureScenario((b) => withSpec(b)); + return { wrapper: projectWith(f), feature: f }; + }, + + "with-clarify": () => { + const f = buildFeatureScenario((b) => withClarify(withSpec(b))); + return { wrapper: projectWith(f), feature: f }; + }, + + "planned": () => { + const f = buildFeatureScenario((b) => withPlan(withClarify(withSpec(b)))); + return { wrapper: projectWith(f), feature: f }; + }, + + "tasks-started": () => { + const f = buildFeatureScenario((b) => withTasks(withPlan(withClarify(withSpec(b))), 24, 8)); + return { wrapper: projectWith(f), feature: f }; + }, + + "tasks-done": () => { + const f = buildFeatureScenario((b) => withTasks(withPlan(withClarify(withSpec(b))), 24, 24)); + return { wrapper: projectWith(f), feature: f }; + }, + + "analyzed": () => { + const f = buildFeatureScenario((b) => withAnalyze(withTasks(withPlan(withClarify(withSpec(b))), 24, 24))); + return { wrapper: projectWith(f), feature: f }; + }, + + "implemented": () => { + const f = buildFeatureScenario((b) => withAnalyze(withTasks(withPlan(withClarify(withSpec(b))), 24, 24))); + f.next = { stage: null, command: null, requiresArgs: false }; + return { wrapper: projectWith(f), feature: f }; + }, +}; + +function emptyProject() { + return { + isSpecKit: true, + cwd: "/Users/me/projects/launch-pad", + constitution: MOCK_CONSTITUTION, + features: [], + currentBranch: "main", + currentFeatureSlug: null, + stages: STAGES, + }; +} + +function projectWith(feature) { + return { + isSpecKit: true, + cwd: "/Users/me/projects/launch-pad", + constitution: MOCK_CONSTITUTION, + features: [feature], + currentBranch: feature.slug, + currentFeatureSlug: feature.slug, + stages: STAGES, + }; +} + +// ---------- Public API ------------------------------------------------------- + +export const BOARD_SCENARIO_NAMES = [ + { value: "", label: "Live (real project)" }, + { value: "not-initialized", label: "Mock · Not initialized" }, + { value: "initialized-empty", label: "Mock · Empty project" }, + { value: "early", label: "Mock · 1 feature (spec only)" }, + { value: "mixed", label: "Mock · 5 features (mixed stages)" }, + { value: "mature", label: "Mock · 7 features (mature project)" }, +]; + +export const FEATURE_SCENARIO_NAMES = [ + { value: "", label: "Live (real feature)" }, + { value: "not-found", label: "Mock · Feature not found" }, + { value: "spec-only", label: "Mock · Spec only" }, + { value: "with-clarify", label: "Mock · Spec + clarify" }, + { value: "planned", label: "Mock · Plan complete" }, + { value: "tasks-started", label: "Mock · Tasks 33%" }, + { value: "tasks-done", label: "Mock · Tasks 100%" }, + { value: "analyzed", label: "Mock · Analyzed" }, + { value: "implemented", label: "Mock · Implemented" }, +]; + +export function getBoardScenario(name) { + const fn = BOARD_SCENARIOS[name]; + if (!fn) return null; + const state = fn(); + state._mock = name; + return state; +} + +export function getFeatureScenario(name) { + const fn = FEATURE_SCENARIOS[name]; + if (!fn) return null; + const { wrapper, feature } = fn(); + wrapper._mock = name; + return { wrapper, feature }; +} diff --git a/extensions/speckit-board/renderer.mjs b/extensions/speckit-board/renderer.mjs new file mode 100644 index 0000000000..c893bb1d65 --- /dev/null +++ b/extensions/speckit-board/renderer.mjs @@ -0,0 +1,873 @@ +// Renderer: HTML for both speckit-board and speckit-feature canvases. +// Uses GitHub canvas theme tokens for native theming. + +const STYLES = ` +:root { + color-scheme: light dark; +} +* { box-sizing: border-box; } +html, body { + margin: 0; + padding: 0; + background: var(--background-color-default, #fff); + color: var(--text-color-default, #1f2328); + font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif); + font-size: var(--text-body-medium, 14px); + line-height: var(--leading-body-medium, 1.5); + -webkit-font-smoothing: antialiased; +} +.app { + padding: 16px; + max-width: 100%; +} +header.topbar { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; + gap: 12px; + flex-wrap: wrap; +} +.title-block { display: flex; align-items: center; gap: 10px; min-width: 0; } +.brand-mark { + width: 28px; height: 28px; + border-radius: 6px; + background: linear-gradient(135deg, var(--b-11-10, #0969da), var(--p-11-10, #8250df)); + display: inline-flex; align-items: center; justify-content: center; + color: #fff; font-weight: 700; font-size: 13px; + box-shadow: 0 1px 2px rgba(0,0,0,0.08); + flex-shrink: 0; +} +h1.title { + font-size: 16px; + font-weight: var(--font-weight-semibold, 600); + margin: 0; + color: var(--text-color-default, #1f2328); +} +.subtitle { + color: var(--text-color-muted, #59636e); + font-size: 12px; + margin: 2px 0 0; +} +.toolbar { + display: flex; + gap: 6px; + align-items: center; + flex-shrink: 0; +} +button, .btn { + appearance: none; + background: var(--background-color-default, #fff); + color: var(--text-color-default, #1f2328); + border: 1px solid var(--border-color-default, rgba(31,35,40,0.15)); + border-radius: 6px; + padding: 4px 10px; + font-size: 12px; + font-family: inherit; + cursor: pointer; + transition: background 0.12s, border-color 0.12s, transform 0.05s; + font-weight: 500; + line-height: 18px; + display: inline-flex; align-items: center; gap: 5px; +} +button:hover:not(:disabled), .btn:hover:not(:disabled) { + background: var(--n-1-10, rgba(208,215,222,0.32)); +} +button:active:not(:disabled) { transform: translateY(0.5px); } +button:disabled { opacity: 0.5; cursor: not-allowed; } +button:focus-visible, .btn:focus-visible { + outline: 2px solid var(--color-focus-outline, #0969da); + outline-offset: 1px; +} +button.primary { + background: var(--b-11-10, #0969da); + color: #fff; + border-color: transparent; +} +button.primary:hover:not(:disabled) { + background: var(--b-12-10, #0860c7); +} +button.ghost { border-color: transparent; } +button.ghost:hover:not(:disabled) { + background: var(--n-1-10, rgba(208,215,222,0.32)); +} + +.constitution-strip { + background: var(--n-1-10, rgba(208,215,222,0.16)); + border: 1px solid var(--border-color-default, rgba(31,35,40,0.12)); + border-radius: 8px; + padding: 10px 12px; + margin-bottom: 16px; + font-size: 12px; +} +.constitution-strip details summary { + cursor: pointer; + font-weight: var(--font-weight-semibold, 600); + list-style: none; + display: flex; align-items: center; gap: 6px; + color: var(--text-color-default, #1f2328); +} +.constitution-strip details summary::before { + content: "▸"; + transition: transform 0.15s; + font-size: 10px; + color: var(--text-color-muted, #59636e); +} +.constitution-strip details[open] summary::before { transform: rotate(90deg); } +.constitution-strip ul { + margin: 8px 0 0; + padding-left: 18px; +} +.constitution-strip li { color: var(--text-color-muted, #59636e); margin: 2px 0; } +.constitution-strip li strong { color: var(--text-color-default, #1f2328); font-weight: 600; } +.constitution-missing { + color: var(--text-color-muted, #59636e); + font-style: italic; +} + +.feature-list { + display: flex; flex-direction: column; gap: 10px; +} +.feature-card { + border: 1px solid var(--border-color-default, rgba(31,35,40,0.15)); + border-radius: 8px; + padding: 12px 14px; + background: var(--background-color-default, #fff); + transition: border-color 0.12s, box-shadow 0.12s; +} +.feature-card:hover { + border-color: var(--border-color-muted, rgba(31,35,40,0.25)); + box-shadow: 0 1px 3px rgba(0,0,0,0.04); +} +.feature-card.current { + border-color: var(--b-11-10, #0969da); + box-shadow: 0 0 0 1px var(--b-11-10, #0969da); +} +.feature-header { + display: flex; align-items: center; justify-content: space-between; + gap: 10px; margin-bottom: 10px; +} +.feature-title { display: flex; align-items: center; gap: 8px; min-width: 0; } +.feature-name { + font-weight: var(--font-weight-semibold, 600); + font-size: 13px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.feature-slug { + color: var(--text-color-muted, #59636e); + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 11px; +} +.current-badge { + background: var(--b-11-10, #0969da); + color: #fff; + border-radius: 10px; + padding: 1px 7px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.pipeline { + display: flex; align-items: center; gap: 4px; + margin: 8px 0; + flex-wrap: wrap; +} +.stage { + display: inline-flex; align-items: center; + padding: 3px 9px; + border-radius: 999px; + font-size: 11px; + font-weight: 500; + border: 1px solid; + transition: all 0.12s; + white-space: nowrap; +} +.stage.done { + background: var(--g-1-10, rgba(26,127,55,0.10)); + border-color: var(--g-4-10, rgba(26,127,55,0.30)); + color: var(--g-11-10, #1a7f37); +} +.stage.pending { + background: transparent; + border-color: var(--border-color-default, rgba(31,35,40,0.15)); + color: var(--text-color-muted, #59636e); +} +.stage.next { + background: var(--b-1-10, rgba(9,105,218,0.10)); + border-color: var(--b-11-10, #0969da); + color: var(--b-11-10, #0969da); + font-weight: 600; +} +.stage-arrow { + color: var(--text-color-muted, #59636e); + font-size: 10px; + opacity: 0.4; +} + +.next-action { + display: flex; gap: 6px; align-items: center; + margin-top: 10px; + padding-top: 10px; + border-top: 1px dashed var(--border-color-default, rgba(31,35,40,0.10)); +} +.next-action-label { + font-size: 11px; + color: var(--text-color-muted, #59636e); + margin-right: 4px; + flex-shrink: 0; +} +.feature-actions { + display: flex; gap: 4px; flex-wrap: wrap; + margin-left: auto; +} + +input[type="text"], textarea { + appearance: none; + background: var(--background-color-default, #fff); + color: var(--text-color-default, #1f2328); + border: 1px solid var(--border-color-default, rgba(31,35,40,0.15)); + border-radius: 6px; + padding: 4px 8px; + font-size: 12px; + font-family: inherit; + min-width: 0; + flex: 1; +} +input[type="text"]:focus, textarea:focus { + outline: none; + border-color: var(--b-11-10, #0969da); + box-shadow: 0 0 0 2px var(--b-1-10, rgba(9,105,218,0.20)); +} +.inline-form { + display: flex; gap: 6px; align-items: center; + flex: 1; +} + +.empty-state { + text-align: center; + padding: 32px 16px; + color: var(--text-color-muted, #59636e); +} +.empty-state h2 { + font-size: 15px; + color: var(--text-color-default, #1f2328); + margin: 0 0 6px; +} +.empty-state p { margin: 4px 0 16px; font-size: 12px; } +.empty-state .empty-actions { + display: flex; gap: 6px; justify-content: center; flex-wrap: wrap; +} +.empty-state form.inline-form { max-width: 420px; margin: 0 auto; } + +.toast { + position: fixed; + bottom: 12px; + right: 12px; + background: var(--text-color-default, #1f2328); + color: var(--background-color-default, #fff); + padding: 6px 12px; + border-radius: 6px; + font-size: 12px; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + opacity: 0; + transform: translateY(4px); + transition: opacity 0.15s, transform 0.15s; + pointer-events: none; + max-width: 320px; + z-index: 100; +} +.toast.show { opacity: 1; transform: translateY(0); } +.toast.err { background: var(--r-11-10, #cf222e); } + +/* Feature canvas */ +.feature-meta { + background: var(--n-1-10, rgba(208,215,222,0.16)); + border: 1px solid var(--border-color-default, rgba(31,35,40,0.12)); + border-radius: 8px; + padding: 12px; + margin-bottom: 16px; +} +.section-title { + text-transform: uppercase; + letter-spacing: 0.5px; + font-size: 10px; + font-weight: var(--font-weight-semibold, 600); + color: var(--text-color-muted, #59636e); + margin: 18px 0 8px; +} +.file-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 8px; +} +.file-tile { + border: 1px solid var(--border-color-default, rgba(31,35,40,0.15)); + border-radius: 6px; + padding: 10px; + display: flex; flex-direction: column; gap: 6px; + background: var(--background-color-default, #fff); + transition: border-color 0.12s, background 0.12s; +} +.file-tile.missing { + opacity: 0.55; + border-style: dashed; +} +.file-tile-name { + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 11px; + font-weight: 600; + color: var(--text-color-default, #1f2328); + overflow: hidden; text-overflow: ellipsis; +} +.file-tile-actions { display: flex; gap: 4px; flex-wrap: wrap; } +.task-summary { + display: flex; align-items: center; gap: 8px; + margin-top: 4px; + font-size: 11px; + color: var(--text-color-muted, #59636e); +} +.progress-track { + flex: 1; + height: 4px; + background: var(--n-2-10, rgba(208,215,222,0.5)); + border-radius: 2px; + overflow: hidden; +} +.progress-fill { + height: 100%; + background: var(--g-11-10, #1a7f37); + transition: width 0.2s; +} +.back-link { + color: var(--text-color-muted, #59636e); + font-size: 12px; + text-decoration: none; + display: inline-flex; align-items: center; gap: 4px; + margin-bottom: 8px; + cursor: pointer; +} +.back-link:hover { color: var(--text-color-default, #1f2328); } +.scenario-picker { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 8px 4px 10px; + border: 1px dashed var(--border-color-default, #d1d9e0); + border-radius: 6px; + background: var(--n-1-10, transparent); + margin-left: 8px; +} +.scenario-picker .scenario-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-color-muted, #59636e); + font-weight: var(--font-weight-semibold, 600); +} +.scenario-picker select { + font: inherit; + font-size: 12px; + color: var(--text-color-default, #1f2328); + background: var(--background-color-default, #fff); + border: 1px solid var(--border-color-default, #d1d9e0); + border-radius: 4px; + padding: 2px 6px; + cursor: pointer; +} +.scenario-picker select:focus { + outline: 2px solid var(--color-focus-outline, #0969da); + outline-offset: 1px; +} +.mock-banner { + background: linear-gradient(90deg, var(--p-11-10, #8250df), var(--b-11-10, #0969da)); + color: #fff; + padding: 6px 12px; + border-radius: 6px; + font-size: 12px; + font-weight: var(--font-weight-semibold, 600); + margin-bottom: 12px; + display: flex; align-items: center; gap: 8px; +} +.mock-banner code { + background: rgba(255,255,255,0.18); + padding: 1px 6px; + border-radius: 3px; + color: #fff; + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 11px; +} +`; + +const CLIENT_JS = ` +const STATE = window.__STATE__; + +function toast(msg, isErr) { + const t = document.createElement("div"); + t.className = "toast" + (isErr ? " err" : ""); + t.textContent = msg; + document.body.appendChild(t); + requestAnimationFrame(() => t.classList.add("show")); + setTimeout(() => { + t.classList.remove("show"); + setTimeout(() => t.remove(), 200); + }, isErr ? 3200 : 1800); +} + +async function postJSON(url, body) { + try { + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body || {}), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || "Request failed (" + res.status + ")"); + } + return await res.json(); + } catch (e) { + toast(e.message || "Action failed", true); + throw e; + } +} + +async function sendPrompt(prompt, successMsg) { + if (!prompt || !prompt.trim()) { + toast("Prompt is empty", true); + return; + } + await postJSON("/api/send", { prompt }); + toast(successMsg || "Sent to session"); + // Trigger a refresh after a short delay so artifacts show up + setTimeout(refresh, 1500); +} + +async function refresh() { + location.reload(); +} + +document.addEventListener("click", async (ev) => { + const t = ev.target.closest("[data-action]"); + if (!t) return; + ev.preventDefault(); + const action = t.dataset.action; + const slug = t.dataset.slug || null; + const prompt = t.dataset.prompt || null; + const file = t.dataset.file || null; + const inputId = t.dataset.input || null; + const inputEl = inputId ? document.getElementById(inputId) : null; + const args = inputEl ? inputEl.value.trim() : ""; + + if (action === "refresh") return refresh(); + if (action === "open-editor" && file) { + await postJSON("/api/open-editor", { file }); + toast("Opening in editor…"); + return; + } + if (action === "open-feature" && slug) { + await postJSON("/api/open-feature", { slug }); + toast("Opening feature view…"); + return; + } + if (action === "send-prompt" && prompt) { + const requiresArgs = t.dataset.requiresArgs === "true"; + if (requiresArgs && !args) { + toast("Type a description first", true); + inputEl?.focus(); + return; + } + const full = args ? prompt + " " + args : prompt; + await sendPrompt(full, t.dataset.success || "Sent"); + if (inputEl) inputEl.value = ""; + return; + } + if (action === "init-project") { + await sendPrompt("Please run 'specify init --here' and walk me through choosing an AI agent.", "Asked agent"); + return; + } +}); + +document.addEventListener("submit", (ev) => { + const form = ev.target; + if (form.matches(".inline-form")) { + ev.preventDefault(); + const btn = form.querySelector("[data-action]"); + if (btn) btn.click(); + } +}); + +document.addEventListener("change", (ev) => { + if (ev.target.matches(".scenario-picker select")) { + const v = ev.target.value; + const url = new URL(window.location.href); + if (v) url.searchParams.set("scenario", v); + else url.searchParams.delete("scenario"); + window.__lastRefresh = Date.now(); + window.location.href = url.toString(); + } +}); + +window.addEventListener("focus", () => { + if (window.__lastRefresh && Date.now() - window.__lastRefresh < 800) return; + window.__lastRefresh = Date.now(); + refresh(); +}); + +document.addEventListener("keydown", (ev) => { + if ((ev.key === "r" || ev.key === "R") && !ev.target.matches("input,textarea")) { + refresh(); + } +}); +`; + +function renderScenarioPicker(scenarios, active) { + if (!scenarios || scenarios.length === 0) return ""; + const opts = scenarios + .map((s) => ``) + .join(""); + return ``; +} + +function renderMockBanner(active, scenarios) { + if (!active) return ""; + const found = (scenarios || []).find((s) => s.value === active); + const label = found ? found.label : active; + return `
🎭 Previewing mock scenario: ${escapeHtml(label)}
`; +} + +function escapeHtml(s) { + return String(s ?? "").replace(/[&<>"']/g, (c) => ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + }[c])); +} + +function renderConstitution(constitution) { + if (!constitution || !constitution.exists) { + return `
+
+ No constitution yet — establish project principles first. + +
+
`; + } + const principles = constitution.principles || []; + if (!principles.length) { + return `
+
+ Constitution +

Constitution exists but no principles parsed.

+
+
`; + } + const items = principles + .map((p) => `
  • ${escapeHtml(p.title)}${p.body ? " — " + escapeHtml(p.body) : ""}
  • `) + .join(""); + return `
    +
    + Constitution · ${principles.length} principle${principles.length === 1 ? "" : "s"} +
      ${items}
    +
    +
    `; +} + +function renderPipeline(feature) { + const stages = [ + { id: "specify", label: "Spec", done: feature.stages.specify }, + { id: "clarify", label: "Clarify", done: feature.stages.clarify, optional: true }, + { id: "plan", label: "Plan", done: feature.stages.plan }, + { id: "tasks", label: "Tasks", done: feature.stages.tasks }, + { id: "analyze", label: "Analyze", done: feature.stages.analyze, optional: true }, + { id: "implement", label: "Implement", done: feature.stages.implement, optional: true }, + ]; + const chips = stages + .map((s, i) => { + const isNext = feature.next?.stage === s.id; + const cls = s.done ? "done" : isNext ? "next" : "pending"; + const icon = s.done ? "✓ " : isNext ? "→ " : ""; + const arrow = i < stages.length - 1 ? '' : ""; + return `${icon}${s.label}${arrow}`; + }) + .join(""); + return `
    ${chips}
    `; +} + +function renderFeatureCard(feature, isCurrent) { + const next = feature.next; + const requiresArgs = next?.requiresArgs; + const inputId = "input-" + feature.slug; + const taskBar = + feature.tasks.total > 0 + ? `
    + ${feature.tasks.done}/${feature.tasks.total} tasks +
    +
    ` + : ""; + + let actionBlock = ""; + if (requiresArgs) { + const placeholder = + next.stage === "specify" + ? "Describe the feature…" + : next.stage === "plan" + ? "Tech stack & architecture context…" + : "Arguments…"; + actionBlock = `
    + + +
    `; + } else { + actionBlock = `
    + +
    `; + } + + return `
    +
    +
    + ${escapeHtml(feature.name)} + ${escapeHtml(feature.slug)} + ${isCurrent ? 'current branch' : ""} +
    +
    + +
    +
    + ${renderPipeline(feature)} + ${taskBar} +
    + Next: + ${actionBlock} +
    +
    `; +} + +function renderEmptyStates(state) { + if (!state.isSpecKit) { + return `
    +

    Not a Spec Kit project

    +

    ${escapeHtml(state.reason || "")}

    +
    + +
    +
    `; + } + if (state.features.length === 0) { + return `
    +

    No features yet

    +

    Create your first spec by describing what you want to build.

    +
    + + +
    +
    `; + } + return ""; +} + +export function renderBoard(state, opts = {}) { + const featureList = + state.features.length > 0 + ? `
    ${state.features + .map((f) => renderFeatureCard(f, f.slug === state.currentFeatureSlug)) + .join("")}
    ` + : ""; + + const featureCount = state.features.length; + const branchLabel = state.currentBranch ? `branch: ${escapeHtml(state.currentBranch)}` : ""; + const picker = renderScenarioPicker(opts.scenarios, opts.activeScenario); + const banner = renderMockBanner(opts.activeScenario, opts.scenarios); + + return ` + + + +Spec Kit Board + + + +
    +
    +
    + SK +
    +

    Spec Kit Board

    +

    ${featureCount} feature${featureCount === 1 ? "" : "s"}${branchLabel ? " · " + branchLabel : ""}

    +
    +
    +
    + ${picker} + +
    +
    + ${banner} + ${state.isSpecKit ? renderConstitution(state.constitution) : ""} + ${state.isSpecKit && state.features.length > 0 ? featureList : ""} + ${renderEmptyStates(state)} +
    + + + +`; +} + +function renderFileTile(label, fileObj, options = {}) { + const exists = !!fileObj && (fileObj.exists !== false); + const path = fileObj?.path || ""; + const actions = exists + ? `
    + +
    ` + : options.createPrompt + ? `
    + +
    ` + : `
    missing
    `; + return `
    +
    ${escapeHtml(label)}
    + ${actions} +
    `; +} + +export function renderFeatureView(state, feature, opts = {}) { + const picker = renderScenarioPicker(opts.scenarios, opts.activeScenario); + const banner = renderMockBanner(opts.activeScenario, opts.scenarios); + + if (!feature) { + return `Feature not found +
    +
    +
    + SK +
    +

    Spec Kit Feature

    +

    no feature loaded

    +
    +
    +
    + ${picker} + +
    +
    + ${banner} +
    +

    Feature not found

    +

    ${escapeHtml(opts.requestedSlug ? "Slug '" + opts.requestedSlug + "' doesn't exist in specs/." : "That feature slug doesn't exist in specs/.")}

    +

    Pick a mock scenario above to preview the feature canvas.

    +
    +
    `; + } + + const files = feature.files; + const taskPct = + feature.tasks.total > 0 + ? Math.round((feature.tasks.done / feature.tasks.total) * 100) + : 0; + + return ` + + + +${escapeHtml(feature.name)} · Spec Kit + + + +
    +
    +
    + SK +
    +

    ${escapeHtml(feature.name)}

    +

    ${escapeHtml(feature.dir)}

    +
    +
    +
    + ${picker} + +
    +
    + + ${banner} +
    + ${renderPipeline(feature)} + ${feature.tasks.total > 0 ? `
    + ${feature.tasks.done}/${feature.tasks.total} tasks (${taskPct}%) +
    +
    ` : ""} +
    + +
    Next step
    + ${(() => { + const next = feature.next; + if (!next) return `

    Feature complete.

    `; + if (next.requiresArgs) { + const inputId = "fv-input"; + return `
    + + +
    `; + } + return ``; + })()} + +
    Artifacts
    +
    + ${renderFileTile("spec.md", files.spec, { createPrompt: "/speckit.specify" })} + ${renderFileTile("plan.md", files.plan, { createPrompt: "/speckit.plan" })} + ${renderFileTile("tasks.md", files.tasks, { createPrompt: "/speckit.tasks" })} + ${files.research ? renderFileTile("research.md", files.research) : ""} + ${files.dataModel ? renderFileTile("data-model.md", files.dataModel) : ""} + ${files.quickstart ? renderFileTile("quickstart.md", files.quickstart) : ""} + ${files.analyze ? renderFileTile(files.analyze.name, files.analyze) : ""} + ${files.checklists.map((c) => renderFileTile(c.name, c)).join("")} +
    + +
    Stage actions
    +
    + + + + +
    +
    + + + +`; +} diff --git a/extensions/speckit-board/scanner.mjs b/extensions/speckit-board/scanner.mjs new file mode 100644 index 0000000000..f5a89ff89c --- /dev/null +++ b/extensions/speckit-board/scanner.mjs @@ -0,0 +1,259 @@ +// Scanner: reads a Spec Kit project directory and returns a structured snapshot. +// Pure file-system reads, no caching, no side effects. Safe to call repeatedly. + +import { promises as fs } from "node:fs"; +import path from "node:path"; + +const STAGES = [ + { id: "constitution", label: "Constitution", file: null, command: "/speckit.constitution" }, + { id: "specify", label: "Specify", file: "spec.md", command: "/speckit.specify" }, + { id: "clarify", label: "Clarify", file: "spec.md", command: "/speckit.clarify", optional: true, detect: "clarifications" }, + { id: "plan", label: "Plan", file: "plan.md", command: "/speckit.plan" }, + { id: "tasks", label: "Tasks", file: "tasks.md", command: "/speckit.tasks" }, + { id: "analyze", label: "Analyze", file: null, command: "/speckit.analyze", optional: true }, + { id: "implement", label: "Implement", file: null, command: "/speckit.implement", optional: true }, +]; + +async function pathExists(p) { + try { + await fs.access(p); + return true; + } catch { + return false; + } +} + +async function readSafe(p) { + try { + return await fs.readFile(p, "utf8"); + } catch { + return null; + } +} + +async function readDirSafe(p) { + try { + return await fs.readdir(p, { withFileTypes: true }); + } catch { + return []; + } +} + +function parseConstitution(text) { + if (!text) return null; + const lines = text.split(/\r?\n/); + const principles = []; + let currentTitle = null; + let currentBody = []; + + for (const line of lines) { + const h2 = line.match(/^##\s+(.+?)\s*$/); + const h3 = line.match(/^###\s+(.+?)\s*$/); + if (h2 || h3) { + if (currentTitle) { + principles.push({ title: currentTitle, body: currentBody.join(" ").trim().slice(0, 200) }); + } + currentTitle = (h2 || h3)[1].replace(/^[\*_]+|[\*_]+$/g, ""); + currentBody = []; + } else if (currentTitle && line.trim() && !line.startsWith("#")) { + currentBody.push(line.trim()); + } + } + if (currentTitle) { + principles.push({ title: currentTitle, body: currentBody.join(" ").trim().slice(0, 200) }); + } + return principles + .filter((p) => !/table of contents|overview|references|governance|amendments/i.test(p.title)) + .slice(0, 8); +} + +function parseTaskCounts(text) { + if (!text) return { total: 0, done: 0 }; + let total = 0; + let done = 0; + for (const line of text.split(/\r?\n/)) { + const m = line.match(/^\s*[-*]\s*\[([ xX])\]/); + if (m) { + total++; + if (m[1].toLowerCase() === "x") done++; + } + } + return { total, done }; +} + +function hasClarifications(specText) { + if (!specText) return false; + return /##\s+Clarifications/i.test(specText) || /\[NEEDS CLARIFICATION/i.test(specText) === false && /Clarifications?:?\s*$/m.test(specText); +} + +async function scanFeature(specsDir, entry) { + const dir = path.join(specsDir, entry); + const slug = entry; + const niceName = entry.replace(/^\d+[-_]?/, "").replace(/[-_]+/g, " ").trim() || entry; + + const specPath = path.join(dir, "spec.md"); + const planPath = path.join(dir, "plan.md"); + const tasksPath = path.join(dir, "tasks.md"); + + const [specExists, planExists, tasksExists] = await Promise.all([ + pathExists(specPath), + pathExists(planPath), + pathExists(tasksPath), + ]); + + const specText = specExists ? await readSafe(specPath) : null; + const tasksText = tasksExists ? await readSafe(tasksPath) : null; + const taskCounts = parseTaskCounts(tasksText); + + const entries = await readDirSafe(dir); + const checklists = entries + .filter((e) => e.isFile() && /^checklist[-_]?.*\.md$/i.test(e.name)) + .map((e) => e.name); + const analyzeReport = entries.find( + (e) => e.isFile() && /^analyze(-report)?\.md$/i.test(e.name) + ); + const contractsDir = entries.find((e) => e.isDirectory() && e.name === "contracts"); + const researchFile = entries.find((e) => e.isFile() && e.name === "research.md"); + const dataModelFile = entries.find((e) => e.isFile() && e.name === "data-model.md"); + const quickstartFile = entries.find((e) => e.isFile() && e.name === "quickstart.md"); + + const stages = { + specify: specExists, + clarify: specExists && hasClarifications(specText), + plan: planExists, + tasks: tasksExists, + analyze: !!analyzeReport, + implement: taskCounts.total > 0 && taskCounts.done > 0, + }; + + // Determine next legal stage + let nextStage = null; + let nextCommand = null; + let nextRequiresArgs = false; + if (!specExists) { + nextStage = "specify"; + nextCommand = "/speckit.specify"; + nextRequiresArgs = true; + } else if (!planExists) { + nextStage = "plan"; + nextCommand = "/speckit.plan"; + nextRequiresArgs = true; + } else if (!tasksExists) { + nextStage = "tasks"; + nextCommand = "/speckit.tasks"; + } else if (taskCounts.done < taskCounts.total) { + nextStage = "implement"; + nextCommand = "/speckit.implement"; + } else { + nextStage = "analyze"; + nextCommand = "/speckit.analyze"; + } + + return { + slug, + name: niceName, + dir: path.relative(path.dirname(specsDir), dir), + absDir: dir, + files: { + spec: specExists ? { path: specPath, exists: true } : { exists: false }, + plan: planExists ? { path: planPath, exists: true } : { exists: false }, + tasks: tasksExists ? { path: tasksPath, exists: true } : { exists: false }, + checklists: checklists.map((name) => ({ name, path: path.join(dir, name) })), + analyze: analyzeReport ? { name: analyzeReport.name, path: path.join(dir, analyzeReport.name) } : null, + research: researchFile ? { path: path.join(dir, "research.md") } : null, + dataModel: dataModelFile ? { path: path.join(dir, "data-model.md") } : null, + quickstart: quickstartFile ? { path: path.join(dir, "quickstart.md") } : null, + contracts: contractsDir ? { path: path.join(dir, "contracts") } : null, + }, + stages, + tasks: taskCounts, + next: { stage: nextStage, command: nextCommand, requiresArgs: nextRequiresArgs }, + }; +} + +async function detectCurrentBranch(cwd) { + try { + const headPath = path.join(cwd, ".git", "HEAD"); + const head = await readSafe(headPath); + if (!head) return null; + const m = head.match(/^ref:\s+refs\/heads\/(.+)$/m); + return m ? m[1].trim() : null; + } catch { + return null; + } +} + +export async function scanProject(cwd) { + if (!cwd) { + return { + isSpecKit: false, + cwd: null, + reason: "No workspace path available", + constitution: null, + features: [], + currentBranch: null, + currentFeatureSlug: null, + }; + } + + const specifyDir = path.join(cwd, ".specify"); + const memoryDir = path.join(specifyDir, "memory"); + const constitutionPath = path.join(memoryDir, "constitution.md"); + const specsDir = path.join(cwd, "specs"); + + const [hasSpecify, hasSpecs, constitutionExists] = await Promise.all([ + pathExists(specifyDir), + pathExists(specsDir), + pathExists(constitutionPath), + ]); + + if (!hasSpecify && !hasSpecs) { + return { + isSpecKit: false, + cwd, + reason: "Not a Spec Kit project (no .specify/ or specs/ directory)", + constitution: null, + features: [], + currentBranch: await detectCurrentBranch(cwd), + currentFeatureSlug: null, + }; + } + + const constitutionText = constitutionExists ? await readSafe(constitutionPath) : null; + const constitution = constitutionExists + ? { + exists: true, + path: constitutionPath, + principles: parseConstitution(constitutionText), + } + : { exists: false, path: constitutionPath, principles: [] }; + + let features = []; + if (hasSpecs) { + const entries = await readDirSafe(specsDir); + const featureDirs = entries.filter((e) => e.isDirectory() && !e.name.startsWith(".")); + features = await Promise.all(featureDirs.map((d) => scanFeature(specsDir, d.name))); + features.sort((a, b) => a.slug.localeCompare(b.slug)); + } + + const currentBranch = await detectCurrentBranch(cwd); + let currentFeatureSlug = null; + if (currentBranch) { + // Match branch names like "001-foo" or "feat/001-foo" against feature dirs + const branchSlug = currentBranch.split("/").pop(); + const match = features.find((f) => f.slug === branchSlug || branchSlug?.startsWith(f.slug)); + if (match) currentFeatureSlug = match.slug; + } + + return { + isSpecKit: true, + cwd, + constitution, + features, + currentBranch, + currentFeatureSlug, + stages: STAGES, + }; +} + +export { STAGES }; diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index b2e8defb18..f8d12eb3e7 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -619,6 +619,13 @@ def _require_specify_project() -> Path: _register_bundle_cmds(app) +# ===== Canvas Commands ===== + +# Canvas subcommand group (specify canvas ...) — see commands/canvas/. +from .commands.canvas import register as _register_canvas_cmds # noqa: E402 +_register_canvas_cmds(app) + + # ===== Workflow Commands ===== workflow_app = typer.Typer( diff --git a/src/specify_cli/commands/canvas/__init__.py b/src/specify_cli/commands/canvas/__init__.py new file mode 100644 index 0000000000..4e1d5715b8 --- /dev/null +++ b/src/specify_cli/commands/canvas/__init__.py @@ -0,0 +1,146 @@ +"""``specify canvas`` command group — install bundled canvas extensions. + +v1 supports a single first-party canvas (``speckit-board``) and is intentionally +lightweight: no registry, no manifest abstraction. Adding more canvases later +should extend ``_CANVASES`` and (if needed) split into a richer module. +""" +from __future__ import annotations + +import os +import shutil +from pathlib import Path + +import typer + +from ..._console import console +from ..._assets import _repo_root + +canvas_app = typer.Typer( + name="canvas", + help="Install and manage Spec Kit canvas extensions for Copilot CLI", + add_completion=False, +) + + +_CANVASES: dict[str, dict[str, str]] = { + "speckit-board": { + "source_subpath": "extensions/speckit-board", + "description": ( + "Spec-Driven Development dashboard: portfolio view of all features, " + "pipeline stages, and one-click slash-command actions." + ), + }, +} + + +def _extensions_root() -> Path: + """Return the Copilot CLI user-scope extensions directory.""" + home = os.environ.get("COPILOT_HOME") or str(Path.home() / ".copilot") + return Path(home) / "extensions" + + +def _source_dir(name: str) -> Path: + spec = _CANVASES[name] + return _repo_root() / spec["source_subpath"] + + +def _fail(message: str) -> None: + console.print(f"[red]Error:[/red] {message}") + raise typer.Exit(code=1) + + +@canvas_app.command("list") +def canvas_list() -> None: + """List canvases available to install.""" + console.print("[bold]Available canvases:[/bold]") + for name, spec in _CANVASES.items(): + installed = (_extensions_root() / name).exists() + marker = "[green]✓ installed[/green]" if installed else "[dim]not installed[/dim]" + console.print(f" • [cyan]{name}[/cyan] {marker}") + console.print(f" {spec['description']}") + + +@canvas_app.command("install") +def canvas_install( + name: str = typer.Argument( + "speckit-board", + help="Canvas to install (default: speckit-board).", + ), + dev: bool = typer.Option( + False, + "--dev", + help="Install via symlink from the repo source (for active development).", + ), + force: bool = typer.Option( + False, + "--force", + help="Overwrite an existing installation.", + ), +) -> None: + """Install a canvas extension into ~/.copilot/extensions/.""" + if name not in _CANVASES: + _fail(f"Unknown canvas '{name}'. Run 'specify canvas list' to see options.") + + src = _source_dir(name) + if not src.is_dir(): + _fail( + f"Source not found at {src}. This command must be run from a checkout of the spec-kit repo." + ) + + dest_root = _extensions_root() + dest = dest_root / name + dest_root.mkdir(parents=True, exist_ok=True) + + if dest.exists() or dest.is_symlink(): + if not force: + _fail( + f"{dest} already exists. Re-run with --force to overwrite, " + f"or run 'specify canvas uninstall {name}' first." + ) + if dest.is_symlink() or dest.is_file(): + dest.unlink() + else: + shutil.rmtree(dest) + + if dev: + dest.symlink_to(src.resolve(), target_is_directory=True) + console.print(f"[green]✓[/green] Symlinked [cyan]{name}[/cyan] → {src}") + else: + shutil.copytree(src, dest) + console.print(f"[green]✓[/green] Installed [cyan]{name}[/cyan] → {dest}") + + console.print() + console.print( + "[dim]Restart Copilot CLI (or run an extensions_reload from the agent) to activate.[/dim]" + ) + console.print( + f"[dim]Open with: ask the agent to 'open the {name} canvas'.[/dim]" + ) + + +@canvas_app.command("uninstall") +def canvas_uninstall( + name: str = typer.Argument( + "speckit-board", + help="Canvas to uninstall (default: speckit-board).", + ), +) -> None: + """Remove a canvas extension from ~/.copilot/extensions/.""" + if name not in _CANVASES: + _fail(f"Unknown canvas '{name}'.") + + dest = _extensions_root() / name + if not dest.exists() and not dest.is_symlink(): + console.print(f"[yellow]Not installed:[/yellow] {dest}") + return + + if dest.is_symlink() or dest.is_file(): + dest.unlink() + else: + shutil.rmtree(dest) + console.print(f"[green]✓[/green] Removed [cyan]{name}[/cyan] from {dest.parent}") + + +def register(app: typer.Typer) -> None: + """Attach the canvas command group to the root Typer app.""" + app.add_typer(canvas_app, name="canvas")