This commit is contained in:
Wendy Smoak 2026-02-15 10:28:12 +03:00 committed by GitHub
commit 7aec8f47ce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 226 additions and 96 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

@ -0,0 +1,78 @@
/**
* Tests for CodexSetup.transformToSkillFormat
*
* Demonstrates that the description regex mangles descriptions containing quotes.
*
* Usage: node test/test-codex-transform.js
*/
const path = require('node:path');
// 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();
console.log(`\n${colors.cyan}CodexSetup.transformToSkillFormat tests${colors.reset}\n`);
// --- Passing case: simple description, no quotes ---
{
const input = `---\ndescription: A simple description\n---\n\nBody content here.`;
const result = setup.transformToSkillFormat(input, 'my-skill');
const expected = `---\nname: my-skill\ndescription: 'A simple description'\n---\n\nBody content here.`;
assert(result === expected, 'simple description without quotes', `got: ${JSON.stringify(result)}`);
}
// --- 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');
// Output should have properly escaped YAML single-quoted scalar: '' for each '
const expected = `---\nname: my-skill\ndescription: 'can''t stop won''t stop'\n---\n\nBody content here.`;
assert(result === expected, 'description with embedded single quotes produces valid escaped YAML', `got: ${JSON.stringify(result)}`);
}
// --- Description with embedded single quote produces valid YAML ---
{
const input = `---\ndescription: "it's a test"\n---\n\nBody.`;
const result = setup.transformToSkillFormat(input, 'test-skill');
const expected = `---\nname: test-skill\ndescription: 'it''s a test'\n---\n\nBody.`;
assert(result === expected, 'description with apostrophe produces valid YAML', `got: ${JSON.stringify(result)}`);
}
// --- 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');
// Input has don''t (YAML-escaped). Should round-trip to don''t in output.
const expected = `---\nname: test-skill\ndescription: 'don''t panic'\n---\n\nBody.`;
assert(result === expected, 'single-quoted description with escaped apostrophe round-trips correctly', `got: ${JSON.stringify(result)}`);
}
// --- Summary ---
console.log(`\n${passed} passed, ${failed} failed\n`);
process.exit(failed > 0 ? 1 : 0);

View File

@ -11,6 +11,8 @@ 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 +25,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 - (~/.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: ~/.agents/skills');
} }
// Confirm the choice // Confirm the choice
@ -80,20 +82,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 +120,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 +152,91 @@ 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(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
if (!fmMatch) {
// No frontmatter -- wrap with minimal frontmatter
return `---\nname: ${skillName}\ndescription: '${skillName}'\n---\n\n${content}`;
}
const frontmatter = fmMatch[1];
const body = fmMatch[2];
// Extract description from existing frontmatter, handling quoted and unquoted values
const descMatch = frontmatter.match(/^description:\s*(?:'((?:[^']|'')*)'|"((?:[^"\\]|\\.)*)"|(.*))\s*$/m);
let description;
if (descMatch) {
if (descMatch[1] != null) {
// Single-quoted YAML: unescape '' to '
description = descMatch[1].replaceAll("''", "'");
} else if (descMatch[2] == null) {
description = descMatch[3];
} else {
// Double-quoted YAML: unescape \" to "
description = descMatch[2].replaceAll(String.raw`\"`, '"');
}
} else {
description = `${skillName} skill`;
}
// Escape single quotes for YAML single-quoted scalar (a literal ' becomes '')
const safeDescription = description.replaceAll("'", "''");
// Build new frontmatter with only skills-spec fields
return `---\nname: ${skillName}\ndescription: '${safeDescription}'\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 +325,17 @@ 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'); 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;
} }
@ -311,14 +386,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 (~/.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 +405,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 +422,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$/, '');
const skillDir = path.join(destDir, skillName);
await fs.ensureDir(skillDir);
const skillContent = `---
name: ${skillName}
description: '${agentName} agent' description: '${agentName} agent'
disable-model-invocation: true
--- ---
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 +464,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}`,
}; };
} }
} }