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.
This commit is contained in:
Your Name 2026-02-07 17:04:27 +08:00 committed by evil0119
parent 2754d66042
commit 331d98079c
11 changed files with 1178 additions and 0 deletions

4
bmad-copilot/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
out/
*.vsix
.vscode-test/

15
bmad-copilot/.vscode/launch.json vendored Normal file
View File

@ -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"
}
]
}

18
bmad-copilot/.vscode/tasks.json vendored Normal file
View File

@ -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
}
}
]
}

140
bmad-copilot/README.md Normal file
View File

@ -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 <name> "<task>"` | Execute task with agent persona |
| `@bmad /run workflow <name> "<task>"` | Execute task in workflow |
| `@bmad /run <name> "<task>"` | Auto-resolve: tries agent first, then workflow |
| `@bmad /agents` | List agents (shorthand for /list agents) |
| `@bmad /agents <name> "<task>"` | Run agent (shorthand for /run agent) |
| `@bmad /workflows` | List workflows (shorthand for /list workflows) |
| `@bmad /workflows <name> "<task>"` | 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.

95
bmad-copilot/package.json Normal file
View File

@ -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 <name> | /run workflow <name> | /run <name>"
},
{
"name": "agents",
"description": "List agents or run one. Usage: /agents | /agents <name> \"<task>\""
},
{
"name": "workflows",
"description": "List workflows or run one. Usage: /workflows | /workflows <name> \"<task>\""
}
]
}
],
"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"
}
}

View File

@ -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<string>('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<boolean>('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<string>();
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);
}

View File

@ -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 <name> "<task>"\` | Run task with agent persona |\n`;
md += `| \`@bmad /run workflow <name> "<task>"\` | Run task in workflow |\n`;
md += `| \`@bmad /run <name> "<task>"\` | Auto-resolve agent or workflow |\n`;
md += `| \`@bmad /agents <name> "<task>"\` | Shorthand for /run agent |\n`;
md += `| \`@bmad /workflows <name> "<task>"\` | 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<string>('rootPath', '') || '(not set)';
const autoDetect = config.get<boolean>('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<void> {
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<void> {
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 <name> "<task>"\` or \`@bmad /run <name> "<task>"\``;
msg += `\n\nRun \`@bmad /list agents\` or \`@bmad /list workflows\` to see available names.`;
stream.markdown(msg);
} else {
stream.markdown('Usage: `@bmad /run agent <name> "<task>"` or `@bmad /run workflow <name> "<task>"`\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<vscode.ChatResult> => {
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 <name> "<task>"
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 <name> "<task>"
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 {};
};

View File

@ -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 <name> "<task>" explicit agent
* workflow <name> "<task>" explicit workflow
* a <name> "<task>" shorthand agent
* w <name> "<task>" shorthand workflow
* <name> "<task>" auto-resolve: try agent first, then workflow
* bmm agents <name> "<task>" namespaced agent
* bmm workflows <name> "<task>" namespaced workflow
* core agents <name> "<task>" namespaced agent
* core workflows <name> "<task>" 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 <name> ──
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 <name> "<task>" and /workflows <name> "<task>" 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;
}

View File

@ -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
}

View File

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

View File

@ -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"]
}