From 331d98079cda57421bbb5caebd273fe4d3039d70 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 7 Feb 2026 17:04:27 +0800 Subject: [PATCH] feat: add BMAD Copilot extension for VS Code - Implemented core functionality for integrating BMAD-METHOD agents and workflows into GitHub Copilot Chat. - Added dynamic command discovery for agents and workflows. - Created commands for listing, running, and diagnosing BMAD installations. - Developed a chat handler to process user commands and provide responses. - Introduced a logger for tracking extension activity and errors. - Set up TypeScript configuration and project structure for development. - Included README documentation for usage instructions and development setup. --- bmad-copilot/.gitignore | 4 + bmad-copilot/.vscode/launch.json | 15 ++ bmad-copilot/.vscode/tasks.json | 18 ++ bmad-copilot/README.md | 140 ++++++++++++ bmad-copilot/package.json | 95 +++++++++ bmad-copilot/src/bmadIndex.ts | 285 +++++++++++++++++++++++++ bmad-copilot/src/chatHandler.ts | 340 ++++++++++++++++++++++++++++++ bmad-copilot/src/commandParser.ts | 186 ++++++++++++++++ bmad-copilot/src/extension.ts | 50 +++++ bmad-copilot/src/logger.ts | 26 +++ bmad-copilot/tsconfig.json | 19 ++ 11 files changed, 1178 insertions(+) create mode 100644 bmad-copilot/.gitignore create mode 100644 bmad-copilot/.vscode/launch.json create mode 100644 bmad-copilot/.vscode/tasks.json create mode 100644 bmad-copilot/README.md create mode 100644 bmad-copilot/package.json create mode 100644 bmad-copilot/src/bmadIndex.ts create mode 100644 bmad-copilot/src/chatHandler.ts create mode 100644 bmad-copilot/src/commandParser.ts create mode 100644 bmad-copilot/src/extension.ts create mode 100644 bmad-copilot/src/logger.ts create mode 100644 bmad-copilot/tsconfig.json diff --git a/bmad-copilot/.gitignore b/bmad-copilot/.gitignore new file mode 100644 index 000000000..e6b3087f5 --- /dev/null +++ b/bmad-copilot/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +out/ +*.vsix +.vscode-test/ diff --git a/bmad-copilot/.vscode/launch.json b/bmad-copilot/.vscode/launch.json new file mode 100644 index 000000000..58b8a7e54 --- /dev/null +++ b/bmad-copilot/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run Extension", + "type": "extensionHost", + "request": "launch", + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}" + ], + "outFiles": ["${workspaceFolder}/out/**/*.js"], + "preLaunchTask": "npm: watch" + } + ] +} diff --git a/bmad-copilot/.vscode/tasks.json b/bmad-copilot/.vscode/tasks.json new file mode 100644 index 000000000..34edf970f --- /dev/null +++ b/bmad-copilot/.vscode/tasks.json @@ -0,0 +1,18 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "type": "npm", + "script": "watch", + "problemMatcher": "$tsc-watch", + "isBackground": true, + "presentation": { + "reveal": "never" + }, + "group": { + "kind": "build", + "isDefault": true + } + } + ] +} diff --git a/bmad-copilot/README.md b/bmad-copilot/README.md new file mode 100644 index 000000000..9292f8002 --- /dev/null +++ b/bmad-copilot/README.md @@ -0,0 +1,140 @@ +# BMAD Copilot + +> BMAD-METHOD integration for VS Code GitHub Copilot Chat. + +Brings the full BMAD agent and workflow experience into Copilot Chat via the `@bmad` participant. Commands, agents, and workflows are discovered dynamically from your BMAD installation — no manual configuration required. + +## Commands + +| Command | Description | +|---------|-------------| +| `@bmad /help` | Show available commands and installed items | +| `@bmad /doctor` | Diagnose BMAD installation status | +| `@bmad /list agents` | List all registered agents with metadata | +| `@bmad /list workflows` | List all registered workflows with metadata | +| `@bmad /run agent ""` | Execute task with agent persona | +| `@bmad /run workflow ""` | Execute task in workflow | +| `@bmad /run ""` | Auto-resolve: tries agent first, then workflow | +| `@bmad /agents` | List agents (shorthand for /list agents) | +| `@bmad /agents ""` | Run agent (shorthand for /run agent) | +| `@bmad /workflows` | List workflows (shorthand for /list workflows) | +| `@bmad /workflows ""` | Run workflow (shorthand for /run workflow) | + +### Alias & Shorthand Patterns + +The parser recognizes multiple forms to match Cloud Code BMAD experience: + +``` +@bmad /run agent analyst "Generate PRD" # explicit agent +@bmad /run workflow create-prd "Build PRD" # explicit workflow +@bmad /run analyst "Generate PRD" # auto-resolve (agent match) +@bmad /run create-prd "Build PRD" # auto-resolve (workflow match) +@bmad /run a analyst "Generate PRD" # shorthand 'a' for agent +@bmad /run w create-prd "Build PRD" # shorthand 'w' for workflow +@bmad /agents analyst "Generate PRD" # direct agent command +@bmad /workflows create-prd "Build PRD" # direct workflow command +@bmad /run bmm agents analyst "Generate PRD" # namespaced by module +@bmad /run core workflows brainstorming "Go" # namespaced by module +``` + +## Dynamic Command Discovery + +The extension automatically scans your BMAD installation for agents and workflows: + +1. **Agent files** — any `*.agent.yaml` or `*.agent.md` file under the BMAD root +2. **Workflow files** — any `workflow.yaml`, `workflow.md`, or `workflow-*.md`/`workflow-*.yaml` file + +For each file found, the extension extracts: + +- **Name** — from filename or YAML `name:` field +- **Title** — from agent `metadata.title` (agents only) +- **Description** — from `persona.role` (agents) or `description:` key (workflows) +- **Icon** — from `metadata.icon` (agents only) +- **Module** — inferred from directory structure (e.g. `bmm`, `core`) + +The index is rebuilt automatically when files change (via FileSystemWatcher). + +### Detection Paths + +The extension searches for BMAD in this order: + +1. `bmad.rootPath` setting (explicit override) +2. `_bmad/` in workspace root +3. `.bmad-core/` or `_bmad-core/` in workspace root +4. `src/` with `bmm/` or `core/` subdirs (BMAD-METHOD repo layout) + +### Copilot Chat Autocomplete + +All registered slash commands (`/help`, `/doctor`, `/list`, `/run`, `/agents`, `/workflows`) appear in the Copilot Chat autocomplete when you type `@bmad /`. + +## Development (F5) + +### Prerequisites + +- VS Code 1.93+ +- GitHub Copilot + Copilot Chat installed and signed in +- Node.js 20+ + +### Setup + +```bash +cd bmad-copilot +npm install +``` + +### Launch Extension Development Host + +1. Open the `bmad-copilot` folder in VS Code +2. Press **F5** (or Run > Start Debugging) +3. Select **Run Extension** configuration +4. In the Extension Development Host window, open a project with BMAD installed + +### Smoke Tests + +#### Workspace without BMAD + +``` +@bmad /doctor → Shows "BMAD Root: Not found" +``` + +#### Workspace with BMAD (`_bmad/` present) + +``` +@bmad /help → Command table + installed items +@bmad /doctor → Detected paths, agent/workflow counts +@bmad /list agents → Table with name, title, module, path +@bmad /list workflows → Table with name, description, module, path +@bmad /run agent analyst "Generate PRD outline" → Copilot responds as analyst +@bmad /run analyst "Generate PRD outline" → Same (auto-resolved) +@bmad /agents pm "Plan sprints" → Copilot responds as PM +@bmad /run workflow create-prd "Build requirements" → Copilot runs workflow +@bmad /workflows → Lists all workflows +``` + +#### BMAD-METHOD repo itself + +The extension detects the source layout (`src/bmm/`, `src/core/`) and indexes agents and workflows from `src/`. + +## Settings + +| Setting | Default | Description | +|---------|---------|-------------| +| `bmad.rootPath` | `""` | Explicit BMAD root directory path | +| `bmad.autoDetect` | `true` | Auto-detect BMAD installation in workspace | + +## Architecture + +``` +src/ +├── extension.ts # Entry point: activation, participant registration +├── chatHandler.ts # Chat request handler: routes all commands +├── bmadIndex.ts # BMAD directory detection, metadata extraction, indexing +├── commandParser.ts # Token parser, alias resolution, fuzzy matching +└── logger.ts # OutputChannel logging +``` + +## Limitations + +- The VS Code Language Model API requires Copilot Chat authorization. If unavailable, the extension falls back to a copyable assembled prompt with a copy button. +- This extension only reads BMAD files. It does not modify any BMAD content. +- Workflow files using `.xml` format (e.g. `advanced-elicitation`) are not currently indexed. diff --git a/bmad-copilot/package.json b/bmad-copilot/package.json new file mode 100644 index 000000000..859482eb2 --- /dev/null +++ b/bmad-copilot/package.json @@ -0,0 +1,95 @@ +{ + "name": "bmad-copilot", + "displayName": "BMAD Copilot", + "description": "Integrate BMAD-METHOD agents and workflows into GitHub Copilot Chat via slash commands.", + "version": "0.1.0", + "publisher": "bmad-code-org", + "license": "MIT", + "engines": { + "vscode": "^1.93.0" + }, + "categories": [ + "Chat" + ], + "keywords": [ + "bmad", + "copilot", + "chat", + "agents", + "workflows" + ], + "extensionDependencies": [ + "github.copilot-chat" + ], + "activationEvents": [], + "main": "./out/extension.js", + "contributes": { + "chatParticipants": [ + { + "id": "bmad-copilot.bmad", + "name": "bmad", + "fullName": "BMAD Method", + "description": "Run BMAD agents and workflows in Copilot Chat. Try /help to get started.", + "isSticky": true, + "commands": [ + { + "name": "help", + "description": "Show available BMAD commands and usage examples" + }, + { + "name": "doctor", + "description": "Diagnose BMAD installation: workspace path, detected files, errors" + }, + { + "name": "list", + "description": "List available agents or workflows. Usage: /list agents | /list workflows" + }, + { + "name": "run", + "description": "Run an agent or workflow. Usage: /run agent | /run workflow | /run " + }, + { + "name": "agents", + "description": "List agents or run one. Usage: /agents | /agents \"\"" + }, + { + "name": "workflows", + "description": "List workflows or run one. Usage: /workflows | /workflows \"\"" + } + ] + } + ], + "configuration": { + "title": "BMAD Copilot", + "properties": { + "bmad.rootPath": { + "type": "string", + "default": "", + "description": "Explicit path to the BMAD root directory (e.g. _bmad). Leave empty for auto-detection." + }, + "bmad.autoDetect": { + "type": "boolean", + "default": true, + "description": "Automatically detect BMAD installation in the workspace." + } + } + }, + "commands": [ + { + "command": "bmad-copilot.copyToClipboard", + "title": "BMAD: Copy Prompt to Clipboard" + } + ] + }, + "scripts": { + "vscode:prepublish": "npm run compile", + "compile": "tsc -p ./", + "watch": "tsc -watch -p ./", + "lint": "eslint src --ext ts" + }, + "devDependencies": { + "@types/vscode": "^1.93.0", + "@types/node": "^20.0.0", + "typescript": "^5.5.0" + } +} \ No newline at end of file diff --git a/bmad-copilot/src/bmadIndex.ts b/bmad-copilot/src/bmadIndex.ts new file mode 100644 index 000000000..080a64c06 --- /dev/null +++ b/bmad-copilot/src/bmadIndex.ts @@ -0,0 +1,285 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import * as fs from 'fs'; +import { logInfo, logWarn, logError } from './logger'; + +/** + * Well-known paths where BMAD is installed after `npx bmad-method install`. + */ +const CANDIDATE_ROOTS = ['_bmad', '.bmad-core', '_bmad-core']; + +export interface BmadItem { + /** Display name derived from filename or YAML metadata (e.g. "analyst") */ + name: string; + /** Absolute file path */ + filePath: string; + /** Relative path from workspace root */ + relativePath: string; + /** Human-readable title extracted from YAML (e.g. "Business Analyst") */ + title?: string; + /** Description extracted from YAML front-matter or top-level key */ + description?: string; + /** Icon emoji extracted from YAML metadata */ + icon?: string; + /** Module the item belongs to (e.g. "bmm", "core") */ + module?: string; +} + +export interface BmadIndex { + rootPath: string; + agents: BmadItem[]; + workflows: BmadItem[]; +} + +// ─── Detection ────────────────────────────────────────── + +function workspaceRoot(): string | undefined { + return vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; +} + +/** + * Resolve the BMAD root directory. + * Priority: explicit config > auto-detect. + */ +export function detectBmadRoot(): string | undefined { + const wsRoot = workspaceRoot(); + if (!wsRoot) { + logWarn('No workspace folder open.'); + return undefined; + } + + const config = vscode.workspace.getConfiguration('bmad'); + const explicit: string = config.get('rootPath', '').trim(); + + if (explicit) { + const abs = path.isAbsolute(explicit) ? explicit : path.join(wsRoot, explicit); + if (fs.existsSync(abs)) { + logInfo(`Using explicit bmad.rootPath: ${abs}`); + return abs; + } + logWarn(`Configured bmad.rootPath "${explicit}" not found at ${abs}`); + } + + const autoDetect = config.get('autoDetect', true); + if (!autoDetect) { + logInfo('Auto-detect disabled and no explicit rootPath.'); + return undefined; + } + + for (const candidate of CANDIDATE_ROOTS) { + const p = path.join(wsRoot, candidate); + if (fs.existsSync(p) && fs.statSync(p).isDirectory()) { + logInfo(`Auto-detected BMAD root: ${p}`); + return p; + } + } + + // Also check if we're inside the BMAD-METHOD repo itself (src/ layout) + const srcPath = path.join(wsRoot, 'src'); + if (fs.existsSync(path.join(srcPath, 'bmm')) || fs.existsSync(path.join(srcPath, 'core'))) { + logInfo(`Detected BMAD-METHOD repo layout at: ${srcPath}`); + return srcPath; + } + + logWarn('BMAD root not found in workspace.'); + return undefined; +} + +// ─── YAML / front-matter metadata extraction ──────────── + +/** + * Lightweight extraction of metadata from YAML agent files. + * Reads plain text and uses regex — no YAML parser dependency. + */ +function extractAgentMeta(content: string): { title?: string; description?: string; icon?: string; module?: string; role?: string } { + const meta: { title?: string; description?: string; icon?: string; module?: string; role?: string } = {}; + // metadata.title + const titleM = content.match(/^\s+title:\s*["']?(.+?)["']?\s*$/m); + if (titleM) { meta.title = titleM[1]; } + // metadata.icon + const iconM = content.match(/^\s+icon:\s*["']?(.+?)["']?\s*$/m); + if (iconM) { meta.icon = iconM[1].trim(); } + // metadata.module + const modM = content.match(/^\s+module:\s*["']?(.+?)["']?\s*$/m); + if (modM) { meta.module = modM[1]; } + // persona.role + const roleM = content.match(/^\s+role:\s*["']?(.+?)["']?\s*$/m); + if (roleM) { meta.role = roleM[1]; } + // Use role as description if available + if (meta.role) { meta.description = meta.role; } + return meta; +} + +/** + * Extract name/description from workflow files. + * Supports: + * - YAML front-matter in .md files (---\nname: ...\ndescription: ...\n---) + * - Top-level YAML keys in .yaml files (name: ...\ndescription: ...) + */ +function extractWorkflowMeta(content: string): { name?: string; description?: string } { + const meta: { name?: string; description?: string } = {}; + // Try YAML front-matter first + const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); + const block = fmMatch ? fmMatch[1] : content; + const nameM = block.match(/^name:\s*["']?(.+?)["']?\s*$/m); + if (nameM) { meta.name = nameM[1]; } + const descM = block.match(/^description:\s*["']?(.+?)["']?\s*$/m); + if (descM) { meta.description = descM[1]; } + return meta; +} + +// ─── Recursive file walker ────────────────────────────── + +function walkAll(dir: string): string[] { + const results: string[] = []; + function walk(current: string): void { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(current, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const full = path.join(current, entry.name); + if (entry.isDirectory()) { + walk(full); + } else if (entry.isFile()) { + results.push(full); + } + } + } + walk(dir); + return results; +} + +// ─── Name derivation ──────────────────────────────────── + +function isAgentFile(filename: string): boolean { + return /\.agent\.(yaml|md)$/i.test(filename); +} + +function isWorkflowFile(filename: string): boolean { + // Matches: workflow.yaml, workflow.md, workflow-*.md, workflow-*.yaml + return /^workflow(-[\w-]+)?\.(yaml|md)$/i.test(filename); +} + +function deriveAgentName(filePath: string): string { + const base = path.basename(filePath); + return base.replace(/\.agent\.(yaml|md)$/i, ''); +} + +function deriveWorkflowName(filePath: string, content: string): string { + // Prefer name from YAML metadata + const meta = extractWorkflowMeta(content); + if (meta.name) { return meta.name; } + // Fallback: strip "workflow-" prefix and extension, or use parent dir name + const base = path.basename(filePath); + const stripped = base.replace(/\.(yaml|md)$/i, '').replace(/^workflow-?/, ''); + if (stripped) { return stripped; } + return path.basename(path.dirname(filePath)); +} + +/** + * Infer the module name from a file path. + * e.g. _bmad/bmm/agents/... -> "bmm", _bmad/core/workflows/... -> "core" + */ +function inferModule(filePath: string, bmadRoot: string): string | undefined { + const rel = path.relative(bmadRoot, filePath).replace(/\\/g, '/'); + const first = rel.split('/')[0]; + // Only return known module-like names (not the file itself) + if (first && !first.includes('.')) { return first; } + return undefined; +} + +// ─── Build index ──────────────────────────────────────── + +export function buildIndex(bmadRoot: string): BmadIndex { + const wsRoot = workspaceRoot() ?? bmadRoot; + const allFiles = walkAll(bmadRoot); + + const agents: BmadItem[] = []; + const workflows: BmadItem[] = []; + const seenWorkflows = new Set(); + + for (const fp of allFiles) { + const filename = path.basename(fp); + const relPath = path.relative(wsRoot, fp).replace(/\\/g, '/'); + const mod = inferModule(fp, bmadRoot); + + if (isAgentFile(filename)) { + let content = ''; + try { content = fs.readFileSync(fp, 'utf-8'); } catch { /* ignore */ } + const meta = extractAgentMeta(content); + agents.push({ + name: deriveAgentName(fp), + filePath: fp, + relativePath: relPath, + title: meta.title, + description: meta.description, + icon: meta.icon, + module: meta.module ?? mod, + }); + } else if (isWorkflowFile(filename)) { + let content = ''; + try { content = fs.readFileSync(fp, 'utf-8'); } catch { /* ignore */ } + const wfName = deriveWorkflowName(fp, content); + const meta = extractWorkflowMeta(content); + // Deduplicate by name (keep first found) + if (!seenWorkflows.has(wfName.toLowerCase())) { + seenWorkflows.add(wfName.toLowerCase()); + workflows.push({ + name: wfName, + filePath: fp, + relativePath: relPath, + description: meta.description, + module: mod, + }); + } + } + } + + // Sort alphabetically + agents.sort((a, b) => a.name.localeCompare(b.name)); + workflows.sort((a, b) => a.name.localeCompare(b.name)); + + logInfo(`Index built — ${agents.length} agents, ${workflows.length} workflows`); + return { rootPath: bmadRoot, agents, workflows }; +} + +// ─── State management ─────────────────────────────────── + +let _index: BmadIndex | undefined; +let _watcher: vscode.FileSystemWatcher | undefined; + +export function getIndex(): BmadIndex | undefined { + return _index; +} + +export function refreshIndex(): BmadIndex | undefined { + const root = detectBmadRoot(); + if (!root) { + _index = undefined; + return undefined; + } + _index = buildIndex(root); + return _index; +} + +export function startWatching(ctx: vscode.ExtensionContext): void { + const wsRoot = workspaceRoot(); + if (!wsRoot) { return; } + + // Watch for changes in yaml/md files that could be agents or workflows + const pattern = new vscode.RelativePattern(wsRoot, '**/*.{yaml,md}'); + _watcher = vscode.workspace.createFileSystemWatcher(pattern); + + const rebuild = () => { + logInfo('File change detected — rebuilding index'); + refreshIndex(); + }; + + _watcher.onDidCreate(rebuild); + _watcher.onDidDelete(rebuild); + _watcher.onDidChange(rebuild); + ctx.subscriptions.push(_watcher); +} diff --git a/bmad-copilot/src/chatHandler.ts b/bmad-copilot/src/chatHandler.ts new file mode 100644 index 000000000..0f971c6eb --- /dev/null +++ b/bmad-copilot/src/chatHandler.ts @@ -0,0 +1,340 @@ +import * as vscode from 'vscode'; +import * as fs from 'fs'; +import { getIndex, refreshIndex, BmadIndex, BmadItem } from './bmadIndex'; +import { parseArgs, parsePromptAsCommand, resolveRunTarget, resolveDirectKind, findClosestName, ResolvedRunTarget } from './commandParser'; +import { logInfo, logWarn, logError } from './logger'; + +// ─── Dynamic help builder ─────────────────────────────── + +function buildHelpText(idx: BmadIndex | undefined): string { + let md = `## BMAD Copilot — Available Commands\n\n`; + md += `| Command | Description |\n`; + md += `|---------|-------------|\n`; + md += `| \`@bmad /help\` | Show this help |\n`; + md += `| \`@bmad /doctor\` | Diagnose BMAD installation |\n`; + md += `| \`@bmad /list agents\` | List all registered agents |\n`; + md += `| \`@bmad /list workflows\` | List all registered workflows |\n`; + md += `| \`@bmad /run agent ""\` | Run task with agent persona |\n`; + md += `| \`@bmad /run workflow ""\` | Run task in workflow |\n`; + md += `| \`@bmad /run ""\` | Auto-resolve agent or workflow |\n`; + md += `| \`@bmad /agents ""\` | Shorthand for /run agent |\n`; + md += `| \`@bmad /workflows ""\` | Shorthand for /run workflow |\n`; + + if (idx) { + md += `\n**Installed agents (${idx.agents.length}):** `; + md += idx.agents.map(a => `\`${a.name}\``).join(', ') || '_none_'; + md += `\n\n**Installed workflows (${idx.workflows.length}):** `; + md += idx.workflows.map(w => `\`${w.name}\``).join(', ') || '_none_'; + } + + md += `\n\n**Examples:**\n`; + md += `\`\`\`\n`; + md += `@bmad /run agent analyst "Generate a PRD outline"\n`; + md += `@bmad /run create-prd "Build product requirements"\n`; + md += `@bmad /agents pm "Plan sprint backlog"\n`; + md += `@bmad /list agents\n`; + md += `@bmad /doctor\n`; + md += `\`\`\`\n`; + return md; +} + +// ─── Fuzzy suggestion ─────────────────────────────────── + +const KNOWN_SUBS = ['help', 'doctor', 'list', 'run', 'agents', 'workflows']; + +function suggestCommand(input: string): string { + let best = ''; + let bestScore = 0; + for (const cmd of KNOWN_SUBS) { + let score = 0; + for (let i = 0; i < Math.min(input.length, cmd.length); i++) { + if (input[i] === cmd[i]) { score++; } + } + if (score > bestScore) { bestScore = score; best = cmd; } + } + return best || 'help'; +} + +// ─── Sub-command handlers ─────────────────────────────── + +function handleHelp(stream: vscode.ChatResponseStream): void { + const idx = getIndex() ?? refreshIndex(); + stream.markdown(buildHelpText(idx)); +} + +function handleDoctor(stream: vscode.ChatResponseStream): void { + const wsRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '(no workspace)'; + const config = vscode.workspace.getConfiguration('bmad'); + const explicitRoot = config.get('rootPath', '') || '(not set)'; + const autoDetect = config.get('autoDetect', true); + + const idx = getIndex() ?? refreshIndex(); + + let md = `## BMAD Doctor\n\n`; + md += `| Item | Value |\n|------|-------|\n`; + md += `| Workspace | \`${wsRoot}\` |\n`; + md += `| bmad.rootPath | \`${explicitRoot}\` |\n`; + md += `| bmad.autoDetect | \`${autoDetect}\` |\n`; + + if (idx) { + md += `| BMAD Root | \`${idx.rootPath}\` |\n`; + md += `| Agents | ${idx.agents.length} |\n`; + md += `| Workflows | ${idx.workflows.length} |\n`; + + if (idx.agents.length > 0) { + md += `\n**Agents:** `; + md += idx.agents.map(a => { + const label = a.icon ? `${a.icon} ${a.name}` : a.name; + return a.title ? `\`${label}\` (${a.title})` : `\`${label}\``; + }).join(', '); + md += `\n`; + } + if (idx.workflows.length > 0) { + md += `\n**Workflows:** `; + md += idx.workflows.map(w => `\`${w.name}\``).join(', '); + md += `\n`; + } + } else { + md += `| BMAD Root | **Not found** |\n`; + md += `\n> BMAD installation not found. Ensure \`_bmad/\` exists in workspace or set \`bmad.rootPath\` in settings.\n`; + } + + stream.markdown(md); +} + +function handleList(stream: vscode.ChatResponseStream, args: string[]): void { + const idx = getIndex() ?? refreshIndex(); + if (!idx) { + stream.markdown('BMAD installation not found. Run `@bmad /doctor` to check configuration.'); + return; + } + + const what = args[0]?.toLowerCase(); + if (what === 'agents' || what === 'agent') { + listItems(stream, 'Agents', idx.agents, true); + } else if (what === 'workflows' || what === 'workflow') { + listItems(stream, 'Workflows', idx.workflows, false); + } else { + stream.markdown(`Specify type: \`@bmad /list agents\` or \`@bmad /list workflows\`\n`); + } +} + +function listItems(stream: vscode.ChatResponseStream, title: string, items: BmadItem[], isAgent: boolean): void { + if (items.length === 0) { + stream.markdown(`## ${title}\n\n_No items found._\n`); + return; + } + let md = `## ${title} (${items.length})\n\n`; + if (isAgent) { + md += `| Name | Title | Module | Path |\n|------|-------|--------|------|\n`; + for (const it of items) { + const icon = it.icon ? `${it.icon} ` : ''; + md += `| \`${icon}${it.name}\` | ${it.title ?? '-'} | ${it.module ?? '-'} | \`${it.relativePath}\` |\n`; + } + } else { + md += `| Name | Description | Module | Path |\n|------|-------------|--------|------|\n`; + for (const it of items) { + const desc = it.description ? truncate(it.description, 60) : '-'; + md += `| \`${it.name}\` | ${desc} | ${it.module ?? '-'} | \`${it.relativePath}\` |\n`; + } + } + stream.markdown(md); +} + +function truncate(s: string, max: number): string { + return s.length > max ? s.slice(0, max - 1) + '…' : s; +} + +// ─── Run agent / workflow ─────────────────────────────── + +async function executeRun( + request: vscode.ChatRequest, + stream: vscode.ChatResponseStream, + token: vscode.CancellationToken, + resolved: ResolvedRunTarget +): Promise { + const { kind, item, task: userTask } = resolved; + + // Read file content + let fileContent: string; + try { + fileContent = fs.readFileSync(item.filePath, 'utf-8'); + logInfo(`Read ${kind} file: ${item.filePath} (${fileContent.length} chars)`); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + logError(`Failed to read ${item.filePath}: ${msg}`); + stream.markdown(`Failed to read ${kind} file \`${item.relativePath}\`: ${msg}`); + return; + } + + const task = userTask || '(no specific task provided — greet the user and describe your capabilities)'; + + const systemPrompt = `You are acting as a BMAD ${kind}. Below is the ${kind} definition file content. Follow the persona, instructions, and capabilities described within.\n\n--- BEGIN ${kind.toUpperCase()} DEFINITION ---\n${fileContent}\n--- END ${kind.toUpperCase()} DEFINITION ---`; + + const userMessage = `User task: ${task}`; + + try { + const model = request.model; + if (model) { + const label = item.icon ? `${item.icon} ${item.name}` : item.name; + logInfo(`Sending prompt to model: ${model.id}`); + stream.progress(`Running as ${label} ${kind}...`); + + const messages = [ + vscode.LanguageModelChatMessage.User(systemPrompt), + vscode.LanguageModelChatMessage.User(userMessage), + ]; + + const chatResponse = await model.sendRequest(messages, {}, token); + for await (const fragment of chatResponse.text) { + stream.markdown(fragment); + } + return; + } + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + logWarn(`LLM direct call failed (${msg}), falling back to prompt display.`); + } + + fallbackPrompt(stream, item, kind, fileContent, task); +} + +function handleRun( + request: vscode.ChatRequest, + stream: vscode.ChatResponseStream, + token: vscode.CancellationToken, + args: string[] +): Promise { + const idx = getIndex() ?? refreshIndex(); + if (!idx) { + stream.markdown('BMAD installation not found. Run `@bmad /doctor` to check configuration.'); + return Promise.resolve(); + } + + const resolved = resolveRunTarget(args, idx); + if (!resolved) { + // Could not resolve — provide helpful feedback + const name = args[0]?.toLowerCase(); + if (name) { + const suggestion = findClosestName(name, idx); + let msg = `Could not resolve \`${args.join(' ')}\`.`; + if (suggestion) { msg += ` Did you mean \`${suggestion}\`?`; } + msg += `\n\nUsage: \`@bmad /run agent ""\` or \`@bmad /run ""\``; + msg += `\n\nRun \`@bmad /list agents\` or \`@bmad /list workflows\` to see available names.`; + stream.markdown(msg); + } else { + stream.markdown('Usage: `@bmad /run agent ""` or `@bmad /run workflow ""`\n\nRun `@bmad /list agents` to see available names.'); + } + return Promise.resolve(); + } + + return executeRun(request, stream, token, resolved); +} + +function fallbackPrompt( + stream: vscode.ChatResponseStream, + item: BmadItem, + kind: string, + fileContent: string, + task: string +): void { + stream.markdown(`> LLM API unavailable. Copy the assembled prompt below and paste it into Copilot Chat.\n\n`); + + const assembled = `I want you to adopt the following ${kind} persona and follow its instructions exactly.\n\n--- BEGIN ${kind.toUpperCase()} DEFINITION ---\n${fileContent}\n--- END ${kind.toUpperCase()} DEFINITION ---\n\nNow respond to this task:\n${task}`; + + stream.markdown('```\n' + assembled + '\n```\n'); + + stream.button({ + title: 'Copy Prompt', + command: 'bmad-copilot.copyToClipboard', + arguments: [assembled], + }); +} + +// ─── Main handler ─────────────────────────────────────── + +export const chatHandler: vscode.ChatRequestHandler = async ( + request: vscode.ChatRequest, + context: vscode.ChatContext, + stream: vscode.ChatResponseStream, + token: vscode.CancellationToken +): Promise => { + try { + logInfo(`Chat request — command: ${request.command ?? '(none)'}, prompt: "${request.prompt}"`); + + let command = request.command; + let args: string[] = []; + + if (command) { + args = parseArgs(request.prompt.trim()); + } else { + const parsed = parsePromptAsCommand(request.prompt); + if (parsed) { + command = parsed.subCommand; + args = parsed.args; + } + } + + switch (command) { + case 'help': + handleHelp(stream); + break; + case 'doctor': + handleDoctor(stream); + break; + case 'list': + handleList(stream, args); + break; + case 'run': + await handleRun(request, stream, token, args); + break; + case 'agents': { + // Shorthand: @bmad /agents "" + const idx = getIndex() ?? refreshIndex(); + if (!idx) { stream.markdown('BMAD installation not found. Run `@bmad /doctor`.'); break; } + const resolved = resolveDirectKind('agent', args, idx); + if (resolved) { + await executeRun(request, stream, token, resolved); + } else { + listItems(stream, 'Agents', idx.agents, true); + } + break; + } + case 'workflows': { + // Shorthand: @bmad /workflows "" + const idx = getIndex() ?? refreshIndex(); + if (!idx) { stream.markdown('BMAD installation not found. Run `@bmad /doctor`.'); break; } + const resolved = resolveDirectKind('workflow', args, idx); + if (resolved) { + await executeRun(request, stream, token, resolved); + } else { + listItems(stream, 'Workflows', idx.workflows, false); + } + break; + } + default: { + // Try to treat as agent/workflow name directly + if (command) { + const idx = getIndex() ?? refreshIndex(); + if (idx) { + const resolved = resolveRunTarget([command, ...args], idx); + if (resolved) { + await executeRun(request, stream, token, resolved); + break; + } + } + const suggestion = suggestCommand(command); + stream.markdown(`Unknown command \`${command}\`. Did you mean \`@bmad /${suggestion}\`?\n\n`); + } + handleHelp(stream); + break; + } + } + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + logError(`Unhandled error in chat handler: ${msg}`); + stream.markdown(`Error: ${msg}\n\nTry \`@bmad /help\` or \`@bmad /doctor\`.`); + } + + return {}; +}; diff --git a/bmad-copilot/src/commandParser.ts b/bmad-copilot/src/commandParser.ts new file mode 100644 index 000000000..7868a4920 --- /dev/null +++ b/bmad-copilot/src/commandParser.ts @@ -0,0 +1,186 @@ +import { BmadIndex, BmadItem } from './bmadIndex'; + +/** + * Parse the user's remaining prompt text after the slash-command into tokens. + * + * Supports: + * - Quoted arguments: "some multi-word arg" + * - Unquoted arguments split by whitespace + */ +export function parseArgs(prompt: string): string[] { + const args: string[] = []; + let current = ''; + let inQuote = false; + let quoteChar = ''; + + for (let i = 0; i < prompt.length; i++) { + const ch = prompt[i]; + if (inQuote) { + if (ch === quoteChar) { + inQuote = false; + args.push(current); + current = ''; + } else { + current += ch; + } + } else if (ch === '"' || ch === "'") { + inQuote = true; + quoteChar = ch; + if (current.length > 0) { + args.push(current); + current = ''; + } + } else if (/\s/.test(ch)) { + if (current.length > 0) { + args.push(current); + current = ''; + } + } else { + current += ch; + } + } + if (current.length > 0) { + args.push(current); + } + return args; +} + +/** + * Parsed result of a user prompt. + */ +export interface ParsedCommand { + subCommand: string; + args: string[]; +} + +/** + * Try to parse a sub-command from raw prompt text (fallback when no VS Code command is set). + */ +export function parsePromptAsCommand(prompt: string): ParsedCommand | undefined { + const tokens = parseArgs(prompt.trim()); + if (tokens.length === 0) { return undefined; } + return { subCommand: tokens[0].toLowerCase(), args: tokens.slice(1) }; +} + +// ─── Resolved command ─────────────────────────────────── + +export type ResolvedKind = 'agent' | 'workflow'; + +export interface ResolvedRunTarget { + kind: ResolvedKind; + item: BmadItem; + task: string; +} + +/** + * Attempt to resolve a "run" command's arguments into a concrete agent or workflow. + * + * Supported patterns (all case-insensitive): + * agent "" — explicit agent + * workflow "" — explicit workflow + * a "" — shorthand agent + * w "" — shorthand workflow + * "" — auto-resolve: try agent first, then workflow + * bmm agents "" — namespaced agent + * bmm workflows "" — namespaced workflow + * core agents "" — namespaced agent + * core workflows "" — namespaced workflow + */ +export function resolveRunTarget(args: string[], index: BmadIndex): ResolvedRunTarget | undefined { + if (args.length === 0) { return undefined; } + + const first = args[0].toLowerCase(); + + // ── Explicit kind ── + if (first === 'agent' || first === 'a') { + return findItem('agent', args.slice(1), index); + } + if (first === 'workflow' || first === 'w') { + return findItem('workflow', args.slice(1), index); + } + + // ── Namespaced: bmm/core agents/workflows ── + const second = args[1]?.toLowerCase(); + if (isModuleName(first) && second) { + if (second === 'agents' || second === 'agent' || second === 'a') { + return findItem('agent', args.slice(2), index, first); + } + if (second === 'workflows' || second === 'workflow' || second === 'w') { + return findItem('workflow', args.slice(2), index, first); + } + } + + // ── Auto-resolve: try as agent name, then workflow name ── + const item = findByName(first, index); + if (item) { + return { kind: item.kind, item: item.item, task: args.slice(1).join(' ') }; + } + + return undefined; +} + +/** + * Resolve arguments for /agents "" and /workflows "" shorthand commands. + */ +export function resolveDirectKind(kind: ResolvedKind, args: string[], index: BmadIndex): ResolvedRunTarget | undefined { + return findItem(kind, args, index); +} + +// ── Helpers ── + +function isModuleName(s: string): boolean { + return /^[a-z][a-z0-9_-]*$/.test(s) && !['agent', 'workflow', 'a', 'w', 'agents', 'workflows'].includes(s); +} + +function findItem(kind: ResolvedKind, args: string[], index: BmadIndex, moduleFilter?: string): ResolvedRunTarget | undefined { + if (args.length === 0) { return undefined; } + const name = args[0].toLowerCase(); + const task = args.slice(1).join(' '); + const list = kind === 'agent' ? index.agents : index.workflows; + let item = list.find(i => i.name.toLowerCase() === name && (!moduleFilter || i.module === moduleFilter)); + if (!item) { + // Fallback: ignore module filter + item = list.find(i => i.name.toLowerCase() === name); + } + if (!item) { return undefined; } + return { kind, item, task }; +} + +function findByName(name: string, index: BmadIndex): { kind: ResolvedKind; item: BmadItem } | undefined { + const n = name.toLowerCase(); + const agent = index.agents.find(a => a.name.toLowerCase() === n); + if (agent) { return { kind: 'agent', item: agent }; } + const wf = index.workflows.find(w => w.name.toLowerCase() === n); + if (wf) { return { kind: 'workflow', item: wf }; } + return undefined; +} + +/** + * Find the closest matching item name across agents and workflows for suggestions. + */ +export function findClosestName(input: string, index: BmadIndex): string | undefined { + const all = [ + ...index.agents.map(a => a.name), + ...index.workflows.map(w => w.name), + ]; + if (all.length === 0) { return undefined; } + const lower = input.toLowerCase(); + // Prefix match first + const prefix = all.find(n => n.toLowerCase().startsWith(lower)); + if (prefix) { return prefix; } + // Substring match + const sub = all.find(n => n.toLowerCase().includes(lower)); + if (sub) { return sub; } + // Levenshtein-like: best character overlap + let best = all[0]; + let bestScore = 0; + for (const n of all) { + let score = 0; + const nl = n.toLowerCase(); + for (let i = 0; i < Math.min(lower.length, nl.length); i++) { + if (lower[i] === nl[i]) { score++; } + } + if (score > bestScore) { bestScore = score; best = n; } + } + return best; +} diff --git a/bmad-copilot/src/extension.ts b/bmad-copilot/src/extension.ts new file mode 100644 index 000000000..90e5d65b8 --- /dev/null +++ b/bmad-copilot/src/extension.ts @@ -0,0 +1,50 @@ +import * as vscode from 'vscode'; +import { initLogger, logInfo } from './logger'; +import { refreshIndex, startWatching } from './bmadIndex'; +import { chatHandler } from './chatHandler'; + +export function activate(context: vscode.ExtensionContext): void { + // ── Logger ── + initLogger(context); + logInfo('BMAD Copilot extension activating…'); + + // ── Initial index ── + const idx = refreshIndex(); + if (idx) { + logInfo(`Activated with ${idx.agents.length} agents, ${idx.workflows.length} workflows`); + } else { + logInfo('No BMAD installation detected in current workspace.'); + } + + // ── File watcher ── + startWatching(context); + + // ── Chat participant ── + const participant = vscode.chat.createChatParticipant('bmad-copilot.bmad', chatHandler); + participant.iconPath = new vscode.ThemeIcon('rocket'); + context.subscriptions.push(participant); + + // ── Copy-to-clipboard command (for fallback prompt) ── + context.subscriptions.push( + vscode.commands.registerCommand('bmad-copilot.copyToClipboard', async (text: string) => { + await vscode.env.clipboard.writeText(text); + vscode.window.showInformationMessage('BMAD prompt copied to clipboard!'); + }) + ); + + // ── Listen for config changes ── + context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('bmad')) { + logInfo('BMAD config changed — rebuilding index'); + refreshIndex(); + } + }) + ); + + logInfo('BMAD Copilot extension activated.'); +} + +export function deactivate(): void { + // Cleanup handled by context.subscriptions +} diff --git a/bmad-copilot/src/logger.ts b/bmad-copilot/src/logger.ts new file mode 100644 index 000000000..ffd36fa50 --- /dev/null +++ b/bmad-copilot/src/logger.ts @@ -0,0 +1,26 @@ +import * as vscode from 'vscode'; + +const CHANNEL_NAME = 'BMAD Copilot'; +let _channel: vscode.OutputChannel | undefined; + +export function initLogger(ctx: vscode.ExtensionContext): vscode.OutputChannel { + _channel = vscode.window.createOutputChannel(CHANNEL_NAME); + ctx.subscriptions.push(_channel); + return _channel; +} + +function ts(): string { + return new Date().toISOString().slice(11, 23); +} + +export function logInfo(msg: string): void { + _channel?.appendLine(`[${ts()}] INFO ${msg}`); +} + +export function logWarn(msg: string): void { + _channel?.appendLine(`[${ts()}] WARN ${msg}`); +} + +export function logError(msg: string): void { + _channel?.appendLine(`[${ts()}] ERROR ${msg}`); +} diff --git a/bmad-copilot/tsconfig.json b/bmad-copilot/tsconfig.json new file mode 100644 index 000000000..78791273c --- /dev/null +++ b/bmad-copilot/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "module": "Node16", + "target": "ES2022", + "outDir": "out", + "rootDir": "src", + "lib": ["ES2022"], + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "moduleResolution": "Node16" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "out"] +}