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:
parent
a8cda7c6fa
commit
9f7a4e4535
|
|
@ -0,0 +1,4 @@
|
||||||
|
node_modules/
|
||||||
|
out/
|
||||||
|
*.vsix
|
||||||
|
.vscode-test/
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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 {};
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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}`);
|
||||||
|
}
|
||||||
|
|
@ -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"]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue