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
JavaScript
/**
* 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