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

1,329 lines (1,192 loc) 87.5 kB
/** * Shared utilities for provider modules * * This module contains common functions used across all providers: * - File operations (ensureDir, listMdFiles, writeFile, etc.) * - Model configuration loading * - Frontmatter parsing * - Other shared utilities */ import realFs from 'fs'; import path from 'path'; import { createHash } from 'crypto'; import { createRequire } from 'module'; import { execSync as nodeExecSync } from 'child_process'; // Use graceful-fs to prevent EMFILE crashes on systems with low ulimit. // graceful-fs queues open() calls when FD pressure is detected and retries // after a backoff, transparently wrapping the native fs module. let fs; try { const require = createRequire(import.meta.url); const gracefulFs = require('graceful-fs'); gracefulFs.gracefulify(realFs); fs = realFs; } catch { // graceful-fs not available — fall back to native fs fs = realFs; } // ============================================================================ // File Operations // ============================================================================ /** * Ensure a directory exists, creating it recursively if needed * @param {string} d - Directory path * @param {boolean} dryRun - If true, skip actual directory creation */ export function ensureDir(d, dryRun = false) { if (dryRun) return; if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true }); } /** * List markdown files in a directory (non-recursive) */ export function listMdFiles(dir, excludePatterns = []) { if (!fs.existsSync(dir)) return []; const defaultExcluded = ['README.md', 'manifest.md', 'agent-template.md', 'openai-compat.md', 'factory-compat.md', 'windsurf-compat.md', 'DEVELOPMENT_GUIDE.md']; const excluded = [...defaultExcluded, ...excludePatterns]; return fs .readdirSync(dir, { withFileTypes: true }) .filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.md') && !e.name.toLowerCase().endsWith('.soul.md') && !excluded.includes(e.name)) .map((e) => path.join(dir, e.name)); } /** * List .soul.md companion files in a directory (non-recursive) */ export function listSoulFiles(dir) { if (!fs.existsSync(dir)) return []; return fs .readdirSync(dir, { withFileTypes: true }) .filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.soul.md')) .map((e) => path.join(dir, e.name)); } /** * List markdown files recursively */ export function listMdFilesRecursive(dir, excludePatterns = []) { if (!fs.existsSync(dir)) return []; const defaultExcluded = ['README.md', 'manifest.md', 'agent-template.md', 'openai-compat.md', 'factory-compat.md', 'windsurf-compat.md', 'DEVELOPMENT_GUIDE.md']; const excluded = [...defaultExcluded, ...excludePatterns]; const results = []; function scan(currentDir) { const entries = fs.readdirSync(currentDir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(currentDir, entry.name); if (entry.isDirectory() && entry.name !== 'templates') { scan(fullPath); } else if (entry.isFile() && entry.name.toLowerCase().endsWith('.md') && !excluded.includes(entry.name)) { results.push(fullPath); } } } scan(dir); return results; } /** * List skill directories (directories containing SKILL.md) */ export function listSkillDirs(dir) { if (!fs.existsSync(dir)) return []; return fs .readdirSync(dir, { withFileTypes: true }) .filter((e) => e.isDirectory() && fs.existsSync(path.join(dir, e.name, 'SKILL.md'))) .map((e) => path.join(dir, e.name)); } /** * Write a file (with dry-run support) */ export function writeFile(dest, data, dryRun) { if (dryRun) { console.log(`[dry-run] write ${dest}`); } else { fs.writeFileSync(dest, data, 'utf8'); } } // ============================================================================ // Deployment Manifest (File Ownership Tagging) // ============================================================================ // Match either form so legacy-marker files are recognized as already-managed: // <!-- aiwg:managed v... ... (legacy, line 1, breaks YAML frontmatter parsing) // # aiwg:managed v... ... (current, inside frontmatter as a YAML comment) const MANAGED_MARKER_RE = /^(?:<!-- aiwg:managed |# aiwg:managed )/m; const MANIFEST_FILENAME = '.aiwg-manifest.json'; /** * Add an `aiwg:managed vVERSION SOURCE` marker to deployed markdown content. * * For files with YAML frontmatter (start with `---\n`), inject the marker as * a YAML comment INSIDE the frontmatter. This keeps `---` on line 1, which * Claude Code (and other YAML frontmatter parsers) require to discover * agents/skills/commands. Issue #1059. * * For files without frontmatter, fall back to the legacy HTML-comment-at-top * form (no parser to break). * * Idempotent — skips if either form of the marker is already present. */ export function addManagedMarker(content, version, source) { if (MANAGED_MARKER_RE.test(content)) return content; // Frontmatter present → inject as YAML comment after the opening `---\n`. if (content.startsWith('---\n')) { return content.replace( /^---\n/, `---\n# aiwg:managed v${version} ${source}\n` ); } // No frontmatter → legacy HTML-comment-at-top form is safe. return `<!-- aiwg:managed v${version} ${source} -->\n${content}`; } /** * Compute SHA-256 hash of content (hex string). */ function contentHash(content) { return createHash('sha256').update(content).digest('hex'); } /** * Read existing sidecar manifest from a deployment directory. * Returns `{ managed: { [filename]: { hash, source, version } } }` or null. */ export function readSidecarManifest(dir) { const p = path.join(dir, MANIFEST_FILENAME); try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; } } /** * Write sidecar manifest to a deployment directory. */ export function writeSidecarManifest(dir, manifest, dryRun) { if (dryRun) return; const p = path.join(dir, MANIFEST_FILENAME); fs.writeFileSync(p, JSON.stringify(manifest, null, 2) + '\n', 'utf8'); } /** * Update sidecar manifest entries for a batch of deployed files. * Merges into existing manifest if present. * * `frameworkSlug` (optional, per-entry) records which AIWG framework * the file came from (e.g., 'forensics-complete', 'sdlc-complete'). * Used by the cross-framework collision guard (#1169) to detect * silent overwrites when two frameworks ship a file with the same * filename but different content. */ export function updateSidecarManifest(dir, deployedEntries, opts) { const { dryRun = false, version = 'unknown', source = 'bundled' } = opts; const existing = readSidecarManifest(dir) || { managed: {} }; for (const entry of deployedEntries) { const { filename, hash, frameworkSlug } = entry; const sidecarEntry = { hash: `sha256:${hash}`, source, version }; if (frameworkSlug) sidecarEntry.frameworkSlug = frameworkSlug; existing.managed[filename] = sidecarEntry; } writeSidecarManifest(dir, existing, dryRun); } // ============================================================================ // Cross-Framework Collision Detection (#1169) // ============================================================================ /** * Extract the framework slug from a source file path. * * Recognizes paths under `agentic/code/frameworks/<slug>/...` and * `agentic/code/addons/<slug>/...`. Returns null for paths outside * those namespaces (operator-authored bundles, addons under * `.aiwg/addons/`, etc.) — those don't get collision-tracked. */ export function extractFrameworkSlug(srcPath) { if (typeof srcPath !== 'string') return null; // Normalize separators for cross-platform matching const normalized = srcPath.replace(/\\/g, '/'); const m = normalized.match(/agentic\/code\/(?:frameworks|addons)\/([^/]+)\//); return m ? m[1] : null; } // ============================================================================ // Model Configuration // ============================================================================ /** * Load model configuration from models.json * Priority: Project models.json > User ~/.config/aiwg/models.json > AIWG defaults */ export function loadModelConfig(srcRoot) { const locations = [ { path: path.join(process.cwd(), 'models.json'), label: 'project' }, { path: path.join(process.env.HOME || process.env.USERPROFILE, '.config', 'aiwg', 'models.json'), label: 'user' }, { path: path.join(srcRoot, 'agentic', 'code', 'frameworks', 'sdlc-complete', 'config', 'models.json'), label: 'AIWG defaults' } ]; for (const loc of locations) { if (fs.existsSync(loc.path)) { try { const config = JSON.parse(fs.readFileSync(loc.path, 'utf8')); config._source = `${loc.label} (${loc.path})`; return config; } catch (err) { console.warn(`Warning: Could not parse models.json at ${loc.path}: ${err.message}`); } } } // Fallback to hardcoded defaults if no config found return { claude: { reasoning: { model: 'opus' }, coding: { model: 'sonnet' }, efficiency: { model: 'haiku' } }, factory: { reasoning: { model: 'claude-opus-4-6' }, coding: { model: 'claude-sonnet-4-6' }, efficiency: { model: 'claude-haiku-4-5-20251001' } }, shorthand: { 'opus': 'claude-opus-4-6', 'sonnet': 'claude-sonnet-4-6', 'haiku': 'claude-haiku-4-5-20251001', 'inherit': 'inherit' }, claude_shorthand: { 'opus': 'opus', 'opus-1m': 'opus[1m]', 'opus[1m]': 'opus[1m]', 'sonnet': 'sonnet', 'sonnet-1m': 'sonnet[1m]', 'sonnet[1m]': 'sonnet[1m]', 'haiku': 'haiku', 'inherit': 'inherit' } }; } // ============================================================================ // Frontmatter Utilities // ============================================================================ /** * Maps provider names to the platform identifiers used in skill platforms: fields. * Skills use descriptive names (e.g. "claude-code") while providers use short names (e.g. "claude"). */ const PROVIDER_TO_PLATFORM = { 'claude': 'claude-code' }; /** * Parse the platforms: field from a SKILL.md frontmatter block. * Handles both inline array (platforms: [a, b]) and multi-line list formats. * Returns null if no platforms field is present (= deploy to all providers). * Returns an empty array only if the field is explicitly empty. */ export function parseSkillPlatforms(content) { const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); if (!fmMatch) return null; const fm = fmMatch[1]; // Inline array: platforms: [claude-code, codex] or platforms: [all] const inlineMatch = fm.match(/^platforms:\s*\[([^\]]*)\]/m); if (inlineMatch) { const items = inlineMatch[1].split(',').map(s => s.trim()).filter(Boolean); if (items.length === 0 || (items.length === 1 && items[0] === 'all')) return null; return items; } // Multi-line list: // platforms: // - claude-code // - hermes const multiMatch = fm.match(/^platforms:\s*\n((?:[ \t]+-[ \t]+\S[^\n]*\n?)+)/m); if (multiMatch) { const items = multiMatch[1] .split('\n') .map(line => line.match(/^[ \t]+-[ \t]+(\S+)/)?.[1]) .filter(Boolean); return items.length > 0 ? items : null; } // platforms: key present but empty if (/^platforms:\s*$/m.test(fm)) return null; return null; // Field absent = deploy to all } /** * Returns true if a skill (given its source content) should be deployed to the given provider. * If no provider is specified, always returns true. */ export function skillMatchesProvider(content, provider) { if (!provider) return true; const platforms = parseSkillPlatforms(content); if (!platforms) return true; // No restriction const platformName = PROVIDER_TO_PLATFORM[provider] || provider; return platforms.includes(platformName) || platforms.includes(provider); } /** * Inject the target platform name into a SKILL.md frontmatter block. * * Source skills use platforms: [all] as a deployment token. * At deploy time this function replaces [all] with [<targetPlatform>] so * each deployed copy accurately reflects where it was installed. * * Explicit restriction lists (not [all]) are preserved as-is. * If no platforms: field is present, one is added. */ export function injectPlatformInContent(content, targetPlatform) { if (!targetPlatform) return content; const fmMatch = content.match(/^(---\n)([\s\S]*?)(\n---\n?)([\s\S]*)$/); if (!fmMatch) return content; const [, open, fm, close, body] = fmMatch; const injected = `platforms: [${targetPlatform}]`; // Case 1: inline [all] token → replace with target platform let updated = fm.replace(/^platforms:\s*\[all\]\n?/m, injected + '\n'); if (updated !== fm) return open + updated + close + body; // Case 2: inline explicit restriction (not [all]) → leave as-is, do not inject if (/^platforms:\s*\[(?!all\])[^\]]+\]/m.test(fm)) { return content; } // Case 3: multi-line list → replace entire block with injected value updated = fm.replace(/^platforms:\s*\n(?:[ \t]+-[ \t]+\S[^\n]*\n?)*/m, injected + '\n'); if (updated !== fm) return open + updated + close + body; // Case 4: bare `platforms:` with no value → replace updated = fm.replace(/^platforms:\s*$/m, injected); if (updated !== fm) return open + updated + close + body; // Case 5: no platforms: field at all → insert after the first frontmatter line const fmLines = fm.split('\n'); fmLines.splice(1, 0, injected); return open + fmLines.join('\n') + close + body; } /** @deprecated Use injectPlatformInContent instead */ export function stripPlatformsFromContent(content) { return injectPlatformInContent(content, null); } /** * Parse YAML frontmatter from markdown content * Returns { frontmatter: string, body: string, metadata: object } */ export function parseFrontmatter(content) { const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); if (!fmMatch) { return { frontmatter: null, body: content, metadata: {} }; } const [, frontmatter, body] = fmMatch; // Parse simple YAML key-value pairs const metadata = {}; for (const line of frontmatter.split('\n')) { const match = line.match(/^(\w+):\s*(.+)$/); if (match) { metadata[match[1]] = match[2].trim(); } } return { frontmatter, body, metadata }; } /** * Create frontmatter string from metadata object */ export function stringifyFrontmatter(metadata, body) { const lines = ['---']; for (const [key, value] of Object.entries(metadata)) { if (value !== undefined && value !== null) { lines.push(`${key}: ${value}`); } } lines.push('---'); return lines.join('\n') + '\n\n' + body.trim(); } // ============================================================================ // String Utilities // ============================================================================ /** * Convert a string to kebab-case * "Technical Researcher" -> "technical-researcher" */ export function toKebabCase(str) { if (!str) return str; return str .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); } /** * Strip JSON comments (JSONC) for parsing * Used by Factory provider for settings.json */ export function stripJsonComments(jsonc) { // Remove single-line comments let result = jsonc.replace(/\/\/.*$/gm, ''); // Remove multi-line comments result = result.replace(/\/\*[\s\S]*?\*\//g, ''); return result; } // ============================================================================ // Agent Category Inference // ============================================================================ /** * Infer agent category from name and body content * Returns: 'analysis', 'documentation', 'planning', or 'implementation' */ export function inferAgentCategory(name, body) { const normalizedName = (name || '').toLowerCase(); const normalizedBody = (body || '').toLowerCase(); // Analysis agents (read-only) if (normalizedName.includes('security') || normalizedName.includes('review') || normalizedName.includes('analyst') || normalizedName.includes('auditor')) { return 'analysis'; } // Documentation agents if (normalizedName.includes('writer') || normalizedName.includes('document') || normalizedName.includes('archivist')) { return 'documentation'; } // Planning agents if (normalizedName.includes('architect') || normalizedName.includes('planner') || normalizedName.includes('requirements') || normalizedName.includes('designer')) { return 'planning'; } // Implementation agents (full access) if (normalizedName.includes('implement') || normalizedName.includes('engineer') || normalizedName.includes('developer') || normalizedName.includes('test')) { return 'implementation'; } // Default to implementation for most flexibility return 'implementation'; } // ============================================================================ // Tool Parsing // ============================================================================ /** * Parse tools string into array */ export function parseTools(toolsString) { if (!toolsString) return []; if (toolsString.startsWith('[')) { try { return JSON.parse(toolsString); } catch (e) { return toolsString.replace(/[\[\]"']/g, '').split(/[,\s]+/).filter(Boolean); } } return toolsString.split(/[,\s]+/).filter(Boolean); } // ============================================================================ // Skill-Command Collision Detection // ============================================================================ /** * Filter out commands that share a name with a skill. * Skills are the richer format (triggers, NL routing, behavior spec) and take precedence. * * @param {string[]} commandFiles - Array of command file paths * @param {string[]} skillDirs - Array of skill directory paths * @returns {string[]} Filtered command files with collisions removed */ export function filterCommandsAgainstSkills(commandFiles, skillDirs) { if (!skillDirs.length || !commandFiles.length) return commandFiles; // Build set of skill names (directory basenames, without extension) const skillNames = new Set(skillDirs.map(d => path.basename(d))); const filtered = []; for (const f of commandFiles) { // Command name is the filename without extension const commandName = path.basename(f).replace(/\.\w+$/, ''); if (skillNames.has(commandName)) { console.log(`skip (skill precedence): command "${commandName}" — skill with same name takes precedence`); } else { filtered.push(f); } } return filtered; } function shouldReportDeployCollisions(opts = {}) { if (opts.reportCollisions === true) return true; if (opts.reportCollisions === false) return false; if (process.env.AIWG_REPORT_DEPLOY_COLLISIONS === '1') return true; if (process.env.VITEST) return false; if (process.env.NODE_ENV === 'test') return false; return true; } // ============================================================================ // File Deployment // ============================================================================ /** * Deploy files to destination directory * Handles transformation via provider's transform function */ export function deployFiles(files, destDir, opts, transformFn) { const { force = false, dryRun = false, provider = 'claude', fileExtension = '.md', injectPlatform = false } = opts; const deployVersion = opts.deployVersion || 'unknown'; const deploySource = opts.deploySource || 'bundled'; // Map of dest path → first batch entry that claimed it. Used to detect // and report cross-framework collisions within a single deploy batch // (#1169). Each value: { src, frameworkSlug } const seen = new Map(); const actions = []; // Collision report — one entry per detected cross-framework collision // (within-batch or against sidecar). Surfaced after the loop. const collisions = []; // Read sidecar manifest for hash-based skip-on-match (#749) and // cross-framework collision detection (#1169) const sidecar = readSidecarManifest(destDir); const sidecarManaged = sidecar?.managed || {}; for (const f of files) { let base = path.basename(f); // Change extension if needed if (fileExtension !== '.md' && base.endsWith('.md')) { base = base.replace(/\.md$/, fileExtension); } let dest = path.join(destDir, base); const currentFrameworkSlug = extractFrameworkSlug(f); // Read and transform source content (needed for content-equality check // and the collision-vs-duplicate distinction) const srcContent = fs.readFileSync(f, 'utf8'); let transformedContent = transformFn ? transformFn(f, srcContent, opts) : srcContent; // Inject target platform into agent .md files that use platforms: [all] if (injectPlatform && provider && /platforms:\s*\[all\]/.test(transformedContent)) { const platformName = PROVIDER_TO_PLATFORM[provider] || provider; transformedContent = injectPlatformInContent(transformedContent, platformName); } // Add managed marker for .md files (#749) if (base.endsWith('.md')) { transformedContent = addManagedMarker(transformedContent, deployVersion, deploySource); } // Compute content hash for sidecar comparison const hash = contentHash(transformedContent); // Within-batch collision check (#1169). If a previous file in this // batch already claimed this dest, distinguish: // - Same content (transform/normalize is idempotent) → silent skip // - Same framework + different content → "duplicate" (legacy reason) // - Different framework + different content → "collision" if (seen.has(dest)) { const prev = seen.get(dest); const prevContent = prev.transformedContent; if (prevContent === transformedContent) { actions.push({ type: 'skip', src: f, dest, reason: 'duplicate-identical' }); continue; } const prevSlug = prev.frameworkSlug; if (currentFrameworkSlug && prevSlug && currentFrameworkSlug !== prevSlug) { // Cross-framework collision within a single deploy batch — first // wins; second is skipped. `--force` keeps the first entry too, // since we have no principled way to pick a winner among peers. collisions.push({ dest, filename: base, existingFramework: prevSlug, existingSrc: prev.src, incomingFramework: currentFrameworkSlug, incomingSrc: f, scope: 'within-batch', }); actions.push({ type: 'skip', src: f, dest, reason: 'collision', collidingFramework: prevSlug, }); continue; } actions.push({ type: 'skip', src: f, dest, reason: 'duplicate' }); continue; } // Skip-on-match: compare hash against sidecar manifest before reading dest file (#749) // Guard: only skip if the destination file still exists on disk. cleanupOldRuleFiles // may have deleted it before deployFiles runs, so the sidecar record is stale. if (!force && sidecarManaged[base]?.hash === `sha256:${hash}` && fs.existsSync(dest)) { actions.push({ type: 'skip', src: f, dest, reason: 'hash-match' }); seen.set(dest, { src: f, frameworkSlug: currentFrameworkSlug, transformedContent }); continue; } // Cross-batch collision check against sidecar (#1169). If the dest // file is already managed by a *different* framework than this deploy, // and the new content differs, refuse to silently overwrite. if ( !force && fs.existsSync(dest) && sidecarManaged[base]?.frameworkSlug && currentFrameworkSlug && sidecarManaged[base].frameworkSlug !== currentFrameworkSlug ) { const destContent = fs.readFileSync(dest, 'utf8'); if (destContent !== transformedContent) { collisions.push({ dest, filename: base, existingFramework: sidecarManaged[base].frameworkSlug, existingSrc: null, incomingFramework: currentFrameworkSlug, incomingSrc: f, scope: 'cross-batch', }); actions.push({ type: 'skip', src: f, dest, reason: 'collision', collidingFramework: sidecarManaged[base].frameworkSlug, }); // Don't claim the dest in `seen` — we did not deploy. The sidecar // entry already holds the previous framework's record and stays. continue; } } // Fallback: check destination file content directly if (!force && fs.existsSync(dest)) { const destContent = fs.readFileSync(dest, 'utf8'); if (destContent === transformedContent) { actions.push({ type: 'skip', src: f, dest, reason: 'unchanged', hash }); seen.set(dest, { src: f, frameworkSlug: currentFrameworkSlug, transformedContent }); continue; } actions.push({ type: 'deploy', src: f, dest, content: transformedContent, reason: 'changed', hash, frameworkSlug: currentFrameworkSlug }); } else if (force && fs.existsSync(dest)) { actions.push({ type: 'deploy', src: f, dest, content: transformedContent, reason: 'forced', hash, frameworkSlug: currentFrameworkSlug }); } else { actions.push({ type: 'deploy', src: f, dest, content: transformedContent, reason: 'new', hash, frameworkSlug: currentFrameworkSlug }); } seen.set(dest, { src: f, frameworkSlug: currentFrameworkSlug, transformedContent }); } const verbose = opts.verbose === true; const deployedEntries = []; for (const a of actions) { if (a.type === 'deploy') { if (dryRun) console.log(`[dry-run] deploy ${a.src} -> ${a.dest} (${a.reason})`); else writeFile(a.dest, a.content, false); if (verbose) console.log(`deployed ${path.basename(a.src)} -> ${path.relative(process.cwd(), a.dest)} (${a.reason})`); deployedEntries.push({ filename: path.basename(a.dest), hash: a.hash, frameworkSlug: a.frameworkSlug }); } else if (a.type === 'skip') { if (verbose) console.log(`skip (${a.reason}): ${path.basename(a.dest)}`); // Preserve existing sidecar entries for skipped files if (a.hash) deployedEntries.push({ filename: path.basename(a.dest), hash: a.hash }); } } // Surface cross-framework collisions to the operator (#1169). Always // visible (not gated on verbose) because silent loss is the failure // mode this guard exists to prevent. if (collisions.length > 0 && shouldReportDeployCollisions(opts)) { const tag = force ? 'override' : 'skip'; console.warn( `\n⚠ Cross-framework deploy collision${collisions.length > 1 ? 's' : ''} detected (${collisions.length}):`, ); for (const c of collisions) { console.warn( ` ${c.filename}: ${c.existingFramework} owns this slot; ${c.incomingFramework} skipped (${c.scope})`, ); } if (!force) { console.warn( ` Re-run with --force to override (last-wins) or rename the colliding file at framework source.`, ); } } // Update sidecar manifest with deployed file hashes (#749) including // framework slug for future collision detection (#1169). if (deployedEntries.length > 0) { updateSidecarManifest(destDir, deployedEntries, { dryRun, version: deployVersion, source: deploySource }); } return actions; } /** * Deploy .soul.md companion files alongside agents. * Soul files are copied as-is (no transformation) to the same directory as agents. */ export function deploySoulCompanions(soulFiles, destDir, opts) { if (!soulFiles || soulFiles.length === 0) return []; return deployFiles(soulFiles, destDir, opts, null); } /** * Read a skill's SKILL.md frontmatter and return whether it is a * "kernel" skill — always-loaded, deploys to the platform's native * skills directory rather than the AIWG-namespaced one. Per epic * #1212. A skill opts in by setting `kernel: true` in its frontmatter. * * Note: `parseFrontmatter` keeps values as strings (no YAML coercion). * Accept both the string `"true"` and the boolean `true` so callers * are not surprised if a future parser upgrade returns booleans. */ export function isKernelSkill(skillDir) { const skillMdPath = path.join(skillDir, 'SKILL.md'); if (!fs.existsSync(skillMdPath)) return false; const content = fs.readFileSync(skillMdPath, 'utf8'); const { metadata } = parseFrontmatter(content); const v = metadata?.kernel; return v === true || v === 'true'; } /** * Deploy skills with kernel-vs-standard routing (#1212/#1216/#1217). * * Partitions `skillDirs` into kernel skills (frontmatter `kernel: true`) * and standard skills. * * **Kernel skills** copy to `kernelDestDir` (platform-native dir, * always-loaded by the platform). Small set, ~9 quickrefs. * * **Standard skills** are NOT copied per-project (#1217). They live at * `$AIWG_ROOT/agentic/code/.../skills/<name>/` and `aiwg discover` * returns absolute paths anchored there. The agent reads them directly * via the `Read` tool — no per-project mirror, no stale-copy risk. * `standardDestDir` is retained as a fallback when `$AIWG_ROOT` is not * readable, and as the cleanup target for legacy `.aiwg/skills/` * directories from rc.13 and earlier deploys. * * @param skillDirs absolute paths to source skill directories * @param standardDestDir absolute path for standard skills (legacy * per-project mirror — used only as cleanup * target by default; populated if * `opts.copyStandardSkills` is true) * @param kernelDestDir absolute path for kernel skills * (e.g., `.cursor/skills`); pass null/undefined * to disable kernel routing * @param opts standard deploy opts forwarded to * `deploySkillDir`. New optional flags: * - `copyStandardSkills` (default: false) — * force per-project copy of standard skills * (used when $AIWG_ROOT is not readable from * the agent's working directory) * * @returns `{ kernel, standardCopied, prunedFromKernelDir, * prunedFromStandardDir }` deployed/pruned counts * * Cleanup behavior: * - Kernel dir: prune any AIWG-shaped skill whose name now belongs * to the standard tier (rc.13 behavior, preserved). * - Standard dir: when standard copies are NOT being deployed this * run, prune any AIWG-shaped skill that exists under * `standardDestDir`. These are legacy per-project mirrors from * rc.13 deploys; the canonical source is now `$AIWG_ROOT`. * - User-authored skills (no SKILL.md, or names not in our deploy * manifest) survive untouched in both directories. */ export function deploySkillsWithKernelRouting( skillDirs, standardDestDir, kernelDestDir, opts, ) { // Caller opts in via `opts.copyStandardSkills` (set by `--copy-all` // CLI flag, #1219). Default (#1217) is no-copy: standard skills stay // at their source path under $AIWG_ROOT and are reached via the // artifact index. const copyStandardSkills = opts?.copyStandardSkills === true; const kernel = []; const standard = []; for (const dir of skillDirs) { if (kernelDestDir && isKernelSkill(dir)) kernel.push(dir); else standard.push(dir); } // Names of skills in the deploy manifest — bound the cleanup to // names AIWG manages so user-authored content survives. const standardNames = new Set(standard.map(p => path.basename(p))); const allSkillNames = new Set([...standardNames, ...kernel.map(p => path.basename(p))]); if (kernel.length > 0 && kernelDestDir) { ensureDir(kernelDestDir, opts?.dryRun); for (const dir of kernel) deploySkillDir(dir, kernelDestDir, opts); } // Standard tier copy is OFF by default (#1217). Only fires when the // operator explicitly opts in via `copyStandardSkills` — typically // because $AIWG_ROOT isn't readable from the agent's working dir. let standardCopied = 0; if (copyStandardSkills && standard.length > 0) { ensureDir(standardDestDir, opts?.dryRun); for (const dir of standard) { deploySkillDir(dir, standardDestDir, opts); standardCopied++; } } // Kernel-dir cleanup: prune skills whose name moved to the standard // tier (rc.13 logic). Holistic cleanup of orphaned skills (renamed or // removed sources) happens in a separate post-all-deploys step // (`pruneStaleAiwgSkills`) — running per-call here would race because // `deploySkills` may be invoked multiple times in one orchestration. let prunedFromKernelDir = 0; if (kernelDestDir && fs.existsSync(kernelDestDir) && !opts?.dryRun) { for (const entry of fs.readdirSync(kernelDestDir, { withFileTypes: true })) { if (!entry.isDirectory()) continue; const skillMd = path.join(kernelDestDir, entry.name, 'SKILL.md'); if (!fs.existsSync(skillMd)) continue; if (!standardNames.has(entry.name)) continue; const target = path.join(kernelDestDir, entry.name); try { fs.rmSync(target, { recursive: true, force: true }); prunedFromKernelDir++; if (opts?.verbose) console.log(`pruned legacy from kernel dir: ${entry.name}`); } catch (err) { if (opts?.verbose) console.warn(`Warning: could not prune ${target}: ${err.message}`); } } } // Standard-dir cleanup (#1217): when we're NOT copying standard // skills, anything AIWG-named under standardDestDir is a legacy // mirror from a rc.13-or-earlier deploy. Prune to clean up. let prunedFromStandardDir = 0; if ( !copyStandardSkills && standardDestDir && fs.existsSync(standardDestDir) && !opts?.dryRun ) { for (const entry of fs.readdirSync(standardDestDir, { withFileTypes: true })) { if (!entry.isDirectory()) continue; const skillMd = path.join(standardDestDir, entry.name, 'SKILL.md'); if (!fs.existsSync(skillMd)) continue; // Only prune skills AIWG manages — bound by the deploy manifest. if (!allSkillNames.has(entry.name)) continue; const target = path.join(standardDestDir, entry.name); try { fs.rmSync(target, { recursive: true, force: true }); prunedFromStandardDir++; if (opts?.verbose) console.log(`pruned legacy from standard dir: ${entry.name}`); } catch (err) { if (opts?.verbose) console.warn(`Warning: could not prune ${target}: ${err.message}`); } } // Try to remove the now-empty standard dir + its parent .aiwg/ // wrapper if both end up empty. Best-effort. try { const remaining = fs.readdirSync(standardDestDir); if (remaining.length === 0) { fs.rmdirSync(standardDestDir); const aiwgWrapper = path.dirname(standardDestDir); if (path.basename(aiwgWrapper) === '.aiwg') { const wrapperRemaining = fs.readdirSync(aiwgWrapper); if (wrapperRemaining.length === 0) fs.rmdirSync(aiwgWrapper); } } } catch { /* non-fatal */ } } return { kernel: kernel.length, standardCopied, prunedFromKernelDir, prunedFromStandardDir, }; } /** * Compute the global desired-kernel set by walking the entire AIWG * source tree (frameworks + addons), regardless of which deploy mode * is in flight. Used by `pruneStaleAiwgSkills` so cleanup never races * with sibling deploy invocations (`aiwg use` runs `deploy-agents.mjs` * multiple times — once per framework, once per addon batch). * * @param {string} srcRoot AIWG repo / install root * @returns {string[]} basenames of every source skill dir whose * SKILL.md frontmatter has `kernel: true` */ export function computeAllKernelNames(srcRoot) { // The caller may pass an addon/framework path (e.g. when deploying a // single addon), not the AIWG install root. Walk up until we find a // directory that contains BOTH `agentic/code/frameworks` and // `agentic/code/addons` — that's the AIWG root. const aiwgRoot = process.env.AIWG_ROOT || (() => { let cur = path.resolve(srcRoot); for (let i = 0; i < 8; i++) { if ( fs.existsSync(path.join(cur, 'agentic', 'code', 'frameworks')) && fs.existsSync(path.join(cur, 'agentic', 'code', 'addons')) ) return cur; const parent = path.dirname(cur); if (parent === cur) break; cur = parent; } return srcRoot; })(); const names = new Set(); const roots = [ path.join(aiwgRoot, 'agentic', 'code', 'frameworks'), path.join(aiwgRoot, 'agentic', 'code', 'addons'), ]; for (const root of roots) { if (!fs.existsSync(root)) continue; for (const componentEntry of fs.readdirSync(root, { withFileTypes: true })) { if (!componentEntry.isDirectory()) continue; const skillsDir = path.join(root, componentEntry.name, 'skills'); if (!fs.existsSync(skillsDir)) continue; for (const skillEntry of fs.readdirSync(skillsDir, { withFileTypes: true })) { if (!skillEntry.isDirectory()) continue; const fullPath = path.join(skillsDir, skillEntry.name); if (isKernelSkill(fullPath)) names.add(skillEntry.name); } } } return Array.from(names); } /** * Holistic post-deploy cleanup of stale AIWG-managed skills. * * Run this AFTER all `deploySkills` invocations have completed for a * given provider so the desired-name set reflects every kernel skill * deployed across all frameworks/addons. Per-call cleanup races because * `deploySkills` may be invoked multiple times in one orchestration — * this function does the cleanup once at the end. * * Identifies AIWG-managed skills via: * 1. `.aiwg-managed` marker file (preferred — set by `deploySkillDir`) * 2. Frontmatter `namespace: aiwg` (migration fallback for pre-marker * deploys; stops firing after one redeploy) * * Bounded to entries identified above, so user-authored skills next to * AIWG-managed ones are never touched. * * @param {string} kernelDestDir absolute path to the platform's kernel * skills dir (e.g. `<project>/.claude/skills/`) * @param {string[]} desiredKernelNames names of every kernel skill that * SHOULD remain (basenames of source dirs) * @param {object} opts `{ dryRun, verbose }` * @returns {number} count of pruned entries */ export function pruneStaleAiwgSkills(kernelDestDir, desiredKernelNames, opts = {}) { if (!kernelDestDir || !fs.existsSync(kernelDestDir)) return 0; if (opts.dryRun) return 0; const desired = new Set(desiredKernelNames); let pruned = 0; for (const entry of fs.readdirSync(kernelDestDir, { withFileTypes: true })) { if (!entry.isDirectory()) continue; if (desired.has(entry.name)) continue; const skillMd = path.join(kernelDestDir, entry.name, 'SKILL.md'); if (!fs.existsSync(skillMd)) continue; const marker = path.join(kernelDestDir, entry.name, '.aiwg-managed'); let isAiwgManaged = fs.existsSync(marker); if (!isAiwgManaged) { try { const content = fs.readFileSync(skillMd, 'utf8'); const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); if (fmMatch && /^\s*namespace:\s*["']?aiwg["']?\s*$/m.test(fmMatch[1])) { isAiwgManaged = true; } } catch { /* unreadable — leave alone */ } } if (!isAiwgManaged) continue; try { fs.rmSync(path.join(kernelDestDir, entry.name), { recursive: true, force: true }); pruned++; if (opts.verbose) console.log(`pruned stale AIWG skill: ${entry.name}`); } catch (err) { if (opts.verbose) console.warn(`Warning: could not prune ${entry.name}: ${err.message}`); } } return pruned; } /** * Deploy a skill directory (copy recursively). * * Platform handling (controlled by opts.provider): * - If the skill's SKILL.md has platforms: [all] → deploy to all, inject [provider] in deployed copy * - If no platforms: field → deploy to all, inject [provider] in deployed copy * - If explicit restriction list → only deploy if opts.provider is in the list; keep list in deployed copy */ export function deploySkillDir(skillDir, destDir, opts) { const { force = false, dryRun = false, provider, transformSkillMd } = opts; const verbose = opts.verbose === true; const skillName = path.basename(skillDir); // Check SKILL.md for explicit platform restriction before deploying anything const skillMdPath = path.join(skillDir, 'SKILL.md'); if (provider && fs.existsSync(skillMdPath)) { const skillContent = fs.readFileSync(skillMdPath, 'utf8'); if (!skillMatchesProvider(skillContent, provider)) { if (verbose) console.log(`skip (platform restricted): ${skillName}`); return; } } const destSkillDir = path.join(destDir, skillName); if (!dryRun) ensureDir(destSkillDir); function copyRecursive(src, dest) { const entries = fs.readdirSync(src, { withFileTypes: true }); for (const entry of entries) { const srcPath = path.join(src, entry.name); const destPath = path.join(dest, entry.name); if (entry.isDirectory()) { if (!dryRun) ensureDir(destPath); copyRecursive(srcPath, destPath); } else { let srcContent = fs.readFileSync(srcPath, 'utf8'); // Inject target platform into SKILL.md — replaces [all] token with [provider-name] if (entry.name === 'SKILL.md' && provider) { const platformName = PROVIDER_TO_PLATFORM[provider] || provider; srcContent = injectPlatformInContent(srcContent, platformName); // Provider-specific frontmatter transform (e.g. Factory remaps // commandHint.allowedTools / commandHint.model). Optional callback — // most providers leave SKILL.md alone after platform injection. if (typeof transformSkillMd === 'function') { srcContent = transformSkillMd(srcContent, opts) || srcContent; } } if (fs.existsSync(destPath)) { const destContent = fs.readFileSync(destPath, 'utf8'); if (destContent === srcContent && !force) { if (verbose) console.log(`skip (unchanged): ${path.relative(destDir, destPath)}`); continue; } } if (dryRun) { console.log(`[dry-run] deploy ${srcPath} -> ${destPath}`); } else { fs.writeFileSync(destPath, srcContent, 'utf8'); if (verbose) console.log(`deployed ${entry.name} -> ${path.relative(process.cwd(), destPath)}`); } } } } copyRecursive(skillDir, destSkillDir); // Drop a `.aiwg-managed` marker so future cleanup runs can identify // AIWG-deployed skills regardless of frontmatter shape (some providers // strip `namespace:` during transform). Cleanup keys off this presence // to safely prune renamed/removed source skills. if (!dryRun) { try { fs.writeFileSync(path.join(destSkillDir, '.aiwg-managed'), 'aiwg\n', 'utf8'); } catch { /* non-fatal */ } } if (verbose) console.log(`deployed skill: ${skillName}`); } // ============================================================================ // Workspace Initialization // ============================================================================ /** * Initialize framework-scoped workspace structure * Creates .aiwg/frameworks/{framework-id}/ directories */ export function initializeFrameworkWorkspace(target, mode, dryRun, srcRoot = null) { const aiwgBase = path.join(target, '.aiwg'); const frameworksDir = path.join(aiwgBase, 'frameworks'); const sharedDir = path.join(aiwgBase, 'shared'); const frameworkDirs = srcRoot ? getFrameworksForMode(srcRoot, mode).map(fw => ({ id: fw.id, subdirs: fw.workspaceSubdirs })) : []; // Backward-compatible fallback when source root isn't provided. if (frameworkDirs.length === 0) { if (mode === 'sdlc' || mode === 'both' || mode === 'all') { frameworkDirs.push({ id: 'sdlc-complete', subdirs: ['repo', 'projects', 'working', 'archive'] }); } if (mode === 'marketing' || mode === 'all') { frameworkDirs.push({ id: 'media-marketing-kit', subdirs: ['repo', 'campaigns', 'working', 'archive'] }); } if (mode === 'media-curator' || mode === 'all') { frameworkDirs.push({ id: 'media-curator', subdirs: ['repo', 'library', 'working', 'archive'] }); } if (mode === 'research' || mode === 'all') { frameworkDirs.push({ id: 'research-complete', subdirs: ['repo', 'corpus', 'working', 'archive'] }); } } if (frameworkDirs.length === 0) return; if (dryRun) { console.log('\n[dry-run] Would create framework-scoped workspace structure:'); console.log(`[dry-run] ${aiwgBase}/`); console.log(`[dry-run] ${frameworksDir}/`); console.log(`[dry-run] ${sharedDir}/`); for (const fw of frameworkDirs) { for (const subdir of fw.subdirs) { console.log(`[dry-run] ${path.join(frameworksDir, fw.id, subdir)}/`); } } return; } ensureDir(aiwgBase); ensureDir(frameworksDir); ensureDir(sharedDir); for (const fw of frameworkDirs) { const fwBase = path.join(frameworksDir, fw.id); ensureDir(fwBase); for (const subdir of fw.subdirs) { ensureDir(path.join(fwBase, subdir)); } } // Initialize registry.json if it doesn't exist const registryPath = path.join(frameworksDir, 'registry.json'); if (!fs.existsSync(registryPath)) { const registry = { version: '1.0.0', created: new Date().toISOString(), frameworks: frameworkDirs.map(fw => ({ id: fw.id, installed: new Date().toISOString(), version: '1.0.0' })) }; fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2), 'utf8'); console.log('Created framework registry at .aiwg/frameworks/registry.json'); } } // ============================================================================ // AGENTS.md Template Handling // ============================================================================ /** * Build a Markdown "Repo Topology" block from .aiwg/aiwg.config remotes (#998). * * Returns an empty string when there's no `remotes` block configured — agents * should fall back to the today-default behavior in that case. * * The output is a small Markdown section suitable for token substitution into * AIWG.md / AGENTS.md / similar context files. URL resolution is best-effort: * when `git remote get-url <name>` fails (not a git repo, missing remote), the * remote name is shown without a URL. * * @param {string} targetDir - Project directory (the one that owns .aiwg/aiwg.config) * @returns {string} Markdown block (with leading/trailing blank lines), or '' when absent */ export function buildRemotesTopologyBlock(targetDir) { const cfgPath = path.join(targetDir, '.aiwg', 'aiwg.config'); if (!fs.existsSync(cfgPath)) return ''; let cfg; try { cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); } catch { return ''; } if (!cfg || !cfg.remotes) return ''; // Apply the same defaults as resolveRemotes() in src/config/aiwg-config.ts. // Inlined here so the deploy path doesn't depend on the compiled TS bundle. const primary = cfg.remotes.primary || 'origin'; const issueTracker = cfg.remotes.issue_tracker || primary; const ci = cfg.remotes.ci || primary; const secondary = Array.isArray(cfg.remotes.secondary) ? cfg.remotes.secondary : []; function getUrl(remote) { try { return nodeExecSync(`git -C ${JSON.stringify(targetDir)} remote get-url ${JSON.stringify(remote)}`, { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8', }).trim(); } catch { return ''; } } const lines = []; lines.push('## Repo Topology'); lines.push(''); lines.push('Agents: respect this when picking remotes/providers. From `.aiwg/aiwg.config` `remotes` block (#994).'); lines.push(''); const primaryUrl = getUrl(primary); const primarySuffix = primaryUrl ? ` (${primaryUrl})` : ''; lines.push(`- **Primary**: \`${primary}\`${primarySuffix} — issues, PRs, CI live here`); if (issueTracker !== primary) { const u = getUrl(issueTracker); lines.push(`- **Issue tracker**: \`${issueTracker}\`${u ? ` (${u})` : ''}`); } if (ci !== primary) { const u = getUrl(ci); lines.push(`- **CI**: \`${ci}\`${u ? ` (${u})` : ''}`); } for (const sec of secondary) { if (!sec || !sec.name) continue; const u = getUrl(sec.name); const purpose = sec.purpose ? ` — ${sec.purpose}` : ''; const releaseTag = sec.push_on_release ? ' (push tags on release)' : ''; lines.push(`- **Secondary**: \`${sec.name}\`${u ? ` (${u})` : ''}${purpose}${releaseTag}`); } lines.push(''); return lines.join('\n'); } /** * Substitute the topology + count tokens in template content. Shared by the * Claude hook file and createAgentsMdFromTemplate so every consumer gets the * same {{REMOTES_TOPOLOGY}} treatment without each provider reinventing it. */ export function interpolateContextTokens(content, opts) { const counts = opts?.counts || {}; const topology = opts?.topology || ''; return content .replace(/\{\{AGENTS_COUNT\}\}/g, String(counts.agents || 0)) .replace(/\{\{COMMANDS_COUNT\}\}/g, String(counts.commands || 0)) .replace(/\{\{SKILLS_COUNT\}\}/g, String(counts.skills || 0)) .replace(/\{\{RULES_COUNT\}\}/g, String(counts.rules || 0)) .replace(/\{\{REMOTES_TOPOLOGY\}\}/g, topology); } /** * Create or update AGENTS.md from