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
487 lines (424 loc) • 16.1 kB
JavaScript
/**
* Deploy Skills to Codex
*
* Transforms AIWG skills to Codex format and deploys to ~/.codex/skills/
*
* Codex Skill Format:
* - Location: ~/.codex/skills/<skill-name>/SKILL.md
* - YAML frontmatter: name (≤100 chars), description (≤500 chars)
* - Body: Instructions (kept on disk, not injected into context)
*
* Usage:
* node tools/skills/deploy-skills-codex.mjs [options]
*
* Options:
* --source <path> Source directory (defaults to repo root)
* --target <path> Target directory (defaults to ~/.codex/skills)
* --mode <type> Deployment mode: addons, sdlc, marketing, media-curator, research, or all (default)
* --dry-run Show what would be deployed without writing
* --force Overwrite existing files
*/
import realFs from 'fs';
import { createRequire } from 'module';
const _require = createRequire(import.meta.url);
let fs;
try { const gfs = _require('graceful-fs'); gfs.gracefulify(realFs); fs = realFs; } catch { fs = realFs; }
import path from 'path';
import os from 'os';
import { getFrameworksForMode, normalizeDeploymentMode, skillMatchesProvider, isKernelSkill } from '../agents/providers/base.mjs';
const CODEX_SKILLS_DIR = path.join(os.homedir(), '.codex', 'skills');
const MAX_NAME_LENGTH = 100;
const MAX_DESCRIPTION_LENGTH = 500;
function parseArgs() {
const args = process.argv.slice(2);
const cfg = {
source: null,
target: CODEX_SKILLS_DIR,
mode: 'all',
dryRun: false,
force: false,
copyStandardSkills: false,
};
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === '--source' && args[i + 1]) cfg.source = path.resolve(args[++i]);
else if (a === '--target' && args[i + 1]) cfg.target = path.resolve(args[++i]);
else if (a === '--mode' && args[i + 1]) cfg.mode = String(args[++i]).toLowerCase();
else if (a === '--dry-run') cfg.dryRun = true;
else if (a === '--force') cfg.force = true;
else if (a === '--copy-all' || a === '--copy-standard-skills') cfg.copyStandardSkills = true;
}
cfg.mode = normalizeDeploymentMode(cfg.mode);
return cfg;
}
function ensureDir(d) {
if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
}
function stripWrappingQuotes(value) {
const trimmed = String(value ?? '').trim();
if (
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
) {
return trimmed.slice(1, -1);
}
return trimmed;
}
function yamlDoubleQuoted(value) {
return String(value ?? '')
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\r?\n/g, ' ')
.trim();
}
/**
* Find skill directories containing SKILL.md
*/
function findSkillDirs(baseDir) {
if (!fs.existsSync(baseDir)) return [];
const skillDirs = [];
const entries = fs.readdirSync(baseDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const skillPath = path.join(baseDir, entry.name, 'SKILL.md');
if (fs.existsSync(skillPath)) {
skillDirs.push(path.join(baseDir, entry.name));
}
}
}
return skillDirs;
}
/**
* Parse AIWG SKILL.md - handles both frontmatter and non-frontmatter formats
*/
function parseSkillContent(content, skillName) {
// Try YAML frontmatter format first
const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
if (fmMatch) {
const [, frontmatter, body] = fmMatch;
const metadata = {};
// Parse YAML-like frontmatter
for (const line of frontmatter.split('\n')) {
const colonIdx = line.indexOf(':');
if (colonIdx > 0) {
const key = line.slice(0, colonIdx).trim();
const value = line.slice(colonIdx + 1).trim();
metadata[key] = stripWrappingQuotes(value);
}
}
return { metadata, body };
}
// Fallback: Parse non-frontmatter format (# skill-name header)
const lines = content.split('\n');
let name = skillName;
let description = '';
let bodyStartIdx = 0;
// Look for # header as name
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (line.startsWith('# ')) {
name = line.slice(2).trim();
bodyStartIdx = i + 1;
break;
}
}
// Look for first paragraph as description (skip empty lines)
for (let i = bodyStartIdx; i < lines.length; i++) {
const line = lines[i].trim();
if (
line &&
!line.endsWith(':') &&
!line.startsWith('#') &&
!line.startsWith('-') &&
!line.startsWith('|')
) {
description = line;
break;
}
}
// If description is too short, try next paragraph
if (description.length < 20) {
for (let i = bodyStartIdx; i < lines.length; i++) {
const line = lines[i].trim();
if (line.startsWith('## ') && line.toLowerCase().includes('purpose')) {
// Look for content after ## Purpose
for (let j = i + 1; j < lines.length && j < i + 10; j++) {
const purposeLine = lines[j].trim();
if (purposeLine && !purposeLine.startsWith('#') && !purposeLine.startsWith('-')) {
description = purposeLine;
break;
}
}
break;
}
}
}
return {
metadata: { name, description },
body: content
};
}
/**
* Transform AIWG skill to Codex format
*/
function transformToCodexSkill(skillDir) {
const skillPath = path.join(skillDir, 'SKILL.md');
const skillName = path.basename(skillDir);
const content = fs.readFileSync(skillPath, 'utf8');
// Platform filtering: skip skills with explicit restrictions that exclude codex.
// Skills using platforms: [all] (the standard token) always pass this check.
if (!skillMatchesProvider(content, 'codex')) {
return null;
}
const parsed = parseSkillContent(content, skillName);
if (!parsed) {
console.warn(`Warning: Could not parse ${skillPath}`);
return null;
}
const { metadata, body } = parsed;
// Validate and truncate
const name = (metadata.name || path.basename(skillDir)).slice(0, MAX_NAME_LENGTH);
let description = metadata.description || '';
// Codex REQUIRES a non-empty description — it rejects SKILL.md files that
// lack one. Fail loudly rather than silently writing `description: ""`
// (the exact regression this guard defends against).
if (!description || !String(description).trim()) {
console.error(
`ERROR: Skill '${name}' has empty/missing description in source ${skillPath}`
);
console.error(
` Codex rejects SKILL.md files without a description field.`
);
console.error(
` Fix the source file: add a non-empty 'description:' to the frontmatter.`
);
return null;
}
// Truncate description to 500 chars, ending at word boundary
if (description.length > MAX_DESCRIPTION_LENGTH) {
description = description.slice(0, MAX_DESCRIPTION_LENGTH - 3);
const lastSpace = description.lastIndexOf(' ');
if (lastSpace > MAX_DESCRIPTION_LENGTH - 50) {
description = description.slice(0, lastSpace);
}
description += '...';
}
// Final guard: never emit `description: ""` under any circumstance.
const quotedDescription = yamlDoubleQuoted(description);
if (!quotedDescription || !quotedDescription.trim()) {
console.error(
`ERROR: Skill '${name}' description collapsed to empty after normalization (source: ${skillPath})`
);
return null;
}
// Build Codex skill format — include platforms: [codex] so deployed skills are self-describing
const codexContent = `---
name: "${yamlDoubleQuoted(name)}"
description: "${quotedDescription}"
platforms: [codex]
---
${body.trim()}
`;
return {
name,
description,
content: codexContent,
sourcePath: skillPath
};
}
/**
* Deploy skill to Codex skills directory
*/
function deploySkill(skill, targetDir, opts) {
const { force = false, dryRun = false } = opts;
const skillDir = path.join(targetDir, skill.name);
const destPath = path.join(skillDir, 'SKILL.md');
// Check if skill already exists
if (fs.existsSync(destPath)) {
const existingContent = fs.readFileSync(destPath, 'utf8');
if (existingContent === skill.content && !force) {
console.log(` skip (unchanged): ${skill.name}`);
return { action: 'skip', reason: 'unchanged' };
}
}
if (dryRun) {
console.log(` [dry-run] deploy: ${skill.name}`);
return { action: 'deploy', reason: 'dry-run' };
}
// Create skill directory and write SKILL.md
ensureDir(skillDir);
fs.writeFileSync(destPath, skill.content, 'utf8');
// Drop a marker file so future deploys can identify AIWG-managed
// skills regardless of frontmatter format (Codex strips `namespace:`
// during transform, so the SKILL.md alone isn't a reliable signal).
// Cleanup keys off this presence.
fs.writeFileSync(path.join(skillDir, '.aiwg-managed'), 'aiwg\n', 'utf8');
console.log(` deployed: ${skill.name}`);
return { action: 'deploy', reason: 'success' };
}
/**
* Get skill directories based on mode
*/
function getSkillDirectories(srcRoot, mode) {
const dirs = [];
// Addon skills
if (mode === 'addons' || mode === 'all') {
const addonsRoot = path.join(srcRoot, 'agentic', 'code', 'addons');
if (fs.existsSync(addonsRoot)) {
const addonDirs = fs.readdirSync(addonsRoot, { withFileTypes: true })
.filter(e => e.isDirectory())
.map(e => path.join(addonsRoot, e.name, 'skills'));
for (const addonSkillsDir of addonDirs) {
if (fs.existsSync(addonSkillsDir)) {
dirs.push({ dir: addonSkillsDir, label: path.basename(path.dirname(addonSkillsDir)) });
}
}
}
}
// Framework skills discovered from framework manifests/directory structure.
const frameworks = getFrameworksForMode(srcRoot, mode);
for (const framework of frameworks) {
if (framework.components.skills.exists) {
dirs.push({ dir: framework.components.skills.path, label: framework.id });
}
}
return dirs;
}
(async function main() {
const cfg = parseArgs();
const { source, target, mode, dryRun, force } = cfg;
// Resolve source directory
const scriptDir = path.dirname(new URL(import.meta.url).pathname);
const repoRoot = path.resolve(scriptDir, '..', '..');
const srcRoot = source || repoRoot;
console.log(`Deploying skills to Codex (~/.codex/skills/)`);
console.log(` Source: ${srcRoot}`);
console.log(` Target: ${target}`);
console.log(` Mode: ${mode}`);
if (dryRun) console.log(` [DRY RUN]`);
console.log();
// Create target directory
if (!dryRun) {
ensureDir(target);
}
// Get skill directories based on mode
const skillDirs = getSkillDirectories(srcRoot, mode);
let totalDeployed = 0;
let totalSkipped = 0;
// Honor #1217 kernel-pivot default: deploy only kernel skills unless
// the operator opts in via `--copy-all` (or `--copy-standard-skills`).
// Codex deploys to ~/.codex/skills/ (home dir) so the kernel/standard
// split is enforced at filter time rather than via separate
// destination directories.
const copyStandardSkills = cfg.copyStandardSkills === true;
// Track every AIWG-managed source skill name so we can scope post-deploy
// cleanup to skills AIWG ships — never delete user-authored or
// third-party skills sitting alongside.
//
// Two name spaces matter for cleanup: source basename (`addons/foo/skills/<name>/`)
// AND deployed name (the `name:` frontmatter field, which Codex uses as
// the target directory). Sample: source `archive-acquisition` deploys
// as `Archive Acquisition`. Track both so cleanup catches each form.
const allManagedNames = new Set();
const desiredNames = new Set();
// Pre-pass: walk every framework/addon skill directory in the source
// tree (not just those matching the requested mode) so cleanup can
// remove stale skills from previously-deployed-but-no-longer-deployed
// frameworks. Bounded to AIWG source roots so user-authored skills in
// ~/.codex/skills/ are never affected.
for (const { dir } of getSkillDirectories(srcRoot, 'all')) {
const allSkills = findSkillDirs(dir);
for (const s of allSkills) {
allManagedNames.add(path.basename(s));
// Also record the frontmatter `name:` since Codex uses that as the
// target dir. Best-effort — ignore parse errors.
try {
const content = fs.readFileSync(path.join(s, 'SKILL.md'), 'utf8');
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (fmMatch) {
const nameMatch = fmMatch[1].match(/^\s*name:\s*(.+?)\s*$/m);
if (nameMatch) {
allManagedNames.add(stripWrappingQuotes(nameMatch[1]));
}
}
} catch { /* ignore */ }
}
}
for (const { dir, label } of skillDirs) {
const found = findSkillDirs(dir);
if (found.length === 0) continue;
for (const s of found) allManagedNames.add(path.basename(s));
const skills = copyStandardSkills
? found
: found.filter(s => isKernelSkill(s));
if (skills.length === 0) continue;
for (const s of skills) desiredNames.add(path.basename(s));
console.log(`\n${label} (${skills.length} skills):`);
for (const skillDir of skills) {
const skill = transformToCodexSkill(skillDir);
if (!skill) {
// transformToCodexSkill already logged the specific reason (parse
// error, missing description, platform mismatch, etc.).
console.log(` skip: ${path.basename(skillDir)} (see error above)`);
totalSkipped++;
continue;
}
const result = deploySkill(skill, target, { force, dryRun });
if (result.action === 'deploy') totalDeployed++;
else totalSkipped++;
}
}
// Post-deploy cleanup: remove any AIWG-managed skill in the target that
// isn't in the current desired set. AIWG-managed = either (a) source
// basename matches an AIWG source dir, or (b) the deployed SKILL.md
// declares `namespace: aiwg` in frontmatter. The latter catches stale
// skills from renamed sources or title-cased name fields. We never
// touch directories whose SKILL.md lacks AIWG provenance — those are
// user-authored or third-party skills sitting alongside.
let totalPruned = 0;
if (fs.existsSync(target)) {
const targetEntries = fs.readdirSync(target, { withFileTypes: true });
for (const entry of targetEntries) {
if (!entry.isDirectory()) continue;
const name = entry.name;
if (desiredNames.has(name)) continue;
let isAiwgManaged = allManagedNames.has(name);
if (!isAiwgManaged) {
// Check for the .aiwg-managed marker file (preferred — survives
// frontmatter transforms) or fall back to namespace check.
const markerFile = path.join(target, name, '.aiwg-managed');
if (fs.existsSync(markerFile)) {
isAiwgManaged = true;
} else {
const skillFile = path.join(target, name, 'SKILL.md');
if (fs.existsSync(skillFile)) {
try {
const content = fs.readFileSync(skillFile, 'utf8');
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (fmMatch) {
const fm = fmMatch[1];
if (/^\s*namespace:\s*["']?aiwg["']?\s*$/m.test(fm)) {
isAiwgManaged = true;
}
}
} catch { /* ignore unreadable; leave alone */ }
}
}
}
if (!isAiwgManaged) continue;
const full = path.join(target, name);
if (dryRun) {
console.log(` [dry-run] would prune stale skill: ${name}`);
} else {
fs.rmSync(full, { recursive: true, force: true });
}
totalPruned++;
}
}
const prunedNote = totalPruned > 0 ? `, ${totalPruned} pruned` : '';
console.log(`\nSummary: ${totalDeployed} deployed, ${totalSkipped} skipped${prunedNote}`);
if (!dryRun && totalDeployed > 0) {
console.log(`\nRestart Codex to load new skills.`);
}
})();