From 108096a884078459a49f0b28a7494f576ed91140 Mon Sep 17 00:00:00 2001 From: Shayne Boyer <7681382+spboyer@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:39:24 -0400 Subject: [PATCH 1/2] feat: add speckit-board canvas extension for Copilot CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a Copilot CLI canvas extension that turns Spec Kit projects into a live Spec-Driven Development dashboard in the side panel, plus a 'specify canvas' CLI subcommand for managing canvas installs. Two canvases registered by one extension: - speckit-board: portfolio view of every feature in specs/, each row showing pipeline state (Specify -> Clarify -> Plan -> Tasks -> Analyze -> Implement) with one-click slash-command actions and a collapsible constitution strip at the top. - speckit-feature: focused drill-in for a single feature with an artifact grid (spec, plan, tasks, checklists, research, data model, analyze report) and stage-specific buttons that send /speckit.* commands to the chat via session.send(). Both canvases are always openable and ship empty states that guide users through 'specify init' or kicking off their first spec. Native theming uses GitHub canvas tokens (--background-color-default, --text-color-*, --font-sans, --b-11-10, --g-11-10, etc.) so the UI follows the host theme — light, dark, and high-contrast. CLI: - 'specify canvas list' shows available canvases and install state. - 'specify canvas install [--dev] [--force] []' copies (or symlinks with --dev) into $COPILOT_HOME/extensions. - 'specify canvas uninstall ' removes the install. Implementation notes: - No package.json / node_modules — @github/copilot-sdk/extension is auto-resolved by the host. Native Node modules only. - One loopback HTTP server per instanceId; SESSION captured at module scope so request handlers can drive the session. - Logs go through session.log() — stdout is reserved for JSON-RPC. - Input validation: open_feature requires non-empty slug; run_command rejects anything that isn't a /speckit.* slash command. - Manual + on-focus refresh; no fs.watch in v1. All 4598 existing tests still pass. Assisted-by: GitHub Copilot (model: claude-opus-4.7, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 24 + extensions/speckit-board/README.md | 97 +++ extensions/speckit-board/extension.mjs | 298 ++++++++ extensions/speckit-board/renderer.mjs | 774 ++++++++++++++++++++ extensions/speckit-board/scanner.mjs | 259 +++++++ src/specify_cli/__init__.py | 7 + src/specify_cli/commands/canvas/__init__.py | 146 ++++ 7 files changed, 1605 insertions(+) create mode 100644 extensions/speckit-board/README.md create mode 100644 extensions/speckit-board/extension.mjs create mode 100644 extensions/speckit-board/renderer.mjs create mode 100644 extensions/speckit-board/scanner.mjs create mode 100644 src/specify_cli/commands/canvas/__init__.py 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..804397adc4 --- /dev/null +++ b/extensions/speckit-board/README.md @@ -0,0 +1,97 @@ +# 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). diff --git a/extensions/speckit-board/extension.mjs b/extensions/speckit-board/extension.mjs new file mode 100644 index 0000000000..d13dce81cd --- /dev/null +++ b/extensions/speckit-board/extension.mjs @@ -0,0 +1,298 @@ +// 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"; + +// 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"); + + if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/index.html")) { + const state = await scanProject(entry.cwd); + return htmlResponse(res, 200, renderBoard(state)); + } + if (req.method === "GET" && url.pathname === "/api/state") { + const state = await scanProject(entry.cwd); + 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"); + + if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/index.html")) { + const state = await scanProject(entry.cwd); + const feature = state.features.find((f) => f.slug === entry.slug) || null; + return htmlResponse(res, 200, renderFeatureView(state, feature)); + } + 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/renderer.mjs b/extensions/speckit-board/renderer.mjs new file mode 100644 index 0000000000..7adf64201d --- /dev/null +++ b/extensions/speckit-board/renderer.mjs @@ -0,0 +1,774 @@ +// 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); } +`; + +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(); + } +}); + +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 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) { + 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)}` : ""; + + return ` + + + +Spec Kit Board + + + +
    +
    +
    + SK +
    +

    Spec Kit Board

    +

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

    +
    +
    +
    + +
    +
    + ${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) { + if (!feature) { + return `Feature not found +
    +
    +

    Feature not found

    +

    That feature slug doesn't exist in specs/.

    +
    + +
    +
    +
    `; + } + + 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)}

    +
    +
    +
    + +
    +
    + +
    + ${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") From 7c3adb0cedd8e3c87ee78ee947a338de72562bd1 Mon Sep 17 00:00:00 2001 From: Shayne Boyer <7681382+spboyer@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:52:27 -0400 Subject: [PATCH 2/2] feat(canvas): add UX mock scenarios for speckit-board canvases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Mock picker in both speckit-board and speckit-feature toolbars that swaps the live scanProject() output for synthetic state, letting reviewers preview every pipeline stage without seeding a real project. - mocks.mjs: 5 board scenarios (not-initialized → mature) and 8 feature scenarios (not-found, spec-only, with-clarify, planned, tasks-started, tasks-done, analyzed, implemented), all matching the real scanner shape so the renderer treats them identically to live state. - extension.mjs: handlers honor ?scenario=; unknown names 404. - renderer.mjs: adds renderScenarioPicker(), a mock banner, picker styling using GitHub canvas theme tokens, and a change handler that updates the URL search param. - README: documents the available scenarios and how to use the picker. Smoke-tested every scenario via curl: board returns 0/0/1/5/7 feature cards as expected and feature scenarios escalate file-tile counts through the lifecycle. Mocks are read-only and never send prompts. Assisted-by: GitHub Copilot (model: claude-opus-4.7, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- extensions/speckit-board/README.md | 15 ++ extensions/speckit-board/extension.mjs | 34 ++- extensions/speckit-board/mocks.mjs | 274 +++++++++++++++++++++++++ extensions/speckit-board/renderer.mjs | 111 +++++++++- 4 files changed, 422 insertions(+), 12 deletions(-) create mode 100644 extensions/speckit-board/mocks.mjs diff --git a/extensions/speckit-board/README.md b/extensions/speckit-board/README.md index 804397adc4..d2db0526b3 100644 --- a/extensions/speckit-board/README.md +++ b/extensions/speckit-board/README.md @@ -95,3 +95,18 @@ Both canvases expose actions the agent can invoke directly: 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 index d13dce81cd..9bcd8b0475 100644 --- a/extensions/speckit-board/extension.mjs +++ b/extensions/speckit-board/extension.mjs @@ -15,6 +15,12 @@ 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(); @@ -62,13 +68,16 @@ function htmlResponse(res, status, 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 = await scanProject(entry.cwd); - return htmlResponse(res, 200, renderBoard(state)); + 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 = await scanProject(entry.cwd); + 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); @@ -76,11 +85,24 @@ async function handleBoardRequest(req, res, entry) { 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")) { - const state = await scanProject(entry.cwd); - const feature = state.features.find((f) => f.slug === entry.slug) || null; - return htmlResponse(res, 200, renderFeatureView(state, feature)); + 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); } 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 index 7adf64201d..c893bb1d65 100644 --- a/extensions/speckit-board/renderer.mjs +++ b/extensions/speckit-board/renderer.mjs @@ -349,6 +349,55 @@ input[type="text"]:focus, textarea:focus { 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 = ` @@ -449,6 +498,17 @@ document.addEventListener("submit", (ev) => { } }); +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(); @@ -462,6 +522,24 @@ document.addEventListener("keydown", (ev) => { }); `; +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) => ({ "&": "&", @@ -615,7 +693,7 @@ function renderEmptyStates(state) { return ""; } -export function renderBoard(state) { +export function renderBoard(state, opts = {}) { const featureList = state.features.length > 0 ? `
    ${state.features @@ -625,6 +703,8 @@ export function renderBoard(state) { 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 ` @@ -644,11 +724,13 @@ export function renderBoard(state) {
    + ${picker}
    + ${banner} ${state.isSpecKit ? renderConstitution(state.constitution) : ""} ${state.isSpecKit && state.features.length > 0 ? featureList : ""} ${renderEmptyStates(state)} @@ -677,16 +759,31 @@ function renderFileTile(label, fileObj, options = {}) { `; } -export function renderFeatureView(state, feature) { +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

    -

    That feature slug doesn't exist in specs/.

    -
    - -
    +

    ${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.

    `; } @@ -715,10 +812,12 @@ export function renderFeatureView(state, feature) {
    + ${picker}
    + ${banner}
    ${renderPipeline(feature)} ${feature.tasks.total > 0 ? `