Compare commits

...

20 Commits

Author SHA1 Message Date
Wendy Smoak ae8f86aeec
Merge f0ad64efc4 into 454b19a125 2026-02-16 22:25:51 +01:00
Alex Verkhovsky 454b19a125
feat(prd): add missing steps 2b (vision) and 2c (executive summary) (#1675)
The PRD workflow step-02 was refactored to be discovery-only with
forward references to steps 2b and 2c, but those files were never
created. This left a gap where the workflow jumped from classification
directly to success criteria with no executive summary generation.

- Add step-02b-vision.md: collaborative vision/differentiator discovery
- Add step-02c-executive-summary.md: generate and append exec summary
- Update step-02 nextStepFile to chain through 02b instead of skipping to 03
2026-02-16 13:46:26 -06:00
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
Wendy Smoak d0ef58b421 Handle Windows line-endings 2026-02-15 09:06:16 -05: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
Wendy Smoak 43672d33c1 simplify test 2026-02-14 19:39:24 -05:00
Wendy Smoak 26dd80e021 Fix description regex to handle quotes in transformToSkillFormat
The regex that extracts descriptions from YAML frontmatter did not
properly parse quoted values. Descriptions containing apostrophes
(e.g. "can't") produced invalid YAML when re-wrapped in single quotes.

Replace the naive regex with proper handling of single-quoted,
double-quoted, and unquoted YAML values, and escape inner single
quotes using YAML '' syntax before re-wrapping.

Add tests for transformToSkillFormat covering plain, double-quoted,
and single-quoted descriptions with embedded apostrophes.
2026-02-14 19:31:04 -05:00
Wendy Smoak 746bd50d19 Add .agents directory to .gitignore 2026-02-14 19:19:02 -05:00
Wendy Smoak fcf781fa39 clean up comments 2026-02-14 19:07:49 -05:00
Wendy Smoak 44f07bf341 Clarify where Codex searches for skills 2026-02-14 19:05:09 -05:00
Wendy Smoak 79855b2c4c default to per-project instead of global 2026-02-14 18:51:53 -05:00
Wendy Smoak 62e0b0ba52 switch default to per-project skills to align with other tools 2026-02-14 18:31:14 -05:00
Wendy Smoak 18a4a489c8 feat(codex): convert installer from deprecated prompts to agentskills.io skills format
Codex CLI has deprecated custom prompts in favor of skills following the
agentskills.io specification. Updates the Codex installer to write
{name}/SKILL.md directories into .agents/skills/ instead of flat files
into .codex/prompts/. Removes CODEX_HOME requirement for project-scoped
installs since Codex auto-discovers .agents/skills/ directories.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 15:06:12 -05:00
7 changed files with 832 additions and 99 deletions

1
.gitignore vendored
View File

@ -36,6 +36,7 @@ cursor
CLAUDE.local.md CLAUDE.local.md
.serena/ .serena/
.claude/settings.local.json .claude/settings.local.json
.agents
z*/ z*/

View File

@ -3,7 +3,7 @@ name: 'step-02-discovery'
description: 'Discover project type, domain, and context through collaborative dialogue' description: 'Discover project type, domain, and context through collaborative dialogue'
# File References # File References
nextStepFile: './step-03-success.md' nextStepFile: './step-02b-vision.md'
outputFile: '{planning_artifacts}/prd.md' outputFile: '{planning_artifacts}/prd.md'
# Data Files # Data Files

View File

@ -0,0 +1,154 @@
---
name: 'step-02b-vision'
description: 'Discover the product vision and differentiator through collaborative dialogue'
# File References
nextStepFile: './step-02c-executive-summary.md'
outputFile: '{planning_artifacts}/prd.md'
# Task References
advancedElicitationTask: '{project-root}/_bmad/core/workflows/advanced-elicitation/workflow.xml'
partyModeWorkflow: '{project-root}/_bmad/core/workflows/party-mode/workflow.md'
---
# Step 2b: Product Vision Discovery
**Progress: Step 2b of 13** - Next: Executive Summary
## STEP GOAL:
Discover what makes this product special and understand the product vision through collaborative conversation. No content generation — facilitation only.
## MANDATORY EXECUTION RULES (READ FIRST):
### Universal Rules:
- 🛑 NEVER generate content without user input
- 📖 CRITICAL: Read the complete step file before taking any action
- 🔄 CRITICAL: When loading next step with 'C', ensure the entire file is read
- ✅ ALWAYS treat this as collaborative discovery between PM peers
- 📋 YOU ARE A FACILITATOR, not a content generator
- ✅ YOU MUST ALWAYS SPEAK OUTPUT In your Agent communication style with the config `{communication_language}`
### Role Reinforcement:
- ✅ You are a product-focused PM facilitator collaborating with an expert peer
- ✅ We engage in collaborative dialogue, not command-response
- ✅ You bring structured thinking and facilitation skills, while the user brings domain expertise and product vision
### Step-Specific Rules:
- 🎯 Focus on discovering vision and differentiator — no content generation yet
- 🚫 FORBIDDEN to generate executive summary content (that's the next step)
- 🚫 FORBIDDEN to append anything to the document in this step
- 💬 APPROACH: Natural conversation to understand what makes this product special
- 🎯 BUILD ON classification insights from step 2
## EXECUTION PROTOCOLS:
- 🎯 Show your analysis before taking any action
- ⚠️ Present A/P/C menu after vision discovery is complete
- 📖 Update frontmatter, adding this step to the end of the list of stepsCompleted
- 🚫 FORBIDDEN to load next step until C is selected
## CONTEXT BOUNDARIES:
- Current document and frontmatter from steps 1 and 2 are available
- Project classification exists from step 2 (project type, domain, complexity, context)
- Input documents already loaded are in memory (product briefs, research, brainstorming, project docs)
- No executive summary content yet (that's step 2c)
- This step ONLY discovers — it does NOT write to the document
## YOUR TASK:
Discover the product vision and differentiator through natural conversation. Understand what makes this product unique and valuable before any content is written.
## VISION DISCOVERY SEQUENCE:
### 1. Acknowledge Classification Context
Reference the classification from step 2 and use it to frame the vision conversation:
"We've established this is a {{projectType}} in the {{domain}} domain with {{complexityLevel}} complexity. Now let's explore what makes this product special."
### 2. Explore What Makes It Special
Guide the conversation to uncover the product's unique value:
- **User delight:** "What would make users say 'this is exactly what I needed'?"
- **Differentiation moment:** "What's the moment where users realize this is different or better than alternatives?"
- **Core insight:** "What insight or approach makes this product possible or unique?"
- **Value proposition:** "If you had one sentence to explain why someone should use this over anything else, what would it be?"
### 3. Understand the Vision
Dig deeper into the product vision:
- **Problem framing:** "What's the real problem you're solving — not the surface symptom, but the deeper need?"
- **Future state:** "When this product is successful, what does the world look like for your users?"
- **Why now:** "Why is this the right time to build this?"
### 4. Validate Understanding
Reflect back what you've heard and confirm:
"Here's what I'm hearing about your vision and differentiator:
**Vision:** {{summarized_vision}}
**What Makes It Special:** {{summarized_differentiator}}
**Core Insight:** {{summarized_insight}}
Does this capture it? Anything I'm missing?"
Let the user confirm or refine your understanding.
### N. Present MENU OPTIONS
Present your understanding of the product vision for review, then display menu:
"Based on our conversation, I have a clear picture of your product vision and what makes it special. I'll use these insights to draft the Executive Summary in the next step.
**What would you like to do?**"
Display: "**Select:** [A] Advanced Elicitation [P] Party Mode [C] Continue to Executive Summary (Step 2c of 13)"
#### Menu Handling Logic:
- IF A: Read fully and follow: {advancedElicitationTask} with the current vision insights, process the enhanced insights that come back, ask user if they accept the improvements, if yes update understanding then redisplay menu, if no keep original understanding then redisplay menu
- IF P: Read fully and follow: {partyModeWorkflow} with the current vision insights, process the collaborative insights, ask user if they accept the changes, if yes update understanding then redisplay menu, if no keep original understanding then redisplay menu
- IF C: Update {outputFile} frontmatter by adding this step name to the end of stepsCompleted array, then read fully and follow: {nextStepFile}
- IF Any other: help user respond, then redisplay menu
#### EXECUTION RULES:
- ALWAYS halt and wait for user input after presenting menu
- ONLY proceed to next step when user selects 'C'
- After other menu items execution, return to this menu
## CRITICAL STEP COMPLETION NOTE
ONLY WHEN [C continue option] is selected and [stepsCompleted updated], will you then read fully and follow: `{nextStepFile}` to generate the Executive Summary.
---
## 🚨 SYSTEM SUCCESS/FAILURE METRICS
### ✅ SUCCESS:
- Classification context from step 2 acknowledged and built upon
- Natural conversation to understand product vision and differentiator
- User's existing documents (briefs, research, brainstorming) leveraged for vision insights
- Vision and differentiator validated with user before proceeding
- Clear understanding established that will inform Executive Summary generation
- Frontmatter updated with stepsCompleted when C selected
### ❌ SYSTEM FAILURE:
- Generating executive summary or any document content (that's step 2c!)
- Appending anything to the PRD document
- Not building on classification from step 2
- Being prescriptive instead of having natural conversation
- Proceeding without user selecting 'C'
**CRITICAL**: Reading only partial step file - leads to incomplete understanding and poor decisions
**CRITICAL**: Proceeding with 'C' without fully reading and understanding the next step file
**Master Rule:** This step is vision discovery only. No content generation, no document writing. Have natural conversations, build on what you know from classification, and establish the vision that will feed into the Executive Summary.

View File

@ -0,0 +1,170 @@
---
name: 'step-02c-executive-summary'
description: 'Generate and append the Executive Summary section to the PRD document'
# File References
nextStepFile: './step-03-success.md'
outputFile: '{planning_artifacts}/prd.md'
# Task References
advancedElicitationTask: '{project-root}/_bmad/core/workflows/advanced-elicitation/workflow.xml'
partyModeWorkflow: '{project-root}/_bmad/core/workflows/party-mode/workflow.md'
---
# Step 2c: Executive Summary Generation
**Progress: Step 2c of 13** - Next: Success Criteria
## STEP GOAL:
Generate the Executive Summary content using insights from classification (step 2) and vision discovery (step 2b), then append it to the PRD document.
## MANDATORY EXECUTION RULES (READ FIRST):
### Universal Rules:
- 🛑 NEVER generate content without user input
- 📖 CRITICAL: Read the complete step file before taking any action
- 🔄 CRITICAL: When loading next step with 'C', ensure the entire file is read
- ✅ ALWAYS treat this as collaborative discovery between PM peers
- 📋 YOU ARE A FACILITATOR, not a content generator
- ✅ YOU MUST ALWAYS SPEAK OUTPUT In your Agent communication style with the config `{communication_language}`
### Role Reinforcement:
- ✅ You are a product-focused PM facilitator collaborating with an expert peer
- ✅ We engage in collaborative dialogue, not command-response
- ✅ Content is drafted collaboratively — present for review before saving
### Step-Specific Rules:
- 🎯 Generate Executive Summary content based on discovered insights
- 💬 Present draft content for user review and refinement before appending
- 🚫 FORBIDDEN to append content without user approval via 'C'
- 🎯 Content must be dense, precise, and zero-fluff (PRD quality standards)
## EXECUTION PROTOCOLS:
- 🎯 Show your analysis before taking any action
- ⚠️ Present A/P/C menu after generating executive summary content
- 💾 ONLY save when user chooses C (Continue)
- 📖 Update output file frontmatter, adding this step name to the end of the list of stepsCompleted
- 🚫 FORBIDDEN to load next step until C is selected
## CONTEXT BOUNDARIES:
- Current document and frontmatter from steps 1, 2, and 2b are available
- Project classification exists from step 2 (project type, domain, complexity, context)
- Vision and differentiator insights exist from step 2b
- Input documents from step 1 are available (product briefs, research, brainstorming, project docs)
- This step generates and appends the first substantive content to the PRD
## YOUR TASK:
Draft the Executive Summary section using all discovered insights, present it for user review, and append it to the PRD document when approved.
## EXECUTIVE SUMMARY GENERATION SEQUENCE:
### 1. Synthesize Available Context
Review all available context before drafting:
- Classification from step 2: project type, domain, complexity, project context
- Vision and differentiator from step 2b: what makes this special, core insight
- Input documents: product briefs, research, brainstorming, project docs
### 2. Draft Executive Summary Content
Generate the Executive Summary section using the content structure below. Apply PRD quality standards:
- High information density — every sentence carries weight
- Zero fluff — no filler phrases or vague language
- Precise and actionable — clear, specific statements
- Dual-audience optimized — readable by humans, consumable by LLMs
### 3. Present Draft for Review
Present the drafted content to the user for review:
"Here's the Executive Summary I've drafted based on our discovery work. Please review and let me know if you'd like any changes:"
Show the full drafted content using the structure from the Content Structure section below.
Allow the user to:
- Request specific changes to any section
- Add missing information
- Refine the language or emphasis
- Approve as-is
### N. Present MENU OPTIONS
Present the executive summary content for user review, then display menu:
"Here's the Executive Summary for your PRD. Review the content above and let me know what you'd like to do."
Display: "**Select:** [A] Advanced Elicitation [P] Party Mode [C] Continue to Success Criteria (Step 3 of 13)"
#### Menu Handling Logic:
- IF A: Read fully and follow: {advancedElicitationTask} with the current executive summary content, process the enhanced content that comes back, ask user if they accept the improvements, if yes update content then redisplay menu, if no keep original content then redisplay menu
- IF P: Read fully and follow: {partyModeWorkflow} with the current executive summary content, process the collaborative improvements, ask user if they accept the changes, if yes update content then redisplay menu, if no keep original content then redisplay menu
- IF C: Append the final content to {outputFile}, update frontmatter by adding this step name to the end of the stepsCompleted array, then read fully and follow: {nextStepFile}
- IF Any other: help user respond, then redisplay menu
#### EXECUTION RULES:
- ALWAYS halt and wait for user input after presenting menu
- ONLY proceed to next step when user selects 'C'
- After other menu items execution, return to this menu
## APPEND TO DOCUMENT:
When user selects 'C', append the following content structure directly to the document:
```markdown
## Executive Summary
{vision_alignment_content}
### What Makes This Special
{product_differentiator_content}
## Project Classification
{project_classification_content}
```
Where:
- `{vision_alignment_content}` — Product vision, target users, and the problem being solved. Dense, precise summary drawn from step 2b vision discovery.
- `{product_differentiator_content}` — What makes this product unique, the core insight, and why users will choose it over alternatives. Drawn from step 2b differentiator discovery.
- `{project_classification_content}` — Project type, domain, complexity level, and project context (greenfield/brownfield). Drawn from step 2 classification.
## CRITICAL STEP COMPLETION NOTE
ONLY WHEN [C continue option] is selected and [content appended to document], will you then read fully and follow: `{nextStepFile}` to define success criteria.
---
## 🚨 SYSTEM SUCCESS/FAILURE METRICS
### ✅ SUCCESS:
- Executive Summary drafted using insights from steps 2 and 2b
- Content meets PRD quality standards (dense, precise, zero-fluff)
- Draft presented to user for review before saving
- User given opportunity to refine content
- Content properly appended to document when C selected
- A/P/C menu presented and handled correctly
- Frontmatter updated with stepsCompleted when C selected
### ❌ SYSTEM FAILURE:
- Generating content without incorporating discovered vision and classification
- Appending content without user selecting 'C'
- Producing vague, fluffy, or low-density content
- Not presenting draft for user review
- Not presenting A/P/C menu after content generation
- Skipping directly to next step without appending content
**CRITICAL**: Reading only partial step file - leads to incomplete understanding and poor decisions
**CRITICAL**: Proceeding with 'C' without fully reading and understanding the next step file
**CRITICAL**: Making decisions without complete understanding of step requirements and protocols
**Master Rule:** Generate high-quality Executive Summary content from discovered insights. Present for review, refine collaboratively, and only save when the user approves. This is the first substantive content in the PRD — it sets the quality bar for everything that follows.

View File

@ -0,0 +1,145 @@
/**
* Tests for CodexSetup.transformToSkillFormat
*
* Validates that descriptions round-trip correctly through parse/stringify,
* producing valid YAML regardless of input quoting style.
*
* Usage: node test/test-codex-transform.js
*/
const path = require('node:path');
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++;
}
}
/**
* 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
const { CodexSetup } = require(path.join(__dirname, '..', 'tools', 'cli', 'installers', 'lib', 'ide', 'codex.js'));
const setup = new CodexSetup();
console.log(`\n${colors.cyan}CodexSetup.transformToSkillFormat tests${colors.reset}\n`);
// --- Simple description, no quotes ---
{
const input = `---\ndescription: A simple description\n---\n\nBody content here.`;
const result = setup.transformToSkillFormat(input, 'my-skill');
const desc = parseOutputDescription(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) ---
{
const input = `---\ndescription: "can't stop won't stop"\n---\n\nBody content here.`;
const result = setup.transformToSkillFormat(input, 'my-skill');
const desc = parseOutputDescription(result);
assert(desc === "can't stop won't stop", 'description with apostrophes round-trips', `got description: ${JSON.stringify(desc)}`);
assert(result.includes('\nBody content here.'), 'body preserved for quoted description');
}
// --- Description with embedded single quote ---
{
const input = `---\ndescription: "it's a test"\n---\n\nBody.`;
const result = setup.transformToSkillFormat(input, 'test-skill');
const desc = parseOutputDescription(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) ---
{
const input = `---\ndescription: 'don''t panic'\n---\n\nBody.`;
const result = setup.transformToSkillFormat(input, 'test-skill');
const desc = parseOutputDescription(result);
assert(desc === "don't panic", 'single-quoted escaped apostrophe round-trips', `got description: ${JSON.stringify(desc)}`);
}
// --- 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 ---
console.log(`\n${passed} passed, ${failed} failed\n`);
process.exit(failed > 0 ? 1 : 0);

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

@ -7,10 +7,13 @@ 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');
/** /**
* Codex setup handler (CLI mode) * Codex setup handler (CLI mode)
* Writes BMAD artifacts as Agent Skills (agentskills.io format)
* into .agents/skills/ directories.
*/ */
class CodexSetup extends BaseIdeSetup { class CodexSetup extends BaseIdeSetup {
constructor() { constructor() {
@ -23,35 +26,35 @@ class CodexSetup extends BaseIdeSetup {
* @returns {Object} Collected configuration * @returns {Object} Collected configuration
*/ */
async collectConfiguration(options = {}) { async collectConfiguration(options = {}) {
// Non-interactive mode: use default (global) // Non-interactive mode: use default (project)
if (options.skipPrompts) { if (options.skipPrompts) {
return { installLocation: 'global' }; return { installLocation: 'project' };
} }
let confirmed = false; let confirmed = false;
let installLocation = 'global'; let installLocation = 'project';
while (!confirmed) { while (!confirmed) {
installLocation = await prompts.select({ installLocation = await prompts.select({
message: 'Where would you like to install Codex CLI prompts?', message: 'Where would you like to install Codex CLI skills?',
choices: [ choices: [
{ {
name: 'Global - Simple for single project ' + '(~/.codex/prompts, but references THIS project only)', name: 'Project-specific - Recommended (<project>/.agents/skills)',
value: 'global',
},
{
name: `Project-specific - Recommended for real work (requires CODEX_HOME=<project-dir>${path.sep}.codex)`,
value: 'project', value: 'project',
}, },
{
name: 'Global - ($HOME/.agents/skills)',
value: 'global',
},
], ],
default: 'global', default: 'project',
}); });
// Show brief confirmation hint (detailed instructions available via verbose) // Show brief confirmation hint (detailed instructions available via verbose)
if (installLocation === 'project') { if (installLocation === 'project') {
await prompts.log.info('Prompts installed to: <project>/.codex/prompts (requires CODEX_HOME)'); await prompts.log.info('Skills installed to: <project>/.agents/skills');
} else { } else {
await prompts.log.info('Prompts installed to: ~/.codex/prompts'); await prompts.log.info('Skills installed to: $HOME/.agents/skills');
} }
// Confirm the choice // Confirm the choice
@ -80,20 +83,21 @@ class CodexSetup extends BaseIdeSetup {
// Always use CLI mode // Always use CLI mode
const mode = 'cli'; const mode = 'cli';
// Get installation location from pre-collected config or default to global // Get installation location from pre-collected config or default to project
const installLocation = options.preCollectedConfig?.installLocation || 'global'; const installLocation = options.preCollectedConfig?.installLocation || 'project';
const { artifacts, counts } = await this.collectClaudeArtifacts(projectDir, bmadDir, options); const { artifacts, counts } = await this.collectClaudeArtifacts(projectDir, bmadDir, options);
const destDir = this.getCodexPromptDir(projectDir, installLocation); const destDir = this.getCodexSkillsDir(projectDir, installLocation);
await fs.ensureDir(destDir); await fs.ensureDir(destDir);
await this.clearOldBmadFiles(destDir, options); await this.clearOldBmadSkills(destDir, options);
// Collect artifacts and write using underscore format // Collect and write agent skills
const agentGen = new AgentCommandGenerator(this.bmadFolderName); const agentGen = new AgentCommandGenerator(this.bmadFolderName);
const { artifacts: agentArtifacts } = await agentGen.collectAgentArtifacts(bmadDir, options.selectedModules || []); const { artifacts: agentArtifacts } = await agentGen.collectAgentArtifacts(bmadDir, options.selectedModules || []);
const agentCount = await agentGen.writeDashArtifacts(destDir, agentArtifacts); const agentCount = await this.writeSkillArtifacts(destDir, agentArtifacts, 'agent-launcher');
// Collect and write task skills
const tasks = await getTasksFromBmad(bmadDir, options.selectedModules || []); const tasks = await getTasksFromBmad(bmadDir, options.selectedModules || []);
const taskArtifacts = []; const taskArtifacts = [];
for (const task of tasks) { for (const task of tasks) {
@ -117,19 +121,23 @@ class CodexSetup extends BaseIdeSetup {
}); });
} }
const ttGen = new TaskToolCommandGenerator(this.bmadFolderName);
const taskSkillArtifacts = taskArtifacts.map((artifact) => ({
...artifact,
content: ttGen.generateCommandContent(artifact, artifact.type),
}));
const tasksWritten = await this.writeSkillArtifacts(destDir, taskSkillArtifacts, 'task');
// Collect and write workflow skills
const workflowGenerator = new WorkflowCommandGenerator(this.bmadFolderName); const workflowGenerator = new WorkflowCommandGenerator(this.bmadFolderName);
const { artifacts: workflowArtifacts } = await workflowGenerator.collectWorkflowArtifacts(bmadDir); const { artifacts: workflowArtifacts } = await workflowGenerator.collectWorkflowArtifacts(bmadDir);
const workflowCount = await workflowGenerator.writeDashArtifacts(destDir, workflowArtifacts); const workflowCount = await this.writeSkillArtifacts(destDir, workflowArtifacts, 'workflow-command');
// Also write tasks using underscore format
const ttGen = new TaskToolCommandGenerator(this.bmadFolderName);
const tasksWritten = await ttGen.writeDashArtifacts(destDir, taskArtifacts);
const written = agentCount + workflowCount + tasksWritten; const written = agentCount + workflowCount + tasksWritten;
if (!options.silent) { if (!options.silent) {
await prompts.log.success( await prompts.log.success(
`${this.name} configured: ${counts.agents} agents, ${counts.workflows} workflows, ${counts.tasks} tasks, ${written} files → ${destDir}`, `${this.name} configured: ${counts.agents} agents, ${counts.workflows} workflows, ${counts.tasks} tasks, ${written} skills → ${destDir}`,
); );
} }
@ -145,13 +153,82 @@ class CodexSetup extends BaseIdeSetup {
} }
/** /**
* Detect Codex installation by checking for BMAD prompt exports * Write artifacts as Agent Skills (agentskills.io format).
* Each artifact becomes a directory containing SKILL.md.
* @param {string} destDir - Base skills directory
* @param {Array} artifacts - Artifacts to write
* @param {string} artifactType - Type filter (e.g., 'agent-launcher', 'workflow-command', 'task')
* @returns {number} Number of skills written
*/
async writeSkillArtifacts(destDir, artifacts, artifactType) {
let writtenCount = 0;
for (const artifact of artifacts) {
// Filter by type if the artifact has a type field
if (artifact.type && artifact.type !== artifactType) {
continue;
}
// Get the dash-format name (e.g., bmad-bmm-create-prd.md) and remove .md
const flatName = toDashPath(artifact.relativePath);
const skillName = flatName.replace(/\.md$/, '');
// Create skill directory
const skillDir = path.join(destDir, skillName);
await fs.ensureDir(skillDir);
// Transform content: rewrite frontmatter for skills format
const skillContent = this.transformToSkillFormat(artifact.content, skillName);
// Write SKILL.md
await fs.writeFile(path.join(skillDir, 'SKILL.md'), skillContent);
writtenCount++;
}
return writtenCount;
}
/**
* Transform artifact content from Codex prompt format to Agent Skills format.
* Removes disable-model-invocation, ensures name matches directory.
* @param {string} content - Original content with YAML frontmatter
* @param {string} skillName - Skill name (must match directory name)
* @returns {string} Transformed content
*/
transformToSkillFormat(content, skillName) {
// Parse frontmatter
const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
if (!fmMatch) {
// No frontmatter -- wrap with minimal frontmatter
const fm = yaml.stringify({ name: skillName, description: skillName }).trimEnd();
return `---\n${fm}\n---\n\n${content}`;
}
const frontmatter = fmMatch[1];
const body = fmMatch[2];
// Parse frontmatter with yaml library to handle all quoting variants
let description;
try {
const parsed = yaml.parse(frontmatter);
description = parsed?.description || `${skillName} skill`;
} catch {
description = `${skillName} skill`;
}
// Build new frontmatter with only skills-spec fields, let yaml handle quoting
const newFrontmatter = yaml.stringify({ name: skillName, description }).trimEnd();
return `---\n${newFrontmatter}\n---\n${body}`;
}
/**
* Detect Codex installation by checking for BMAD skills
*/ */
async detect(projectDir) { async detect(projectDir) {
// Check both global and project-specific locations // Check both global and project-specific locations
const globalDir = this.getCodexPromptDir(null, 'global'); const globalDir = this.getCodexSkillsDir(null, 'global');
const projectDir_local = projectDir || process.cwd(); const projectDir_local = projectDir || process.cwd();
const projectSpecificDir = this.getCodexPromptDir(projectDir_local, 'project'); const projectSpecificDir = this.getCodexSkillsDir(projectDir_local, 'project');
// Check global location // Check global location
if (await fs.pathExists(globalDir)) { if (await fs.pathExists(globalDir)) {
@ -240,27 +317,20 @@ class CodexSetup extends BaseIdeSetup {
}; };
} }
getCodexPromptDir(projectDir = null, location = 'global') { getCodexSkillsDir(projectDir = null, location = 'project') {
if (location === 'project' && projectDir) { if (location === 'project' && projectDir) {
return path.join(projectDir, '.codex', 'prompts'); return path.join(projectDir, '.agents', 'skills');
} }
return path.join(os.homedir(), '.codex', 'prompts'); if (location === 'project' && !projectDir) {
throw new Error('projectDir is required for project-scoped skill installation');
}
return path.join(os.homedir(), '.agents', 'skills');
} }
async flattenAndWriteArtifacts(artifacts, destDir) { /**
let written = 0; * Remove existing BMAD skill directories from the skills directory.
*/
for (const artifact of artifacts) { async clearOldBmadSkills(destDir, options = {}) {
const flattenedName = this.flattenFilename(artifact.relativePath);
const targetPath = path.join(destDir, flattenedName);
await fs.writeFile(targetPath, artifact.content);
written++;
}
return written;
}
async clearOldBmadFiles(destDir, options = {}) {
if (!(await fs.pathExists(destDir))) { if (!(await fs.pathExists(destDir))) {
return; return;
} }
@ -299,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);
} }
@ -311,14 +382,14 @@ class CodexSetup extends BaseIdeSetup {
const lines = [ const lines = [
'IMPORTANT: Codex Configuration', 'IMPORTANT: Codex Configuration',
'', '',
'/prompts installed globally to your HOME DIRECTORY.', 'Skills installed globally to your HOME DIRECTORY ($HOME/.agents/skills).',
'', '',
'These prompts 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.",
'', '',
'You can now use /commands in Codex CLI', 'Skills are available in Codex CLI automatically.',
' Example: /bmad_bmm_pm', ' Use /skills to see available skills',
' Type / to see all available commands', ' Skills can also be invoked implicitly based on task description',
]; ];
return lines.join('\n'); return lines.join('\n');
} }
@ -330,40 +401,15 @@ class CodexSetup extends BaseIdeSetup {
* @returns {string} Instructions text * @returns {string} Instructions text
*/ */
getProjectSpecificInstructions(projectDir = null, destDir = null) { getProjectSpecificInstructions(projectDir = null, destDir = null) {
const isWindows = os.platform() === 'win32'; const lines = [
const commonLines = [
'Project-Specific Codex Configuration', 'Project-Specific Codex Configuration',
'', '',
`Prompts will be installed to: ${destDir || '<project>/.codex/prompts'}`, `Skills installed to: ${destDir || '<project>/.agents/skills'}`,
'',
'REQUIRED: You must set CODEX_HOME to use these prompts',
'', '',
'Codex automatically discovers skills in .agents/skills/ at and above the current directory and in your home directory.',
'No additional configuration is needed.',
]; ];
const windowsLines = [
'Create a codex.cmd file in your project root:',
'',
' @echo off',
' set CODEX_HOME=%~dp0.codex',
' codex %*',
'',
String.raw`Then run: .\codex instead of codex`,
'(The %~dp0 gets the directory of the .cmd file)',
];
const unixLines = [
'Add this alias to your ~/.bashrc or ~/.zshrc:',
'',
' alias codex=\'CODEX_HOME="$PWD/.codex" codex\'',
'',
'After adding, run: source ~/.bashrc (or source ~/.zshrc)',
'(The $PWD uses your current working directory)',
];
const closingLines = ['', 'This tells Codex CLI to use prompts from this project instead of ~/.codex'];
const lines = [...commonLines, ...(isWindows ? windowsLines : unixLines), ...closingLines];
return lines.join('\n'); return lines.join('\n');
} }
@ -372,31 +418,34 @@ class CodexSetup extends BaseIdeSetup {
*/ */
async cleanup(projectDir = null) { async cleanup(projectDir = null) {
// Clean both global and project-specific locations // Clean both global and project-specific locations
const globalDir = this.getCodexPromptDir(null, 'global'); const globalDir = this.getCodexSkillsDir(null, 'global');
await this.clearOldBmadFiles(globalDir); await this.clearOldBmadSkills(globalDir);
if (projectDir) { if (projectDir) {
const projectSpecificDir = this.getCodexPromptDir(projectDir, 'project'); const projectSpecificDir = this.getCodexSkillsDir(projectDir, 'project');
await this.clearOldBmadFiles(projectSpecificDir); await this.clearOldBmadSkills(projectSpecificDir);
} }
} }
/** /**
* Install a custom agent launcher for Codex * Install a custom agent launcher for Codex as an Agent Skill
* @param {string} projectDir - Project directory (not used, Codex installs to home) * @param {string} projectDir - Project directory
* @param {string} agentName - Agent name (e.g., "fred-commit-poet") * @param {string} agentName - Agent name (e.g., "fred-commit-poet")
* @param {string} agentPath - Path to compiled agent (relative to project root) * @param {string} agentPath - Path to compiled agent (relative to project root)
* @param {Object} metadata - Agent metadata * @param {Object} metadata - Agent metadata
* @returns {Object|null} Info about created command * @returns {Object|null} Info about created skill
*/ */
async installCustomAgentLauncher(projectDir, agentName, agentPath, metadata) { async installCustomAgentLauncher(projectDir, agentName, agentPath, metadata) {
const destDir = this.getCodexPromptDir(projectDir, 'project'); const destDir = this.getCodexSkillsDir(projectDir, 'project');
await fs.ensureDir(destDir);
const launcherContent = `--- // Skill name from the dash name (without .md)
name: '${agentName}' const skillName = customAgentDashName(agentName).replace(/\.md$/, '');
description: '${agentName} agent' const skillDir = path.join(destDir, skillName);
disable-model-invocation: true await fs.ensureDir(skillDir);
const fm = yaml.stringify({ name: skillName, description: `${agentName} agent` }).trimEnd();
const skillContent = `---
${fm}
--- ---
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.
@ -411,14 +460,12 @@ You must fully embody this agent's persona and follow all activation instruction
</agent-activation> </agent-activation>
`; `;
// Use underscore format: bmad_custom_fred-commit-poet.md const skillPath = path.join(skillDir, 'SKILL.md');
const fileName = customAgentDashName(agentName); await fs.writeFile(skillPath, skillContent, 'utf8');
const launcherPath = path.join(destDir, fileName);
await fs.writeFile(launcherPath, launcherContent, 'utf8');
return { return {
path: path.relative(projectDir, launcherPath), path: path.relative(projectDir, skillPath),
command: `/${fileName.replace('.md', '')}`, command: `$${skillName}`,
}; };
} }
} }