UNPKG

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

661 lines (579 loc) 24.4 kB
/** * Claude Code Provider * * The default/primary provider for AIWG. Claude Code is the most feature-rich * provider with full support for agents, commands, skills, and rules. * * Deployment paths: * Skills are sequestered under `.claude/.aiwg/skills/` so the * platform's flat-namespace skill-listing budget doesn't truncate * them. Discovery is index-driven (epic #1212). The kernel of * always-loaded skills deploys to `.claude/skills/` so Claude Code's * native loader picks them up. Agents, commands, and rules continue * to deploy to their platform-native paths. * * - Agents: .claude/agents/ (platform-native) * - Commands: .claude/commands/ (platform-native) * - AIWG skills: .claude/.aiwg/skills/ (index-driven discovery) * - Kernel skills: .claude/skills/ (platform-native, always-loaded) * - Rules: .claude/rules/ (platform-native) * - Hooks/settings: .claude/ (platform-native) */ 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 { tmpdir } from 'os'; import { ensureDir, listMdFiles, listMdFilesRecursive, listSkillDirs, writeFile, deployFiles, deploySkillDir, deploySkillsWithKernelRouting, isKernelSkill, pruneStaleAiwgSkills, computeAllKernelNames, parseFrontmatter, initializeFrameworkWorkspace, filterAgentFiles, getAddonAgentFiles, getAddonCommandFiles, getAddonSkillDirs, getAddonRuleFiles, assembleRulesIndex, normalizeDeploymentMode, collectFrameworkArtifacts, cleanupOldRuleFiles, filterCommandsAgainstSkills, deploySoulCompanions, buildRemotesTopologyBlock, interpolateContextTokens } from './base.mjs'; // ============================================================================ // Provider Configuration // ============================================================================ export const name = 'claude'; export const aliases = []; export const paths = { agents: '.claude/agents/', commands: '.claude/commands/', // Skills are hidden under `.claude/.aiwg/skills/` so the platform's // flat-namespace skill-listing budget doesn't truncate them. // Discovery is index-driven (#1212). The kernel set deploys to // `kernelSkills` separately so Claude Code natively loads it. skills: '.claude/.aiwg/skills/', rules: '.claude/rules/', }; // Kernel skills path: always-loaded set the platform sees natively. // The rest of AIWG's skills sit at `paths.skills` and are reached // through the artifact index (epic #1212). export const kernelSkillsPath = '.claude/skills/'; export const support = { agents: 'native', commands: 'native', skills: 'native', rules: 'native' }; export const capabilities = { skills: true, rules: true, aggregatedOutput: false, yamlFormat: false, mdcFormat: false, homeDirectoryDeploy: false, projectLocalMirror: false }; // ============================================================================ // Model Handling // ============================================================================ /** * Replace model in frontmatter based on role classification * opus -> reasoning, sonnet -> coding, haiku -> efficiency */ export function replaceModelFrontmatter(content, models) { const fmStart = content.indexOf('---'); if (fmStart !== 0) return content; const fmEnd = content.indexOf('\n---', 3); if (fmEnd === -1) return content; const header = content.slice(0, fmEnd + 4); const body = content.slice(fmEnd + 4); const modelMatch = header.match(/^model:\s*([^\n]+)$/m); let newModel = null; if (modelMatch) { const orig = modelMatch[1].trim(); const clean = orig.replace(/['"]/g, ''); let role = 'coding'; if (/^opus$/i.test(clean)) role = 'reasoning'; else if (/^haiku$/i.test(clean)) role = 'efficiency'; if (role === 'reasoning') newModel = models.reasoning; else if (role === 'efficiency') newModel = models.efficiency; else newModel = models.coding; } if (!newModel) return content; const updatedHeader = header.replace(/^model:\s*[^\n]+$/m, `model: ${newModel}`); return updatedHeader + body; } /** * Map model shorthand to Claude format * For Claude, we keep opus/sonnet/haiku unless overridden */ export function mapModel(shorthand, modelCfg, modelsConfig) { // If overrides specified, use them if (modelCfg.reasoningModel || modelCfg.codingModel || modelCfg.efficiencyModel) { const clean = (shorthand || 'sonnet').toLowerCase().replace(/['"]/g, ''); if (/opus/i.test(clean)) return modelCfg.reasoningModel || 'opus'; if (/haiku/i.test(clean)) return modelCfg.efficiencyModel || 'haiku'; return modelCfg.codingModel || 'sonnet'; } // No transformation needed for Claude - keep shorthand return shorthand || 'sonnet'; } // ============================================================================ // Content Transformation // ============================================================================ /** * Transform agent content for Claude * Claude is the native format - minimal transformation needed */ export function transformAgent(srcPath, content, opts) { const { reasoningModel, codingModel, efficiencyModel } = opts; // Only transform if model overrides specified if (reasoningModel || codingModel || efficiencyModel) { const models = { reasoning: reasoningModel || 'opus', coding: codingModel || 'sonnet', efficiency: efficiencyModel || 'haiku' }; return replaceModelFrontmatter(content, models); } return content; } /** * Transform command content for Claude * Commands use same format as agents - minimal transformation */ export function transformCommand(srcPath, content, opts) { return transformAgent(srcPath, content, opts); } // ============================================================================ // Legacy Skill Cleanup // ============================================================================ /** * Claude built-in command names that must never be used as bare skill slugs. * Mirrors the list in src/smiths/skillsmith/collision-detector.ts. */ const CLAUDE_BUILTINS = new Set([ 'help', 'clear', 'compact', 'review', 'init', 'doctor', 'memory', 'settings', 'logout', 'login', 'mcp', 'migrate', ]); /** * After deploying skills, remove stale bare-named skills that collide with * Claude built-ins, provided all three conditions hold: * 1. The skill directory is owned by the aiwg namespace (namespace: aiwg in SKILL.md) * 2. The skill name is in the CLAUDE_BUILTINS set * 3. A namespaced replacement (aiwg-{name}) already exists in the same directory * * This implements the "+1 release" auto-cleanup milestone from the skill namespace * migration guide (docs/migration/skill-namespace-migration.md). */ function cleanupLegacyBuiltinCollisions(destDir, opts) { if (opts.dryRun || !fs.existsSync(destDir)) return; let entries; try { entries = fs.readdirSync(destDir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { if (!entry.isDirectory()) continue; const name = entry.name; if (!CLAUDE_BUILTINS.has(name)) continue; const skillPath = path.join(destDir, name); const namespacedPath = path.join(destDir, `aiwg-${name}`); const skillMd = path.join(skillPath, 'SKILL.md'); // Only remove when the namespaced replacement has already been deployed if (!fs.existsSync(skillMd) || !fs.existsSync(namespacedPath)) continue; let content = ''; try { content = fs.readFileSync(skillMd, 'utf8'); } catch { continue; } // Guard: only remove if the skill is owned by the aiwg namespace if (!/^namespace:\s*aiwg\s*$/m.test(content)) continue; try { fs.rmSync(skillPath, { recursive: true, force: true }); if (opts.verbose) console.log(`removed legacy skill: ${name} (superseded by aiwg-${name})`); } catch { /* non-fatal */ } } } // ============================================================================ // Deployment Functions // ============================================================================ /** * Deploy agents to .claude/agents/ */ export function deployAgents(agentFiles, targetDir, opts) { const destDir = path.join(targetDir, paths.agents); ensureDir(destDir, opts.dryRun); return deployFiles(agentFiles, destDir, { ...opts, injectPlatform: true }, transformAgent); } /** * Deploy commands to .claude/commands/ */ export function deployCommands(commandFiles, targetDir, opts) { const destDir = path.join(targetDir, paths.commands); ensureDir(destDir, opts.dryRun); return deployFiles(commandFiles, destDir, opts, transformCommand); } /** * Deploy skills. * * Skills are directories containing SKILL.md and supporting files. Two * deploy targets per epic #1212: * * - **Kernel skills** (frontmatter `kernel: true`) → `.claude/skills/` * (platform-native, always-loaded). These are the always-on * quickref / utility skills that frame the agent's interaction with * the rest of AIWG. Kept small (~10-15 entries) to fit within the * platform's flat-namespace skill-listing budget. * * - **Standard skills** → `.claude/.aiwg/skills/`. The bulk of AIWG's * skills, hidden from the platform's flat listing and discoverable * via the artifact index. */ export function deploySkills(skillDirs, targetDir, opts) { const standardDestDir = path.join(targetDir, paths.skills); const kernelDestDir = path.join(targetDir, kernelSkillsPath); // copyStandardSkills resolution: opts.copyStandardSkills (set by // `--copy-all` CLI flag, #1219). Default (#1217) is no-copy + // index-driven discovery. The deploySkillsWithKernelRouting helper // does the actual partition + cleanup. // does the same priority resolution centrally. deploySkillsWithKernelRouting(skillDirs, standardDestDir, kernelDestDir, opts); // Remove legacy bare-named skills superseded by their aiwg- prefixed replacements cleanupLegacyBuiltinCollisions(standardDestDir, opts); } /** * Deploy rules to .claude/rules/ * Deploys consolidated RULES-INDEX.md instead of individual rule files. * Cleans up old individual rule files from previous deployments. */ export function deployRules(ruleFiles, targetDir, opts) { const destDir = path.join(targetDir, paths.rules); ensureDir(destDir, opts.dryRun); // Pass incomingFiles so addon deploys with 0 rules don't wipe the main // framework's rules (#1143 mitigation; also fixes #1117 PUW-016). cleanupOldRuleFiles(destDir, { ...opts, incomingFiles: ruleFiles }); return deployFiles(ruleFiles, destDir, opts, transformCommand); } // ============================================================================ // AGENTS.md (Not typically used for Claude, but supported) // ============================================================================ export function createAgentsMd(target, srcRoot, dryRun) { // Claude Code doesn't typically use AGENTS.md since it has native agent support // But we can create one for documentation purposes if needed console.log('Claude Code uses native .claude/agents/ - AGENTS.md not required'); } // ============================================================================ // Post-Deployment // ============================================================================ export async function postDeploy(targetDir, opts) { // Initialize framework workspace structure initializeFrameworkWorkspace(targetDir, opts.mode, opts.dryRun, opts.srcRoot); // Claude-specific post-deployment (settings.json, etc.) const claudeDir = path.join(targetDir, '.claude'); const settingsPath = path.join(claudeDir, 'settings.json'); // If settings.json doesn't exist, create a minimal one if (!fs.existsSync(settingsPath) && !opts.dryRun) { ensureDir(claudeDir); const settings = { version: '1.0', created: new Date().toISOString(), aiwg: { enabled: true, mode: opts.mode || 'all' } }; fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8'); console.log('Created .claude/settings.json'); } // Hook file architecture: write AIWG.md and wire @AIWG.md into CLAUDE.md if (opts.srcRoot) { deployHookFile(targetDir, opts); } } /** * Deploy the AIWG.md hook file and add @AIWG.md directive to CLAUDE.md. * Hook file approach: AIWG content lives in AIWG.md; CLAUDE.md stays minimal * with a single @AIWG.md directive that Claude Code loads at session start. * Falls back gracefully if template is missing (older installs). */ /** * Substitute {{TOKEN}} placeholders in hook file content with deployment counts * and the resolved remotes topology (#998). * * Delegates to the shared interpolateContextTokens helper in base.mjs so the * Claude hook file and the AGENTS.md template path render the same tokens. */ function interpolateHookTokens(content, counts, targetDir) { return interpolateContextTokens(content, { counts, topology: targetDir ? buildRemotesTopologyBlock(targetDir) : '', }); } function deployHookFile(targetDir, opts) { const { srcRoot, dryRun, counts } = opts; const templatePath = path.join(srcRoot, 'agentic', 'code', 'frameworks', 'sdlc-complete', 'templates', 'project', 'AIWG.md'); const hookDest = path.join(targetDir, 'AIWG.md'); const claudeDest = path.join(targetDir, 'CLAUDE.md'); const directive = '@AIWG.md'; if (!fs.existsSync(templatePath)) return; // Build effective counts: use current-run counts where > 0, otherwise read the // actual deployed count from the filesystem. This prevents a partial deploy // (e.g. agents-only, no --skills flag) from resetting skill/command counts to // "0" in the generated AIWG.md. const effectiveCounts = { ...counts }; function countMdFiles(dir) { try { return fs.readdirSync(dir).filter(f => f.endsWith('.md')).length; } catch { return 0; } } function countDirs(dir) { try { return fs.readdirSync(dir, { withFileTypes: true }).filter(e => e.isDirectory()).length; } catch { return 0; } } if (!effectiveCounts.agents) effectiveCounts.agents = countMdFiles(path.join(targetDir, '.claude', 'agents')); if (!effectiveCounts.skills) effectiveCounts.skills = countDirs(path.join(targetDir, '.claude', 'skills')); if (!effectiveCounts.commands) effectiveCounts.commands = countMdFiles(path.join(targetDir, '.claude', 'commands')); if (!effectiveCounts.rules) effectiveCounts.rules = countMdFiles(path.join(targetDir, '.claude', 'rules')); // Write AIWG.md (always overwrite — it's generated content) if (dryRun) { console.log('[dry-run] Would write AIWG.md from template'); } else { let content = fs.readFileSync(templatePath, 'utf8'); content = interpolateHookTokens(content, effectiveCounts, targetDir); fs.writeFileSync(hookDest, content, 'utf8'); console.log('Created AIWG.md (hook file)'); } // Add @AIWG.md directive to CLAUDE.md if present but missing the directive if (fs.existsSync(claudeDest)) { const existing = fs.readFileSync(claudeDest, 'utf8'); if (!existing.includes(directive)) { if (dryRun) { console.log('[dry-run] Would add @AIWG.md directive to CLAUDE.md'); } else { // Insert directive after the first heading or at end of first paragraph const lines = existing.split('\n'); let insertAt = lines.length; for (let i = 0; i < lines.length; i++) { if (lines[i].startsWith('#')) { // Insert after the first heading block (skip blank lines after heading) let j = i + 1; while (j < lines.length && lines[j].trim() === '') j++; insertAt = j; break; } } lines.splice(insertAt, 0, '', directive, ''); fs.writeFileSync(claudeDest, lines.join('\n'), 'utf8'); console.log('Added @AIWG.md directive to CLAUDE.md'); } } } } // ============================================================================ // File Extension // ============================================================================ export function getFileExtension(type) { return '.md'; } // ============================================================================ // Main Deploy Function // ============================================================================ /** * Main deployment function for Claude provider * Orchestrates deployment of agents, commands, skills, and rules */ export async function deploy(opts) { const { srcRoot, target, mode, deployCommands: shouldDeployCommands, deploySkills: shouldDeploySkills, deployRules: shouldDeployRules, commandsOnly, skillsOnly, rulesOnly, dryRun } = opts; const verbose = opts.verbose || false; if (verbose) { console.log(`\n=== Claude Code Provider ===`); console.log(`Target: ${target}`); console.log(`Mode: ${mode}`); } // Collect source files based on mode const agentFiles = []; const commandFiles = []; const skillDirs = []; const ruleFiles = []; // Check for addon-style directory structure (direct agents/, commands/, skills/ subdirs) // This handles deployment when --source points to an addon directory const isAddonSource = fs.existsSync(path.join(srcRoot, 'agents')) || fs.existsSync(path.join(srcRoot, 'commands')) || fs.existsSync(path.join(srcRoot, 'skills')); if (isAddonSource) { // Deploy from addon-style directory structure const addonAgentsDir = path.join(srcRoot, 'agents'); if (fs.existsSync(addonAgentsDir)) { agentFiles.push(...listMdFiles(addonAgentsDir)); } if (shouldDeployCommands || commandsOnly) { const addonCommandsDir = path.join(srcRoot, 'commands'); if (fs.existsSync(addonCommandsDir)) { commandFiles.push(...listMdFiles(addonCommandsDir)); } } if (shouldDeploySkills || skillsOnly) { const addonSkillsDir = path.join(srcRoot, 'skills'); if (fs.existsSync(addonSkillsDir)) { skillDirs.push(...listSkillDirs(addonSkillsDir)); } } if (shouldDeployRules || rulesOnly) { const addonRulesDir = path.join(srcRoot, 'rules'); if (fs.existsSync(addonRulesDir)) { ruleFiles.push(...listMdFiles(addonRulesDir)); } } } const normalizedMode = normalizeDeploymentMode(mode); // All addons (dynamically discovered) if (normalizedMode === 'general' || normalizedMode === 'sdlc' || normalizedMode === 'both' || normalizedMode === 'all') { agentFiles.push(...getAddonAgentFiles(srcRoot)); if (shouldDeployCommands || commandsOnly) { commandFiles.push(...getAddonCommandFiles(srcRoot)); } if (shouldDeploySkills || skillsOnly) { skillDirs.push(...getAddonSkillDirs(srcRoot)); } if (shouldDeployRules || rulesOnly) { ruleFiles.push(...getAddonRuleFiles(srcRoot)); } } const frameworkArtifacts = collectFrameworkArtifacts(srcRoot, normalizedMode, { includeAgents: true, includeCommands: shouldDeployCommands || commandsOnly, includeSkills: shouldDeploySkills || skillsOnly, includeRules: shouldDeployRules || rulesOnly, recursiveCommands: true, consolidatedSdlcRules: true }); agentFiles.push(...frameworkArtifacts.agents); const soulFiles = [...(frameworkArtifacts.souls || [])]; commandFiles.push(...frameworkArtifacts.commands); skillDirs.push(...frameworkArtifacts.skills); ruleFiles.push(...frameworkArtifacts.rules); // Deploy based on flags — track counts for summary const counts = { agents: 0, commands: 0, skills: 0, rules: 0, souls: 0 }; if (!commandsOnly && !skillsOnly) { // Apply filters if specified const filteredAgents = filterAgentFiles(agentFiles, opts); if (verbose && (opts.filter || opts.filterRole)) { console.log(`\nFiltered from ${agentFiles.length} to ${filteredAgents.length} agents`); } if (verbose) console.log(`\nDeploying ${filteredAgents.length} agents...`); deployAgents(filteredAgents, target, opts); counts.agents = filteredAgents.length; // Deploy soul companion files alongside agents if (soulFiles.length > 0) { const destDir = path.join(target, paths.agents); if (verbose) console.log(`\nDeploying ${soulFiles.length} soul files...`); deploySoulCompanions(soulFiles, destDir, opts); counts.souls = soulFiles.length; } } // Filter commands that collide with skills (skills take precedence) const filteredCommands = (shouldDeploySkills || skillsOnly) ? filterCommandsAgainstSkills(commandFiles, skillDirs) : commandFiles; if (shouldDeployCommands || commandsOnly) { if (verbose) console.log(`\nDeploying ${filteredCommands.length} commands...`); deployCommands(filteredCommands, target, opts); counts.commands = filteredCommands.length; } if (shouldDeploySkills || skillsOnly) { if (verbose) console.log(`\nDeploying ${skillDirs.length} skills...`); deploySkills(skillDirs, target, opts); counts.skills = skillDirs.length; // Holistic cleanup of stale AIWG-managed kernel skills (renamed or // removed sources). Builds the desired-kernel set by walking the // ENTIRE source tree, not just this-call's skillDirs, because // `deploy-agents.mjs` is invoked multiple times by `aiwg use` // (once per framework + once per addon batch). Per-call cleanup // would prune kernel skills owned by a sibling call. const kernelDestDir = path.join(target, kernelSkillsPath); pruneStaleAiwgSkills(kernelDestDir, computeAllKernelNames(srcRoot), opts); } if (shouldDeployRules || rulesOnly) { // Try assembled rules index (combines all component indexes) const assembled = assembleRulesIndex(srcRoot); if (assembled) { // Write assembled index to a unique temp dir to avoid races when // multiple deployments run concurrently (e.g., parallel test workers) const tmpDir = fs.mkdtempSync(path.join(tmpdir(), 'aiwg-rules-assembly-')); const assembledPath = path.join(tmpDir, 'RULES-INDEX.md'); fs.writeFileSync(assembledPath, assembled); // Replace the sdlc RULES-INDEX.md with the assembled one; // keep any non-RULES-INDEX files (non-consolidated addon rules) const finalRuleFiles = [ assembledPath, ...ruleFiles.filter(f => path.basename(f) !== 'RULES-INDEX.md') ]; if (verbose) console.log(`\nDeploying assembled RULES-INDEX.md + ${finalRuleFiles.length - 1} additional rule files...`); deployRules(finalRuleFiles, target, opts); counts.rules = finalRuleFiles.length; // Cleanup temp directory try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (e) { /* ignore */ } } else { // Fallback: deploy individual files if (verbose) console.log(`\nDeploying ${ruleFiles.length} rules...`); deployRules(ruleFiles, target, opts); counts.rules = ruleFiles.length; } } // Post-deployment (pass counts for hook file token substitution) await postDeploy(target, { ...opts, counts }); if (verbose) { console.log('\n=== Claude deployment complete ===\n'); } else { // Clean summary output const parts = []; if (counts.agents > 0) parts.push(`${counts.agents} agents`); if (counts.souls > 0) parts.push(`${counts.souls} souls`); if (counts.commands > 0) parts.push(`${counts.commands} commands`); if (counts.skills > 0) parts.push(`${counts.skills} skills`); if (counts.rules > 0) parts.push(`${counts.rules} rules`); if (parts.length > 0) { console.log(` Deployed: ${parts.join(' ')}`); } } } // ============================================================================ // Default Export // ============================================================================ export default { name, aliases, paths, kernelSkillsPath, support, capabilities, transformAgent, transformCommand, mapModel, deployAgents, deployCommands, deploySkills, deployRules, createAgentsMd, postDeploy, getFileExtension, deploy };