Compare commits

..

14 Commits

Author SHA1 Message Date
Wendy Smoak f0ad64efc4 Handle Windows line endings like manifest-generator.js:175 2026-02-15 11:54:30 -05:00
Wendy Smoak decf15b5da Add more tests for Coderabbit 2026-02-15 11:27:49 -05:00
Wendy Smoak 64e5a9c696 guard against missing project directory 2026-02-15 10:12:49 -05:00
Wendy Smoak b318d9242e fix(codex): use yaml.stringify for skill frontmatter to escape special characters
Prevents invalid YAML when agentName or skillName contains quotes or
other special characters. Aligns the fallback path in transformToSkillFormat
and installCustomAgentLauncher with the existing yaml.stringify usage on
the main code path.
2026-02-15 10:03:54 -05:00
Wendy Smoak 6db629278a Use /Users/wsmoak instead of ~ 2026-02-15 09:49:16 -05:00
Wendy Smoak 94666bd05b
Merge branch 'main' into skills-for-codex 2026-02-15 09:37:23 -05:00
Wendy Smoak dfd961944c add more tests 2026-02-15 09:17:53 -05:00
Davor Racic 2d134314c9
feat(cli): add uninstall command with selective component removal (#1650)
* feat(cli): add uninstall command with selective component removal

Add `bmad uninstall` CLI command for clean removal of BMAD installations.
Interactive mode with directory router and component multiselect; non-interactive
`--yes` flag preserves user artifacts by default. Three-phase spinner UX,
manifest-scoped IDE cleanup, GitHub Copilot marker stripping, recursive empty
directory cleanup, and chalk-to-clack migration in copilot handler.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(cli): address code review findings for uninstall command

- Add path traversal guard in uninstallOutputFolder (resolve + startsWith)
- Thread silent flag through to cleanupCopilotInstructions
- Trim text input before path.resolve in directory prompt
- DRY uninstall() by delegating to extracted helper methods
- Validate projectDir existence before probing for BMAD
- Use fs.rmdir instead of fs.remove in removeEmptyParents (race safety)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(cli): add destructive action warning and confirm before uninstall

Move warning box after component selection and add a confirmation prompt
defaulting to No, so users see the irreversibility warning right before
the point of no return. Non-interactive --yes mode skips both.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Brian <bmadcode@gmail.com>
2026-02-15 08:13:03 -06:00
Wendy Smoak d0ef58b421 Handle Windows line-endings 2026-02-15 09:06:16 -05:00
Ankit Gupta a5e2b1c63a
docs: fix changelog URL in installer start message (#1660)
Co-authored-by: Ankit Gupta <ankit.gupta@intercom.io>
2026-02-15 07:59:11 -06:00
Wendy Smoak 4bd43ec8b9
Merge branch 'main' into skills-for-codex 2026-02-15 08:05:23 -05:00
Wendy Smoak 7f81518896 Replace description regex with yaml.parse/stringify in codex.js
Use the yaml library (already a project dependency) instead of a
hand-rolled regex to parse and re-serialize frontmatter descriptions,
matching the pattern used in manifest-generator.js. Update tests to
validate round-trip correctness rather than exact quoting style.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 07:13:30 -05:00
Brian Madison 5b5cb1a396 modify post install notes example 2026-02-14 21:25:58 -06:00
Brian Madison 98c1fa8282 document project remove legacy workflow init and workflow- status function calls 2026-02-14 15:16:51 -06:00
13 changed files with 804 additions and 187 deletions

View File

@ -78,7 +78,6 @@ your-project/
├── _bmad/ # BMad configuration ├── _bmad/ # BMad configuration
├── _bmad-output/ ├── _bmad-output/
│ ├── PRD.md # Your requirements document │ ├── PRD.md # Your requirements document
│ └── bmm-workflow-status.yaml # Progress tracking
└── ... └── ...
``` ```
```` ````
@ -143,7 +142,7 @@ your-project/
### Types ### Types
| Type | Example | | Type | Example |
| ----------------- | ---------------------------- | | ----------------- | ----------------------------- |
| **Index/Landing** | `core-concepts/index.md` | | **Index/Landing** | `core-concepts/index.md` |
| **Concept** | `what-are-agents.md` | | **Concept** | `what-are-agents.md` |
| **Feature** | `quick-flow.md` | | **Feature** | `quick-flow.md` |

View File

@ -25,6 +25,7 @@
}, },
"scripts": { "scripts": {
"bmad:install": "node tools/cli/bmad-cli.js install", "bmad:install": "node tools/cli/bmad-cli.js install",
"bmad:uninstall": "node tools/cli/bmad-cli.js uninstall",
"docs:build": "node tools/build-docs.mjs", "docs:build": "node tools/build-docs.mjs",
"docs:dev": "astro dev --root website", "docs:dev": "astro dev --root website",
"docs:fix-links": "node tools/fix-doc-links.js", "docs:fix-links": "node tools/fix-doc-links.js",

View File

@ -8,55 +8,7 @@
<critical>This router determines workflow mode and delegates to specialized sub-workflows</critical> <critical>This router determines workflow mode and delegates to specialized sub-workflows</critical>
<step n="1" goal="Validate workflow and get project info"> <step n="1" goal="Check for ability to resume and determine workflow mode">
<invoke-workflow path="{project-root}/_bmad/bmm/workflows/workflow-status">
<param>mode: data</param>
<param>data_request: project_config</param>
</invoke-workflow>
<check if="status_exists == false">
<output>{{suggestion}}</output>
<output>Note: Documentation workflow can run standalone. Continuing without progress tracking.</output>
<action>Set standalone_mode = true</action>
<action>Set status_file_found = false</action>
</check>
<check if="status_exists == true">
<action>Store {{status_file_path}} for later updates</action>
<action>Set status_file_found = true</action>
<!-- Extract brownfield/greenfield from status data -->
<check if="field_type == 'greenfield'">
<output>Note: This is a greenfield project. Documentation workflow is typically for brownfield projects.</output>
<ask>Continue anyway to document planning artifacts? (y/n)</ask>
<check if="n">
<action>Exit workflow</action>
</check>
</check>
<!-- Now validate sequencing -->
<invoke-workflow path="{project-root}/_bmad/bmm/workflows/workflow-status">
<param>mode: validate</param>
<param>calling_workflow: document-project</param>
</invoke-workflow>
<check if="warning != ''">
<output>{{warning}}</output>
<output>Note: This may be auto-invoked by prd for brownfield documentation.</output>
<ask>Continue with documentation? (y/n)</ask>
<check if="n">
<output>{{suggestion}}</output>
<action>Exit workflow</action>
</check>
</check>
</check>
</step>
<step n="2" goal="Check for resumability and determine workflow mode">
<critical>SMART LOADING STRATEGY: Check state file FIRST before loading any CSV files</critical>
<action>Check for existing state file at: {project_knowledge}/project-scan-report.json</action> <action>Check for existing state file at: {project_knowledge}/project-scan-report.json</action>
<check if="project-scan-report.json exists"> <check if="project-scan-report.json exists">
@ -175,47 +127,4 @@ Your choice [1/2/3]:
</step> </step>
<step n="4" goal="Update status and complete">
<check if="status_file_found == true">
<invoke-workflow path="{project-root}/_bmad/bmm/workflows/workflow-status">
<param>mode: update</param>
<param>action: complete_workflow</param>
<param>workflow_name: document-project</param>
</invoke-workflow>
<check if="success == true">
<output>Status updated!</output>
</check>
</check>
<output>**✅ Document Project Workflow Complete, {user_name}!**
**Documentation Generated:**
- Mode: {{workflow_mode}}
- Scan Level: {{scan_level}}
- Output: {project_knowledge}/index.md and related files
{{#if status_file_found}}
**Status Updated:**
- Progress tracking updated
**Next Steps:**
- **Next required:** {{next_workflow}} ({{next_agent}} agent)
Check status anytime with: `workflow-status`
{{else}}
**Next Steps:**
Since no workflow is in progress:
- Refer to the BMM workflow guide if unsure what to do next
- Or run `workflow-init` to create a workflow path and get guided next steps
{{/if}}
</output>
</step>
</workflow> </workflow>

View File

@ -1,12 +1,14 @@
/** /**
* Tests for CodexSetup.transformToSkillFormat * Tests for CodexSetup.transformToSkillFormat
* *
* Demonstrates that the description regex mangles descriptions containing quotes. * Validates that descriptions round-trip correctly through parse/stringify,
* producing valid YAML regardless of input quoting style.
* *
* Usage: node test/test-codex-transform.js * Usage: node test/test-codex-transform.js
*/ */
const path = require('node:path'); const path = require('node:path');
const yaml = require('yaml');
// ANSI colors // ANSI colors
const colors = { const colors = {
@ -31,6 +33,17 @@ function assert(condition, testName, detail) {
} }
} }
/**
* Parse the output frontmatter and return the description value.
* Validates the output is well-formed YAML that parses back correctly.
*/
function parseOutputDescription(output) {
const match = output.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!match) return null;
const parsed = yaml.parse(match[1]);
return parsed?.description;
}
// Import the class under test // Import the class under test
const { CodexSetup } = require(path.join(__dirname, '..', 'tools', 'cli', 'installers', 'lib', 'ide', 'codex.js')); const { CodexSetup } = require(path.join(__dirname, '..', 'tools', 'cli', 'installers', 'lib', 'ide', 'codex.js'));
@ -38,39 +51,93 @@ const setup = new CodexSetup();
console.log(`\n${colors.cyan}CodexSetup.transformToSkillFormat tests${colors.reset}\n`); console.log(`\n${colors.cyan}CodexSetup.transformToSkillFormat tests${colors.reset}\n`);
// --- Passing case: simple description, no quotes --- // --- Simple description, no quotes ---
{ {
const input = `---\ndescription: A simple description\n---\n\nBody content here.`; const input = `---\ndescription: A simple description\n---\n\nBody content here.`;
const result = setup.transformToSkillFormat(input, 'my-skill'); const result = setup.transformToSkillFormat(input, 'my-skill');
const expected = `---\nname: my-skill\ndescription: 'A simple description'\n---\n\nBody content here.`; const desc = parseOutputDescription(result);
assert(result === expected, 'simple description without quotes', `got: ${JSON.stringify(result)}`); assert(desc === 'A simple description', 'simple description round-trips', `got description: ${JSON.stringify(desc)}`);
assert(result.includes('\nBody content here.'), 'body preserved for simple description');
} }
// --- Description with embedded single quotes (from double-quoted YAML input) --- // --- Description with embedded single quotes (from double-quoted YAML input) ---
{ {
const input = `---\ndescription: "can't stop won't stop"\n---\n\nBody content here.`; const input = `---\ndescription: "can't stop won't stop"\n---\n\nBody content here.`;
const result = setup.transformToSkillFormat(input, 'my-skill'); const result = setup.transformToSkillFormat(input, 'my-skill');
const desc = parseOutputDescription(result);
// Output should have properly escaped YAML single-quoted scalar: '' for each ' assert(desc === "can't stop won't stop", 'description with apostrophes round-trips', `got description: ${JSON.stringify(desc)}`);
const expected = `---\nname: my-skill\ndescription: 'can''t stop won''t stop'\n---\n\nBody content here.`; assert(result.includes('\nBody content here.'), 'body preserved for quoted description');
assert(result === expected, 'description with embedded single quotes produces valid escaped YAML', `got: ${JSON.stringify(result)}`);
} }
// --- Description with embedded single quote produces valid YAML --- // --- Description with embedded single quote ---
{ {
const input = `---\ndescription: "it's a test"\n---\n\nBody.`; const input = `---\ndescription: "it's a test"\n---\n\nBody.`;
const result = setup.transformToSkillFormat(input, 'test-skill'); const result = setup.transformToSkillFormat(input, 'test-skill');
const expected = `---\nname: test-skill\ndescription: 'it''s a test'\n---\n\nBody.`; const desc = parseOutputDescription(result);
assert(result === expected, 'description with apostrophe produces valid YAML', `got: ${JSON.stringify(result)}`); assert(desc === "it's a test", 'description with apostrophe round-trips', `got description: ${JSON.stringify(desc)}`);
} }
// --- Single-quoted input with pre-escaped apostrophe (YAML '' escape) --- // --- Single-quoted input with pre-escaped apostrophe (YAML '' escape) ---
{ {
const input = `---\ndescription: 'don''t panic'\n---\n\nBody.`; const input = `---\ndescription: 'don''t panic'\n---\n\nBody.`;
const result = setup.transformToSkillFormat(input, 'test-skill'); const result = setup.transformToSkillFormat(input, 'test-skill');
// Input has don''t (YAML-escaped). Should round-trip to don''t in output. const desc = parseOutputDescription(result);
const expected = `---\nname: test-skill\ndescription: 'don''t panic'\n---\n\nBody.`; assert(desc === "don't panic", 'single-quoted escaped apostrophe round-trips', `got description: ${JSON.stringify(desc)}`);
assert(result === expected, 'single-quoted description with escaped apostrophe round-trips correctly', `got: ${JSON.stringify(result)}`); }
// --- Verify name is set correctly ---
{
const input = `---\ndescription: test\n---\n\nBody.`;
const result = setup.transformToSkillFormat(input, 'my-custom-skill');
const match = result.match(/^---\n([\s\S]*?)\n---/);
const parsed = yaml.parse(match[1]);
assert(parsed.name === 'my-custom-skill', 'name field matches skillName argument', `got name: ${JSON.stringify(parsed.name)}`);
}
// --- Extra frontmatter keys are stripped ---
{
const input = `---\ndescription: foo\ndisable-model-invocation: true\ncustom-field: bar\n---\n\nBody.`;
const result = setup.transformToSkillFormat(input, 'strip-extra');
const desc = parseOutputDescription(result);
assert(desc === 'foo', 'description preserved when extra keys present', `got description: ${JSON.stringify(desc)}`);
const match = result.match(/^---\n([\s\S]*?)\n---/);
const parsed = yaml.parse(match[1]);
assert(parsed.name === 'strip-extra', 'name equals skillName after stripping extras', `got name: ${JSON.stringify(parsed.name)}`);
assert(!('disable-model-invocation' in parsed), 'disable-model-invocation stripped', `keys: ${Object.keys(parsed).join(', ')}`);
assert(!('custom-field' in parsed), 'custom-field stripped', `keys: ${Object.keys(parsed).join(', ')}`);
const keys = Object.keys(parsed).sort();
assert(
keys.length === 2 && keys[0] === 'description' && keys[1] === 'name',
'only name and description remain',
`keys: ${keys.join(', ')}`,
);
}
// --- No frontmatter wraps content ---
{
const input = 'Just some content without frontmatter.';
const result = setup.transformToSkillFormat(input, 'bare-skill');
const desc = parseOutputDescription(result);
assert(desc === 'bare-skill', 'no-frontmatter fallback uses skillName as description', `got description: ${JSON.stringify(desc)}`);
assert(result.includes('Just some content without frontmatter.'), 'body preserved when no frontmatter');
}
// --- No frontmatter with single-quote in skillName ---
{
const input = 'Body content for the skill.';
const result = setup.transformToSkillFormat(input, "it's-a-task");
const desc = parseOutputDescription(result);
assert(desc === "it's-a-task", 'no-frontmatter skillName with single quote round-trips', `got description: ${JSON.stringify(desc)}`);
assert(result.includes('Body content for the skill.'), 'body preserved for single-quote skillName');
}
// --- CRLF frontmatter is parsed correctly (Windows line endings) ---
{
const input = '---\r\ndescription: windows line endings\r\n---\r\n\r\nBody.';
const result = setup.transformToSkillFormat(input, 'crlf-skill');
const desc = parseOutputDescription(result);
assert(desc === 'windows line endings', 'CRLF frontmatter parses correctly', `got description: ${JSON.stringify(desc)}`);
assert(result.includes('Body.'), 'body preserved for CRLF input');
} }
// --- Summary --- // --- Summary ---

View File

@ -0,0 +1,216 @@
/**
* Tests for CodexSetup.writeSkillArtifacts
*
* Validates directory creation, SKILL.md file writing, type filtering,
* and integration with transformToSkillFormat.
*
* Usage: node test/test-codex-write-skills.js
*/
const path = require('node:path');
const fs = require('fs-extra');
const os = require('node:os');
const yaml = require('yaml');
// ANSI colors
const colors = {
reset: '\u001B[0m',
green: '\u001B[32m',
red: '\u001B[31m',
cyan: '\u001B[36m',
dim: '\u001B[2m',
};
let passed = 0;
let failed = 0;
function assert(condition, testName, detail) {
if (condition) {
console.log(` ${colors.green}PASS${colors.reset} ${testName}`);
passed++;
} else {
console.log(` ${colors.red}FAIL${colors.reset} ${testName}`);
if (detail) console.log(` ${colors.dim}${detail}${colors.reset}`);
failed++;
}
}
// Import the class under test
const { CodexSetup } = require(path.join(__dirname, '..', 'tools', 'cli', 'installers', 'lib', 'ide', 'codex.js'));
const setup = new CodexSetup();
// Create a temp directory for each test run
let tmpDir;
async function createTmpDir() {
tmpDir = path.join(os.tmpdir(), `bmad-test-skills-${Date.now()}`);
await fs.ensureDir(tmpDir);
return tmpDir;
}
async function cleanTmpDir() {
if (tmpDir) {
await fs.remove(tmpDir);
}
}
async function runTests() {
console.log(`\n${colors.cyan}CodexSetup.writeSkillArtifacts tests${colors.reset}\n`);
// --- Writes a single artifact as a skill directory with SKILL.md ---
{
const destDir = await createTmpDir();
const artifacts = [
{
type: 'task',
relativePath: 'bmm/tasks/create-story.md',
content: '---\ndescription: Create a user story\n---\n\nStory creation instructions.',
},
];
const count = await setup.writeSkillArtifacts(destDir, artifacts, 'task');
assert(count === 1, 'single artifact returns count 1');
const skillDir = path.join(destDir, 'bmad-bmm-create-story');
assert(await fs.pathExists(skillDir), 'skill directory created');
const skillFile = path.join(skillDir, 'SKILL.md');
assert(await fs.pathExists(skillFile), 'SKILL.md file created');
const content = await fs.readFile(skillFile, 'utf8');
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
assert(fmMatch !== null, 'SKILL.md has frontmatter');
const parsed = yaml.parse(fmMatch[1]);
assert(parsed.name === 'bmad-bmm-create-story', 'name matches skill directory name', `got: ${parsed.name}`);
assert(parsed.description === 'Create a user story', 'description preserved', `got: ${parsed.description}`);
assert(content.includes('Story creation instructions.'), 'body content preserved');
await cleanTmpDir();
}
// --- Filters artifacts by type ---
{
const destDir = await createTmpDir();
const artifacts = [
{
type: 'task',
relativePath: 'bmm/tasks/create-story.md',
content: '---\ndescription: A task\n---\n\nTask body.',
},
{
type: 'workflow-command',
relativePath: 'bmm/workflows/plan-project.md',
content: '---\ndescription: A workflow\n---\n\nWorkflow body.',
},
{
type: 'agent-launcher',
relativePath: 'bmm/agents/pm.md',
content: '---\ndescription: An agent\n---\n\nAgent body.',
},
];
const count = await setup.writeSkillArtifacts(destDir, artifacts, 'task');
assert(count === 1, 'only matching type is written when filtering for task');
const entries = await fs.readdir(destDir);
assert(entries.length === 1, 'only one skill directory created', `got ${entries.length}: ${entries.join(', ')}`);
assert(entries[0] === 'bmad-bmm-create-story', 'correct artifact was written', `got: ${entries[0]}`);
await cleanTmpDir();
}
// --- Writes multiple artifacts of the same type ---
{
const destDir = await createTmpDir();
const artifacts = [
{
type: 'workflow-command',
relativePath: 'bmm/workflows/plan-project.md',
content: '---\ndescription: Plan\n---\n\nPlan body.',
},
{
type: 'workflow-command',
relativePath: 'core/workflows/review.md',
content: '---\ndescription: Review\n---\n\nReview body.',
},
];
const count = await setup.writeSkillArtifacts(destDir, artifacts, 'workflow-command');
assert(count === 2, 'two artifacts written');
const entries = new Set((await fs.readdir(destDir)).sort());
assert(entries.has('bmad-bmm-plan-project'), 'first skill directory exists');
assert(entries.has('bmad-review'), 'second skill directory exists (core module)');
await cleanTmpDir();
}
// --- Returns 0 when no artifacts match type ---
{
const destDir = await createTmpDir();
const artifacts = [
{
type: 'agent-launcher',
relativePath: 'bmm/agents/pm.md',
content: '---\ndescription: An agent\n---\n\nBody.',
},
];
const count = await setup.writeSkillArtifacts(destDir, artifacts, 'task');
assert(count === 0, 'returns 0 when no types match');
const entries = await fs.readdir(destDir);
assert(entries.length === 0, 'no directories created when no types match');
await cleanTmpDir();
}
// --- Handles empty artifacts array ---
{
const destDir = await createTmpDir();
const count = await setup.writeSkillArtifacts(destDir, [], 'task');
assert(count === 0, 'returns 0 for empty artifacts array');
await cleanTmpDir();
}
// --- Artifacts without type field are always written ---
{
const destDir = await createTmpDir();
const artifacts = [
{
relativePath: 'bmm/tasks/no-type.md',
content: '---\ndescription: No type field\n---\n\nBody.',
},
];
const count = await setup.writeSkillArtifacts(destDir, artifacts, 'task');
assert(count === 1, 'artifact without type field is written (no filtering)');
await cleanTmpDir();
}
// --- Content without frontmatter gets minimal frontmatter added ---
{
const destDir = await createTmpDir();
const artifacts = [
{
type: 'task',
relativePath: 'bmm/tasks/bare.md',
content: 'Just plain content, no frontmatter.',
},
];
const count = await setup.writeSkillArtifacts(destDir, artifacts, 'task');
assert(count === 1, 'bare content artifact written');
const skillFile = path.join(destDir, 'bmad-bmm-bare', 'SKILL.md');
const content = await fs.readFile(skillFile, 'utf8');
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
assert(fmMatch !== null, 'frontmatter added to bare content');
const parsed = yaml.parse(fmMatch[1]);
assert(parsed.name === 'bmad-bmm-bare', 'name set for bare content', `got: ${parsed.name}`);
assert(content.includes('Just plain content, no frontmatter.'), 'original content preserved');
await cleanTmpDir();
}
// --- Summary ---
console.log(`\n${passed} passed, ${failed} failed\n`);
process.exit(failed > 0 ? 1 : 0);
}
runTests().catch((error) => {
console.error('Test runner error:', error);
process.exit(1);
});

View File

@ -16,8 +16,8 @@ Always displayed after the module is configured:
```yaml ```yaml
post-install-notes: | post-install-notes: |
Remember to set the API_KEY environment variable. Thank you for choosing the XYZ Cool Module
See: https://example.com/setup For Support about this Module call 555-1212
``` ```
### Conditional Format ### Conditional Format

View File

@ -0,0 +1,167 @@
const path = require('node:path');
const fs = require('fs-extra');
const prompts = require('../lib/prompts');
const { Installer } = require('../installers/lib/core/installer');
const installer = new Installer();
module.exports = {
command: 'uninstall',
description: 'Remove BMAD installation from the current project',
options: [
['-y, --yes', 'Remove all BMAD components without prompting (preserves user artifacts)'],
['--directory <path>', 'Project directory (default: current directory)'],
],
action: async (options) => {
try {
let projectDir;
if (options.directory) {
// Explicit --directory flag takes precedence
projectDir = path.resolve(options.directory);
} else if (options.yes) {
// Non-interactive mode: use current directory
projectDir = process.cwd();
} else {
// Interactive: ask user which directory to uninstall from
// select() handles cancellation internally (exits process)
const dirChoice = await prompts.select({
message: 'Where do you want to uninstall BMAD from?',
choices: [
{ value: 'cwd', name: `Current directory (${process.cwd()})` },
{ value: 'other', name: 'Another directory...' },
],
});
if (dirChoice === 'other') {
// text() handles cancellation internally (exits process)
const customDir = await prompts.text({
message: 'Enter the project directory path:',
placeholder: process.cwd(),
validate: (value) => {
if (!value || value.trim().length === 0) return 'Directory path is required';
},
});
projectDir = path.resolve(customDir.trim());
} else {
projectDir = process.cwd();
}
}
if (!(await fs.pathExists(projectDir))) {
await prompts.log.error(`Directory does not exist: ${projectDir}`);
process.exit(1);
}
const { bmadDir } = await installer.findBmadDir(projectDir);
if (!(await fs.pathExists(bmadDir))) {
await prompts.log.warn('No BMAD installation found.');
process.exit(0);
}
const existingInstall = await installer.getStatus(projectDir);
const version = existingInstall.version || 'unknown';
const modules = (existingInstall.modules || []).map((m) => m.id || m.name).join(', ');
const ides = (existingInstall.ides || []).join(', ');
const outputFolder = await installer.getOutputFolder(projectDir);
await prompts.intro('BMAD Uninstall');
await prompts.note(`Version: ${version}\nModules: ${modules}\nIDE integrations: ${ides}`, 'Current Installation');
let removeModules = true;
let removeIdeConfigs = true;
let removeOutputFolder = false;
if (!options.yes) {
// multiselect() handles cancellation internally (exits process)
const selected = await prompts.multiselect({
message: 'Select components to remove:',
options: [
{
value: 'modules',
label: `BMAD Modules & data (${installer.bmadFolderName}/)`,
hint: 'Core installation, agents, workflows, config',
},
{ value: 'ide', label: 'IDE integrations', hint: ides || 'No IDEs configured' },
{ value: 'output', label: `User artifacts (${outputFolder}/)`, hint: 'WARNING: Contains your work products' },
],
initialValues: ['modules', 'ide'],
required: true,
});
removeModules = selected.includes('modules');
removeIdeConfigs = selected.includes('ide');
removeOutputFolder = selected.includes('output');
const red = (s) => `\u001B[31m${s}\u001B[0m`;
await prompts.note(
red('💀 This action is IRREVERSIBLE! Removed files cannot be recovered!') +
'\n' +
red('💀 IDE configurations and modules will need to be reinstalled.') +
'\n' +
red('💀 User artifacts are preserved unless explicitly selected.'),
'!! DESTRUCTIVE ACTION !!',
);
const confirmed = await prompts.confirm({
message: 'Proceed with uninstall?',
default: false,
});
if (!confirmed) {
await prompts.outro('Uninstall cancelled.');
process.exit(0);
}
}
// Phase 1: IDE integrations
if (removeIdeConfigs) {
const s = await prompts.spinner();
s.start('Removing IDE integrations...');
await installer.uninstallIdeConfigs(projectDir, existingInstall, { silent: true });
s.stop(`Removed IDE integrations (${ides || 'none'})`);
}
// Phase 2: User artifacts
if (removeOutputFolder) {
const s = await prompts.spinner();
s.start(`Removing user artifacts (${outputFolder}/)...`);
await installer.uninstallOutputFolder(projectDir, outputFolder);
s.stop('User artifacts removed');
}
// Phase 3: BMAD modules & data (last — other phases may need _bmad/)
if (removeModules) {
const s = await prompts.spinner();
s.start(`Removing BMAD modules & data (${installer.bmadFolderName}/)...`);
await installer.uninstallModules(projectDir);
s.stop('Modules & data removed');
}
const summary = [];
if (removeIdeConfigs) summary.push('IDE integrations cleaned');
if (removeModules) summary.push('Modules & data removed');
if (removeOutputFolder) summary.push('User artifacts removed');
if (!removeOutputFolder) summary.push(`User artifacts preserved in ${outputFolder}/`);
await prompts.note(summary.join('\n'), 'Summary');
await prompts.outro('To reinstall, run: npx bmad-method install');
process.exit(0);
} catch (error) {
try {
const errorMessage = error instanceof Error ? error.message : String(error);
await prompts.log.error(`Uninstall failed: ${errorMessage}`);
if (error instanceof Error && error.stack) {
await prompts.log.message(error.stack);
}
} catch {
console.error(error instanceof Error ? error.message : error);
}
process.exit(1);
}
},
};

View File

@ -34,7 +34,7 @@ startMessage: |
- Subscribe on YouTube: https://www.youtube.com/@BMadCode - Subscribe on YouTube: https://www.youtube.com/@BMadCode
- Every star & sub helps us reach more developers! - Every star & sub helps us reach more developers!
Latest updates: https://github.com/bmad-code-org/BMAD-METHOD/CHANGELOG.md Latest updates: https://github.com/bmad-code-org/BMAD-METHOD/blob/main/CHANGELOG.md
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

View File

@ -1528,20 +1528,157 @@ class Installer {
} }
/** /**
* Uninstall BMAD * Uninstall BMAD with selective removal options
* @param {string} directory - Project directory
* @param {Object} options - Uninstall options
* @param {boolean} [options.removeModules=true] - Remove _bmad/ directory
* @param {boolean} [options.removeIdeConfigs=true] - Remove IDE configurations
* @param {boolean} [options.removeOutputFolder=false] - Remove user artifacts output folder
* @returns {Object} Result with success status and removed components
*/ */
async uninstall(directory) { async uninstall(directory, options = {}) {
const projectDir = path.resolve(directory); const projectDir = path.resolve(directory);
const { bmadDir } = await this.findBmadDir(projectDir); const { bmadDir } = await this.findBmadDir(projectDir);
if (await fs.pathExists(bmadDir)) { if (!(await fs.pathExists(bmadDir))) {
await fs.remove(bmadDir); return { success: false, reason: 'not-installed' };
} }
// Clean up IDE configurations // 1. DETECT: Read state BEFORE deleting anything
await this.ideManager.cleanup(projectDir); const existingInstall = await this.detector.detect(bmadDir);
const outputFolder = await this._readOutputFolder(bmadDir);
return { success: true }; const removed = { modules: false, ideConfigs: false, outputFolder: false };
// 2. IDE CLEANUP (before _bmad/ deletion so configs are accessible)
if (options.removeIdeConfigs !== false) {
await this.uninstallIdeConfigs(projectDir, existingInstall, { silent: options.silent });
removed.ideConfigs = true;
}
// 3. OUTPUT FOLDER (only if explicitly requested)
if (options.removeOutputFolder === true && outputFolder) {
removed.outputFolder = await this.uninstallOutputFolder(projectDir, outputFolder);
}
// 4. BMAD DIRECTORY (last, after everything that needs it)
if (options.removeModules !== false) {
removed.modules = await this.uninstallModules(projectDir);
}
return { success: true, removed, version: existingInstall.version };
}
/**
* Uninstall IDE configurations only
* @param {string} projectDir - Project directory
* @param {Object} existingInstall - Detection result from detector.detect()
* @param {Object} [options] - Options (e.g. { silent: true })
* @returns {Promise<Object>} Results from IDE cleanup
*/
async uninstallIdeConfigs(projectDir, existingInstall, options = {}) {
await this.ideManager.ensureInitialized();
const cleanupOptions = { isUninstall: true, silent: options.silent };
const ideList = existingInstall.ides || [];
if (ideList.length > 0) {
return this.ideManager.cleanupByList(projectDir, ideList, cleanupOptions);
}
return this.ideManager.cleanup(projectDir, cleanupOptions);
}
/**
* Remove user artifacts output folder
* @param {string} projectDir - Project directory
* @param {string} outputFolder - Output folder name (relative)
* @returns {Promise<boolean>} Whether the folder was removed
*/
async uninstallOutputFolder(projectDir, outputFolder) {
if (!outputFolder) return false;
const resolvedProject = path.resolve(projectDir);
const outputPath = path.resolve(resolvedProject, outputFolder);
if (!outputPath.startsWith(resolvedProject + path.sep)) {
return false;
}
if (await fs.pathExists(outputPath)) {
await fs.remove(outputPath);
return true;
}
return false;
}
/**
* Remove the _bmad/ directory
* @param {string} projectDir - Project directory
* @returns {Promise<boolean>} Whether the directory was removed
*/
async uninstallModules(projectDir) {
const { bmadDir } = await this.findBmadDir(projectDir);
if (await fs.pathExists(bmadDir)) {
await fs.remove(bmadDir);
return true;
}
return false;
}
/**
* Get the configured output folder name for a project
* Resolves bmadDir internally from projectDir
* @param {string} projectDir - Project directory
* @returns {string} Output folder name (relative, default: '_bmad-output')
*/
async getOutputFolder(projectDir) {
const { bmadDir } = await this.findBmadDir(projectDir);
return this._readOutputFolder(bmadDir);
}
/**
* Read the output_folder setting from module config files
* Checks bmm/config.yaml first, then other module configs
* @param {string} bmadDir - BMAD installation directory
* @returns {string} Output folder path or default
*/
async _readOutputFolder(bmadDir) {
const yaml = require('yaml');
// Check bmm/config.yaml first (most common)
const bmmConfigPath = path.join(bmadDir, 'bmm', 'config.yaml');
if (await fs.pathExists(bmmConfigPath)) {
try {
const content = await fs.readFile(bmmConfigPath, 'utf8');
const config = yaml.parse(content);
if (config && config.output_folder) {
// Strip {project-root}/ prefix if present
return config.output_folder.replace(/^\{project-root\}[/\\]/, '');
}
} catch {
// Fall through to other modules
}
}
// Scan other module config.yaml files
try {
const entries = await fs.readdir(bmadDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory() || entry.name === 'bmm' || entry.name.startsWith('_')) continue;
const configPath = path.join(bmadDir, entry.name, 'config.yaml');
if (await fs.pathExists(configPath)) {
try {
const content = await fs.readFile(configPath, 'utf8');
const config = yaml.parse(content);
if (config && config.output_folder) {
return config.output_folder.replace(/^\{project-root\}[/\\]/, '');
}
} catch {
// Continue scanning
}
}
}
} catch {
// Directory scan failed
}
// Default fallback
return '_bmad-output';
} }
/** /**

View File

@ -456,8 +456,18 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}}
async cleanup(projectDir, options = {}) { async cleanup(projectDir, options = {}) {
// Clean all target directories // Clean all target directories
if (this.installerConfig?.targets) { if (this.installerConfig?.targets) {
const parentDirs = new Set();
for (const target of this.installerConfig.targets) { for (const target of this.installerConfig.targets) {
await this.cleanupTarget(projectDir, target.target_dir, options); await this.cleanupTarget(projectDir, target.target_dir, options);
// Track parent directories for empty-dir cleanup
const parentDir = path.dirname(target.target_dir);
if (parentDir && parentDir !== '.') {
parentDirs.add(parentDir);
}
}
// After all targets cleaned, remove empty parent directories (recursive up to projectDir)
for (const parentDir of parentDirs) {
await this.removeEmptyParents(projectDir, parentDir);
} }
} else if (this.installerConfig?.target_dir) { } else if (this.installerConfig?.target_dir) {
await this.cleanupTarget(projectDir, this.installerConfig.target_dir, options); await this.cleanupTarget(projectDir, this.installerConfig.target_dir, options);
@ -509,6 +519,41 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}}
if (removedCount > 0 && !options.silent) { if (removedCount > 0 && !options.silent) {
await prompts.log.message(` Cleaned ${removedCount} BMAD files from ${targetDir}`); await prompts.log.message(` Cleaned ${removedCount} BMAD files from ${targetDir}`);
} }
// Remove empty directory after cleanup
if (removedCount > 0) {
try {
const remaining = await fs.readdir(targetPath);
if (remaining.length === 0) {
await fs.remove(targetPath);
}
} catch {
// Directory may already be gone or in use — skip
}
}
}
/**
* Recursively remove empty directories walking up from dir toward projectDir
* Stops at projectDir boundary never removes projectDir itself
* @param {string} projectDir - Project root (boundary)
* @param {string} relativeDir - Relative directory to start from
*/
async removeEmptyParents(projectDir, relativeDir) {
let current = relativeDir;
let last = null;
while (current && current !== '.' && current !== last) {
last = current;
const fullPath = path.join(projectDir, current);
try {
if (!(await fs.pathExists(fullPath))) break;
const remaining = await fs.readdir(fullPath);
if (remaining.length > 0) break;
await fs.rmdir(fullPath);
} catch {
break;
}
current = path.dirname(current);
}
} }
} }

View File

@ -7,6 +7,7 @@ const { AgentCommandGenerator } = require('./shared/agent-command-generator');
const { TaskToolCommandGenerator } = require('./shared/task-tool-command-generator'); const { TaskToolCommandGenerator } = require('./shared/task-tool-command-generator');
const { getTasksFromBmad } = require('./shared/bmad-artifacts'); const { getTasksFromBmad } = require('./shared/bmad-artifacts');
const { toDashPath, customAgentDashName } = require('./shared/path-utils'); const { toDashPath, customAgentDashName } = require('./shared/path-utils');
const yaml = require('yaml');
const prompts = require('../../../lib/prompts'); const prompts = require('../../../lib/prompts');
/** /**
@ -42,7 +43,7 @@ class CodexSetup extends BaseIdeSetup {
value: 'project', value: 'project',
}, },
{ {
name: 'Global - (~/.agents/skills)', name: 'Global - ($HOME/.agents/skills)',
value: 'global', value: 'global',
}, },
], ],
@ -53,7 +54,7 @@ class CodexSetup extends BaseIdeSetup {
if (installLocation === 'project') { if (installLocation === 'project') {
await prompts.log.info('Skills installed to: <project>/.agents/skills'); await prompts.log.info('Skills installed to: <project>/.agents/skills');
} else { } else {
await prompts.log.info('Skills installed to: ~/.agents/skills'); await prompts.log.info('Skills installed to: $HOME/.agents/skills');
} }
// Confirm the choice // Confirm the choice
@ -196,37 +197,28 @@ class CodexSetup extends BaseIdeSetup {
*/ */
transformToSkillFormat(content, skillName) { transformToSkillFormat(content, skillName) {
// Parse frontmatter // Parse frontmatter
const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/); const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
if (!fmMatch) { if (!fmMatch) {
// No frontmatter -- wrap with minimal frontmatter // No frontmatter -- wrap with minimal frontmatter
return `---\nname: ${skillName}\ndescription: '${skillName}'\n---\n\n${content}`; const fm = yaml.stringify({ name: skillName, description: skillName }).trimEnd();
return `---\n${fm}\n---\n\n${content}`;
} }
const frontmatter = fmMatch[1]; const frontmatter = fmMatch[1];
const body = fmMatch[2]; const body = fmMatch[2];
// Extract description from existing frontmatter, handling quoted and unquoted values // Parse frontmatter with yaml library to handle all quoting variants
const descMatch = frontmatter.match(/^description:\s*(?:'((?:[^']|'')*)'|"((?:[^"\\]|\\.)*)"|(.*))\s*$/m);
let description; let description;
if (descMatch) { try {
if (descMatch[1] != null) { const parsed = yaml.parse(frontmatter);
// Single-quoted YAML: unescape '' to ' description = parsed?.description || `${skillName} skill`;
description = descMatch[1].replaceAll("''", "'"); } catch {
} else if (descMatch[2] == null) {
description = descMatch[3];
} else {
// Double-quoted YAML: unescape \" to "
description = descMatch[2].replaceAll(String.raw`\"`, '"');
}
} else {
description = `${skillName} skill`; description = `${skillName} skill`;
} }
// Escape single quotes for YAML single-quoted scalar (a literal ' becomes '') // Build new frontmatter with only skills-spec fields, let yaml handle quoting
const safeDescription = description.replaceAll("'", "''"); const newFrontmatter = yaml.stringify({ name: skillName, description }).trimEnd();
return `---\n${newFrontmatter}\n---\n${body}`;
// Build new frontmatter with only skills-spec fields
return `---\nname: ${skillName}\ndescription: '${safeDescription}'\n---\n${body}`;
} }
/** /**
@ -329,6 +321,9 @@ class CodexSetup extends BaseIdeSetup {
if (location === 'project' && projectDir) { if (location === 'project' && projectDir) {
return path.join(projectDir, '.agents', 'skills'); return path.join(projectDir, '.agents', 'skills');
} }
if (location === 'project' && !projectDir) {
throw new Error('projectDir is required for project-scoped skill installation');
}
return path.join(os.homedir(), '.agents', 'skills'); return path.join(os.homedir(), '.agents', 'skills');
} }
@ -374,7 +369,8 @@ class CodexSetup extends BaseIdeSetup {
} }
async readAndProcessWithProject(filePath, metadata, projectDir) { async readAndProcessWithProject(filePath, metadata, projectDir) {
const content = await fs.readFile(filePath, 'utf8'); const rawContent = await fs.readFile(filePath, 'utf8');
const content = rawContent.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
return super.processContent(content, metadata, projectDir); return super.processContent(content, metadata, projectDir);
} }
@ -386,7 +382,7 @@ class CodexSetup extends BaseIdeSetup {
const lines = [ const lines = [
'IMPORTANT: Codex Configuration', 'IMPORTANT: Codex Configuration',
'', '',
'Skills installed globally to your HOME DIRECTORY (~/.agents/skills).', 'Skills installed globally to your HOME DIRECTORY ($HOME/.agents/skills).',
'', '',
'These skills reference a specific _bmad path.', 'These skills reference a specific _bmad path.',
"To use with other projects, you'd need to copy the _bmad dir.", "To use with other projects, you'd need to copy the _bmad dir.",
@ -447,9 +443,9 @@ class CodexSetup extends BaseIdeSetup {
const skillDir = path.join(destDir, skillName); const skillDir = path.join(destDir, skillName);
await fs.ensureDir(skillDir); await fs.ensureDir(skillDir);
const fm = yaml.stringify({ name: skillName, description: `${agentName} agent` }).trimEnd();
const skillContent = `--- const skillContent = `---
name: ${skillName} ${fm}
description: '${agentName} agent'
--- ---
You must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command. You must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command.

View File

@ -1,6 +1,6 @@
const path = require('node:path'); const path = require('node:path');
const { BaseIdeSetup } = require('./_base-ide'); const { BaseIdeSetup } = require('./_base-ide');
const chalk = require('chalk'); const prompts = require('../../../lib/prompts');
const { AgentCommandGenerator } = require('./shared/agent-command-generator'); const { AgentCommandGenerator } = require('./shared/agent-command-generator');
const { BMAD_FOLDER_NAME, toDashPath } = require('./shared/path-utils'); const { BMAD_FOLDER_NAME, toDashPath } = require('./shared/path-utils');
const fs = require('fs-extra'); const fs = require('fs-extra');
@ -31,7 +31,7 @@ class GitHubCopilotSetup extends BaseIdeSetup {
* @param {Object} options - Setup options * @param {Object} options - Setup options
*/ */
async setup(projectDir, bmadDir, options = {}) { async setup(projectDir, bmadDir, options = {}) {
console.log(chalk.cyan(`Setting up ${this.name}...`)); if (!options.silent) await prompts.log.info(`Setting up ${this.name}...`);
// Create .github/agents and .github/prompts directories // Create .github/agents and .github/prompts directories
const githubDir = path.join(projectDir, this.githubDir); const githubDir = path.join(projectDir, this.githubDir);
@ -66,21 +66,15 @@ class GitHubCopilotSetup extends BaseIdeSetup {
const targetPath = path.join(agentsDir, fileName); const targetPath = path.join(agentsDir, fileName);
await this.writeFile(targetPath, agentContent); await this.writeFile(targetPath, agentContent);
agentCount++; agentCount++;
console.log(chalk.green(` ✓ Created agent: ${fileName}`));
} }
// Generate prompt files from bmad-help.csv // Generate prompt files from bmad-help.csv
const promptCount = await this.generatePromptFiles(projectDir, bmadDir, agentArtifacts, agentManifest); const promptCount = await this.generatePromptFiles(projectDir, bmadDir, agentArtifacts, agentManifest);
// Generate copilot-instructions.md // Generate copilot-instructions.md
await this.generateCopilotInstructions(projectDir, bmadDir, agentManifest); await this.generateCopilotInstructions(projectDir, bmadDir, agentManifest, options);
console.log(chalk.green(`\n${this.name} configured:`)); if (!options.silent) await prompts.log.success(`${this.name} configured: ${agentCount} agents, ${promptCount} prompts → .github/`);
console.log(chalk.dim(` - ${agentCount} agents created in .github/agents/`));
console.log(chalk.dim(` - ${promptCount} prompts created in .github/prompts/`));
console.log(chalk.dim(` - copilot-instructions.md generated`));
console.log(chalk.dim(` - Destination: .github/`));
return { return {
success: true, success: true,
@ -406,7 +400,7 @@ tools: ${toolsStr}
* @param {string} bmadDir - BMAD installation directory * @param {string} bmadDir - BMAD installation directory
* @param {Map} agentManifest - Agent manifest data * @param {Map} agentManifest - Agent manifest data
*/ */
async generateCopilotInstructions(projectDir, bmadDir, agentManifest) { async generateCopilotInstructions(projectDir, bmadDir, agentManifest, options = {}) {
const configVars = await this.loadModuleConfig(bmadDir); const configVars = await this.loadModuleConfig(bmadDir);
// Build the agents table from the manifest // Build the agents table from the manifest
@ -495,19 +489,16 @@ Type \`/bmad-\` in Copilot Chat to see all available BMAD workflows and agent ac
const after = existing.slice(endIdx + markerEnd.length); const after = existing.slice(endIdx + markerEnd.length);
const merged = `${before}${markedContent}${after}`; const merged = `${before}${markedContent}${after}`;
await this.writeFile(instructionsPath, merged); await this.writeFile(instructionsPath, merged);
console.log(chalk.green(' ✓ Updated BMAD section in copilot-instructions.md'));
} else { } else {
// Existing file without markers — back it up before overwriting // Existing file without markers — back it up before overwriting
const backupPath = `${instructionsPath}.bak`; const backupPath = `${instructionsPath}.bak`;
await fs.copy(instructionsPath, backupPath); await fs.copy(instructionsPath, backupPath);
console.log(chalk.yellow(` ⚠ Backed up existing copilot-instructions.md → copilot-instructions.md.bak`)); if (!options.silent) await prompts.log.warn(` Backed up copilot-instructions.md → .bak`);
await this.writeFile(instructionsPath, `${markedContent}\n`); await this.writeFile(instructionsPath, `${markedContent}\n`);
console.log(chalk.green(' ✓ Generated copilot-instructions.md (with BMAD markers)'));
} }
} else { } else {
// No existing file — create fresh with markers // No existing file — create fresh with markers
await this.writeFile(instructionsPath, `${markedContent}\n`); await this.writeFile(instructionsPath, `${markedContent}\n`);
console.log(chalk.green(' ✓ Generated copilot-instructions.md'));
} }
} }
@ -607,7 +598,7 @@ Type \`/bmad-\` in Copilot Chat to see all available BMAD workflows and agent ac
/** /**
* Cleanup GitHub Copilot configuration - surgically remove only BMAD files * Cleanup GitHub Copilot configuration - surgically remove only BMAD files
*/ */
async cleanup(projectDir) { async cleanup(projectDir, options = {}) {
// Clean up agents directory // Clean up agents directory
const agentsDir = path.join(projectDir, this.githubDir, this.agentsDir); const agentsDir = path.join(projectDir, this.githubDir, this.agentsDir);
if (await fs.pathExists(agentsDir)) { if (await fs.pathExists(agentsDir)) {
@ -621,8 +612,8 @@ Type \`/bmad-\` in Copilot Chat to see all available BMAD workflows and agent ac
} }
} }
if (removed > 0) { if (removed > 0 && !options.silent) {
console.log(chalk.dim(` Cleaned up ${removed} existing BMAD agents`)); await prompts.log.message(` Cleaned up ${removed} existing BMAD agents`);
} }
} }
@ -639,16 +630,70 @@ Type \`/bmad-\` in Copilot Chat to see all available BMAD workflows and agent ac
} }
} }
if (removed > 0) { if (removed > 0 && !options.silent) {
console.log(chalk.dim(` Cleaned up ${removed} existing BMAD prompts`)); await prompts.log.message(` Cleaned up ${removed} existing BMAD prompts`);
} }
} }
// Note: copilot-instructions.md is NOT cleaned up here. // During uninstall, also strip BMAD markers from copilot-instructions.md.
// generateCopilotInstructions() handles marker-based replacement in a single // During reinstall (default), this is skipped because generateCopilotInstructions()
// read-modify-write pass, which correctly preserves user content outside the markers. // handles marker-based replacement in a single read-modify-write pass,
// Stripping markers here would cause generation to treat the file as legacy (no markers) // which correctly preserves user content outside the markers.
// and overwrite user content. if (options.isUninstall) {
await this.cleanupCopilotInstructions(projectDir, options);
}
}
/**
* Strip BMAD marker section from copilot-instructions.md
* If file becomes empty after stripping, delete it.
* If a .bak backup exists and the main file was deleted, restore the backup.
* @param {string} projectDir - Project directory
* @param {Object} [options] - Options (e.g. { silent: true })
*/
async cleanupCopilotInstructions(projectDir, options = {}) {
const instructionsPath = path.join(projectDir, this.githubDir, 'copilot-instructions.md');
const backupPath = `${instructionsPath}.bak`;
if (!(await fs.pathExists(instructionsPath))) {
return;
}
const content = await fs.readFile(instructionsPath, 'utf8');
const markerStart = '<!-- BMAD:START -->';
const markerEnd = '<!-- BMAD:END -->';
const startIdx = content.indexOf(markerStart);
const endIdx = content.indexOf(markerEnd);
if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) {
return; // No valid markers found
}
// Strip the marker section (including markers)
const before = content.slice(0, startIdx);
const after = content.slice(endIdx + markerEnd.length);
const cleaned = before + after;
if (cleaned.trim().length === 0) {
// File is empty after stripping — delete it
await fs.remove(instructionsPath);
// If backup exists, restore it
if (await fs.pathExists(backupPath)) {
await fs.rename(backupPath, instructionsPath);
if (!options.silent) {
await prompts.log.message(' Restored copilot-instructions.md from backup');
}
}
} else {
// Write cleaned content back (preserve original whitespace)
await fs.writeFile(instructionsPath, cleaned, 'utf8');
// If backup exists, it's stale now — remove it
if (await fs.pathExists(backupPath)) {
await fs.remove(backupPath);
}
}
} }
} }

View File

@ -216,13 +216,14 @@ class IdeManager {
/** /**
* Cleanup IDE configurations * Cleanup IDE configurations
* @param {string} projectDir - Project directory * @param {string} projectDir - Project directory
* @param {Object} [options] - Cleanup options passed through to handlers
*/ */
async cleanup(projectDir) { async cleanup(projectDir, options = {}) {
const results = []; const results = [];
for (const [name, handler] of this.handlers) { for (const [name, handler] of this.handlers) {
try { try {
await handler.cleanup(projectDir); await handler.cleanup(projectDir, options);
results.push({ ide: name, success: true }); results.push({ ide: name, success: true });
} catch (error) { } catch (error) {
results.push({ ide: name, success: false, error: error.message }); results.push({ ide: name, success: false, error: error.message });
@ -232,6 +233,40 @@ class IdeManager {
return results; return results;
} }
/**
* Cleanup only the IDEs in the provided list
* Falls back to cleanup() (all handlers) if ideList is empty or undefined
* @param {string} projectDir - Project directory
* @param {Array<string>} ideList - List of IDE names to clean up
* @param {Object} [options] - Cleanup options passed through to handlers
* @returns {Array} Results array
*/
async cleanupByList(projectDir, ideList, options = {}) {
if (!ideList || ideList.length === 0) {
return this.cleanup(projectDir, options);
}
await this.ensureInitialized();
const results = [];
// Build lowercase lookup for case-insensitive matching
const lowercaseHandlers = new Map([...this.handlers.entries()].map(([k, v]) => [k.toLowerCase(), v]));
for (const ideName of ideList) {
const handler = lowercaseHandlers.get(ideName.toLowerCase());
if (!handler) continue;
try {
await handler.cleanup(projectDir, options);
results.push({ ide: ideName, success: true });
} catch (error) {
results.push({ ide: ideName, success: false, error: error.message });
}
}
return results;
}
/** /**
* Get list of supported IDEs * Get list of supported IDEs
* @returns {Array} List of supported IDE names * @returns {Array} List of supported IDE names