aiwg
Version:
Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo
350 lines • 13.4 kB
JavaScript
/**
* Skill→Command Translation Layer
*
* Generates command files from canonical SKILL.md sources for providers that
* require command format natively (Factory, OpenCode, Warp, Windsurf, Copilot,
* Codex, OpenClaw).
*
* Skills are the canonical source format; commands are deployment artifacts.
* See ADR: Skills as the Canonical Extension Type.
*
* @implements .aiwg/architecture/adr-skills-canonical-extension-type.md
* @issue #550
*/
import fs from 'fs/promises';
import path from 'path';
// ============================================
// Provider Configuration
// ============================================
/**
* Providers that need command files generated from skills.
*
* Providers not listed here get skills deployed natively (no translation needed).
*/
const PROVIDERS_NEEDING_COMMANDS = new Set([
'factory',
'opencode', // OpenCode scans .opencode/command/**/*.md via ConfigCommand.load() (PUW-006 #1107)
'warp',
'windsurf',
'copilot',
'codex',
'openclaw',
]);
/**
* Providers where skills are deployed natively (no command generation needed).
*/
const SKILLS_ONLY_PROVIDERS = new Set([
'claude',
'cursor',
'hermes',
]);
/**
* Check if a provider needs command files generated from skills.
*/
export function providerNeedsCommands(provider) {
return PROVIDERS_NEEDING_COMMANDS.has(provider);
}
/**
* Check if a provider uses skills natively (no command translation).
*/
export function providerUsesSkillsNatively(provider) {
return SKILLS_ONLY_PROVIDERS.has(provider);
}
// ============================================
// Frontmatter Parsing
// ============================================
/**
* Parse YAML-like frontmatter from a SKILL.md file.
*
* This is a lightweight parser that handles the subset of YAML used in
* SKILL.md frontmatter. For full YAML support, a library like js-yaml
* would be needed, but the frontmatter format is simple enough for this.
*/
export function parseFrontmatter(content) {
const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
if (!fmMatch) {
return { frontmatter: {}, body: content };
}
const fmRaw = fmMatch[1];
const body = fmMatch[2];
const frontmatter = {};
// Parse top-level keys
const lines = fmRaw.split('\n');
let i = 0;
while (i < lines.length) {
const line = lines[i];
// description: ...
const descMatch = line.match(/^description:\s*(.+)/);
if (descMatch) {
frontmatter.description = descMatch[1].trim();
i++;
continue;
}
// userInvocable: false
const uiMatch = line.match(/^userInvocable:\s*(true|false)/i);
if (uiMatch) {
frontmatter.userInvocable = uiMatch[1].toLowerCase() === 'true';
i++;
continue;
}
// user-invocable: false (kebab-case variant)
const uiKebabMatch = line.match(/^user-invocable:\s*(true|false)/i);
if (uiKebabMatch) {
frontmatter.userInvocable = uiKebabMatch[1].toLowerCase() === 'true';
i++;
continue;
}
// effort: N
const effortMatch = line.match(/^effort:\s*(\d+)/);
if (effortMatch) {
frontmatter.effort = parseInt(effortMatch[1], 10);
i++;
continue;
}
// context: fork|inherit
const ctxMatch = line.match(/^context:\s*(\w+)/);
if (ctxMatch) {
frontmatter.context = ctxMatch[1];
i++;
continue;
}
// disableModelInvocation: true
const dmiMatch = line.match(/^(?:disableModelInvocation|disable-model-invocation):\s*(true|false)/i);
if (dmiMatch) {
frontmatter.disableModelInvocation = dmiMatch[1].toLowerCase() === 'true';
i++;
continue;
}
// allowed-tools: (top-level, for the skill itself)
const atMatch = line.match(/^allowed-tools:\s*(.+)/);
if (atMatch) {
frontmatter.allowedTools = atMatch[1].trim();
i++;
continue;
}
// commandHint: block
if (line.match(/^commandHint:\s*$/)) {
const hint = {};
i++;
while (i < lines.length && lines[i].match(/^\s+/)) {
const hintLine = lines[i].trim();
const ahMatch = hintLine.match(/^argumentHint:\s*(.+)/);
if (ahMatch) {
hint.argumentHint = ahMatch[1].trim();
i++;
continue;
}
const hatMatch = hintLine.match(/^allowedTools:\s*(.+)/);
if (hatMatch) {
hint.allowedTools = hatMatch[1].trim();
i++;
continue;
}
const tmplMatch = hintLine.match(/^template:\s*(.+)/);
if (tmplMatch) {
hint.template = tmplMatch[1].trim();
i++;
continue;
}
const modMatch = hintLine.match(/^model:\s*(.+)/);
if (modMatch) {
hint.model = modMatch[1].trim();
i++;
continue;
}
const catMatch = hintLine.match(/^category:\s*(.+)/);
if (catMatch) {
hint.category = catMatch[1].trim();
i++;
continue;
}
const orchMatch = hintLine.match(/^orchestration:\s*(true|false)/i);
if (orchMatch) {
hint.orchestration = orchMatch[1].toLowerCase() === 'true';
i++;
continue;
}
const cliMatch = hintLine.match(/^cliDisabled:\s*(true|false)/i);
if (cliMatch) {
hint.cliDisabled = cliMatch[1].toLowerCase() === 'true';
i++;
continue;
}
i++;
}
frontmatter.commandHint = hint;
continue;
}
i++;
}
return { frontmatter, body };
}
// ============================================
// Command Generation
// ============================================
/**
* Generate a command .md file content from parsed skill data.
*
* Maps SKILL.md frontmatter to legacy command frontmatter format:
* - `description:` from skill description
* - `argument-hint:` from commandHint.argumentHint
* - `allowed-tools:` from commandHint.allowedTools (comma-separated)
*/
export function generateCommandContent(_skillName, frontmatter, body) {
const lines = ['---'];
// description is always included
if (frontmatter.description) {
lines.push(`description: ${frontmatter.description}`);
}
const hint = frontmatter.commandHint;
// argument-hint from commandHint
if (hint?.argumentHint) {
lines.push(`argument-hint: ${hint.argumentHint}`);
}
// allowed-tools: prefer commandHint.allowedTools, fall back to top-level allowedTools
const tools = hint?.allowedTools ?? frontmatter.allowedTools;
if (tools) {
const toolStr = Array.isArray(tools) ? tools.join(', ') : tools;
lines.push(`allowed-tools: ${toolStr}`);
}
lines.push('---');
lines.push('');
// Body content is passed through unchanged
lines.push(body.trimStart());
return lines.join('\n');
}
// ============================================
// Translation Pipeline
// ============================================
/**
* Translate all skills in a directory to command files.
*
* Reads each subdirectory in `skillsDir` as a skill, parses SKILL.md,
* and generates a corresponding command .md file.
*
* @param skillsDir - Source skills directory (e.g., `agentic/code/frameworks/sdlc-complete/skills/`)
* @param options - Translation options (provider, target dir, dry-run)
* @returns Translation result with counts and any errors
*/
export async function translateSkillsToCommands(skillsDir, options) {
const result = {
provider: options.provider,
targetDir: options.targetDir,
translated: [],
skipped: [],
errors: [],
totalProcessed: 0,
};
// Check if this provider needs commands. nameFilter overrides the
// provider gating: when an operator passes an explicit filter (e.g. Claude
// flow→command emission per PUW-015 #1116), they're opting in to selective
// translation regardless of the provider's default skills-native posture.
if (!options.nameFilter && !providerNeedsCommands(options.provider)) {
if (options.verbose) {
console.log(` Provider '${options.provider}' uses skills natively — skipping command generation`);
}
return result;
}
// Read skill directories
let entries;
try {
const dirEntries = await fs.readdir(skillsDir, { withFileTypes: true });
entries = dirEntries.filter(e => e.isDirectory()).map(e => e.name);
}
catch {
// No skills directory — nothing to translate
return result;
}
// Process each skill
for (const skillName of entries) {
// Apply name filter when provided (PUW-015 / #1116 — Claude flow filter).
if (options.nameFilter && !options.nameFilter(skillName)) {
continue;
}
result.totalProcessed++;
const skillMdPath = path.join(skillsDir, skillName, 'SKILL.md');
try {
const content = await fs.readFile(skillMdPath, 'utf-8');
const { frontmatter, body } = parseFrontmatter(content);
// Skip background-only skills (userInvocable: false)
if (frontmatter.userInvocable === false) {
const skipped = {
sourcePath: skillMdPath,
skillName,
commandFilename: `${skillName}.md`,
content: '',
skipped: true,
skipReason: 'userInvocable: false (background-only skill)',
};
result.skipped.push(skipped);
if (options.verbose) {
console.log(` Skipped: ${skillName} (background-only)`);
}
continue;
}
// Generate command content
const commandContent = generateCommandContent(skillName, frontmatter, body);
const commandFilename = `${skillName}.md`;
const translated = {
sourcePath: skillMdPath,
skillName,
commandFilename,
content: commandContent,
skipped: false,
};
// Write file (unless dry-run)
if (!options.dryRun) {
const targetPath = path.join(options.targetDir, commandFilename);
await fs.mkdir(options.targetDir, { recursive: true });
await fs.writeFile(targetPath, commandContent, 'utf-8');
// Copilot dual-write per PUW-004 (#1105): also emit to
// `.github/prompts/<skill>.prompt.md` so Copilot Chat picks up the
// skills as `/`-invocable prompt files. Always-deploy invariant
// (ADR-1 §0.6) keeps the legacy `.github/commands/` write above.
if (options.provider === 'copilot') {
const projectRoot = options.projectPath
?? path.dirname(path.dirname(options.targetDir));
const promptsDir = path.join(projectRoot, '.github', 'prompts');
const promptPath = path.join(promptsDir, `${skillName}.prompt.md`);
await fs.mkdir(promptsDir, { recursive: true });
await fs.writeFile(promptPath, commandContent, 'utf-8');
}
}
result.translated.push(translated);
if (options.verbose) {
console.log(` Translated: ${skillName} → ${commandFilename}`);
}
}
catch (error) {
// SKILL.md might not exist (skill has no markdown file) — skip gracefully
const errMsg = error instanceof Error ? error.message : String(error);
if (errMsg.includes('ENOENT')) {
// No SKILL.md — skip silently
continue;
}
result.errors.push({ skillName, error: errMsg });
if (options.verbose) {
console.error(` Error: ${skillName} — ${errMsg}`);
}
}
}
return result;
}
/**
* Translate a single SKILL.md content string to command format.
*
* Convenience function for testing and one-off translations.
*
* @param skillName - Skill name (used for display only)
* @param skillContent - Raw SKILL.md file content
* @returns Generated command .md content, or null if skill should be skipped
*/
export function translateSingleSkill(skillName, skillContent) {
const { frontmatter, body } = parseFrontmatter(skillContent);
// Skip background-only skills
if (frontmatter.userInvocable === false) {
return null;
}
return generateCommandContent(skillName, frontmatter, body);
}
//# sourceMappingURL=skill-command-translator.js.map