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
248 lines • 10 kB
JavaScript
/**
* Skill Deployment Collision Detector
*
* Scans target platform directories before `aiwg use` to identify name conflicts
* between skills being deployed and existing skills already present.
*
* Severity levels:
* - none — target path does not exist, deploy silently
* - info — target exists and is owned by the same namespace, overwrite silently
* - warn — target exists and is owned by a different namespace or user, prompt user
* - error — name matches a known platform built-in or AIWG CLI command, block deployment
*
* @see adr-skill-namespace-strategy.md
* @implements #698
* @implements #804
*/
import { promises as fs } from 'fs';
import { createHash } from 'crypto';
import * as path from 'path';
// ============================================
// Platform Built-in Blocklists
// ============================================
/**
* Platform built-in commands that must never be overwritten.
* Attempting to deploy a skill with one of these names is an ERROR.
*/
const PLATFORM_BUILTINS = {
'claude': [
'help', 'clear', 'compact', 'review', 'init', 'doctor',
'memory', 'settings', 'logout', 'login', 'mcp', 'migrate',
],
'cursor': ['settings', 'chat', 'edit'],
'codex': ['help', 'run', 'exec'],
'copilot': ['help', 'explain', 'fix', 'tests', 'review'],
'windsurf': ['help', 'settings'],
'opencode': ['help', 'run'],
'warp': ['help', 'settings'],
'hermes': [],
'openclaw': [],
'factory': [],
'generic': [],
};
// Note: previously this module also blocked `aiwg-{cliCommand}` slugs (e.g.
// `aiwg-doctor`) as "shadowing" the AIWG CLI. That check was removed because
// it directly contradicted the namespace migration strategy: `/aiwg-doctor`
// IS the canonical slug for the doctor skill (the bare `doctor` collides with
// Claude's built-in). Slash commands and shell CLI commands live on different
// surfaces and do not actually conflict at runtime.
// See: docs/migration/skill-namespace-migration.md, .aiwg/architecture/adr-skill-namespace-strategy.md
// ============================================
// Ownership Attribution
// ============================================
/**
* Determine if an existing skill directory is owned by the given namespace.
*
* A skill is owned by `namespace` when ANY of:
* 1. Its SKILL.md frontmatter contains `namespace: {namespace}`
* 2. Its parent directory is named after the namespace (e.g. `.claude/skills/aiwg/`)
*
* This generalises the original AIWG-only check to support any package namespace,
* enabling correct cross-namespace collision severity for third-party packages (#804).
*
* @param skillPath - Absolute path to the skill directory
* @param namespace - Namespace to test ownership against (default: 'aiwg')
*/
export async function isOwnedByNamespace(skillPath, namespace = 'aiwg') {
// Check 1: namespace in SKILL.md frontmatter
const skillFile = path.join(skillPath, 'SKILL.md');
try {
const content = await fs.readFile(skillFile, 'utf-8');
// Match `namespace: <value>` line where value equals the queried namespace
const nsMatch = content.match(/^namespace:\s*(.+?)\s*$/m);
if (nsMatch && nsMatch[1] === namespace) {
return true;
}
}
catch {
// file doesn't exist or unreadable — not owned
}
// Check 2: deployed under a namespace subdirectory
const parentDir = path.basename(path.dirname(skillPath));
if (parentDir === namespace) {
return true;
}
return false;
}
// ============================================
// Content Hash Comparison
// ============================================
/**
* Compute MD5 hash of a file's content. Returns null if unreadable.
*/
async function fileHash(filePath) {
try {
const content = await fs.readFile(filePath);
return createHash('md5').update(content).digest('hex');
}
catch {
return null;
}
}
/**
* Check if the source skill SKILL.md matches the deployed SKILL.md by content hash.
* Returns true when both exist and have identical content (no update needed).
*/
async function skillContentUnchanged(sourceDir, deployedDir) {
const srcFile = path.join(sourceDir, 'SKILL.md');
const dstFile = path.join(deployedDir, 'SKILL.md');
const [srcHash, dstHash] = await Promise.all([fileHash(srcFile), fileHash(dstFile)]);
if (!srcHash || !dstHash)
return false;
return srcHash === dstHash;
}
// ============================================
// Collision Check
// ============================================
/**
* Check for deployment collisions before writing skills to a platform directory.
*
* Ownership comparison uses the deploying package's namespace:
* - Same namespace overwrites → `info` severity (silent upgrade)
* - Cross-namespace or user-owned overwrites → `warn` severity (user confirmation)
*
* @param options - Collision check parameters
* @returns Array of collision results, one per skill name. Only non-`none` results
* are returned (clean deployments are omitted).
*/
export async function checkCollisions(options) {
const { platform, projectPath, skillNames, skillsBaseDir, sourceSkillsDir, namespace = 'aiwg' } = options;
const platformBuiltins = new Set(PLATFORM_BUILTINS[platform] ?? []);
const results = [];
for (const skillName of skillNames) {
const targetPath = skillsBaseDir
? path.join(skillsBaseDir, skillName)
: path.join(projectPath, `.${platform}/skills`, skillName);
// Check 1: Platform built-in collision (literal slug only).
//
// The namespaced canonical slug (e.g. `aiwg-doctor`) is the actual command
// registered with the platform — it does NOT collide with the platform's bare
// built-in (`doctor`). The whole point of the `aiwg-` prefix per
// `docs/migration/skill-namespace-migration.md` is to side-step platform built-ins.
//
// Only check the literal skillName here. Bare names like `doctor` (no prefix)
// are still caught and blocked.
if (platformBuiltins.has(skillName)) {
results.push({
skillName,
targetPath,
severity: 'error',
reason: `'${skillName}' matches a ${platform} platform built-in command`,
blocksDeployment: true,
});
continue;
}
// Check 2: Target path existence
let exists = false;
try {
await fs.access(targetPath);
exists = true;
}
catch {
// doesn't exist — no collision
}
if (!exists) {
// No collision, don't add to results
continue;
}
// Check 3: Ownership of existing skill
// Same-namespace overwrites are silent upgrades (info).
// Cross-namespace or unowned overwrites require user confirmation (warn).
const ownedBySameNamespace = await isOwnedByNamespace(targetPath, namespace);
if (ownedBySameNamespace) {
// Check if content is actually unchanged — skip silently if identical
if (sourceSkillsDir) {
const sourceDir = path.join(sourceSkillsDir, skillName);
const unchanged = await skillContentUnchanged(sourceDir, targetPath);
if (unchanged) {
// Identical content — no collision, no output needed
continue;
}
}
results.push({
skillName,
targetPath,
severity: 'info',
reason: `'${skillName}' updating`,
blocksDeployment: false,
});
}
else {
results.push({
skillName,
targetPath,
severity: 'warn',
reason: `'${skillName}' already exists at ${targetPath} and is not owned by namespace '${namespace}' — will overwrite existing skill`,
blocksDeployment: false,
});
}
}
return results;
}
/**
* Format collision results as a human-readable warning block.
*
* @param results - Collision results from `checkCollisions()`
* @param options - Formatting options
* @param options.verbose - When false, suppress info-level messages (same-namespace updates)
* @returns Formatted string for CLI output, or empty string if no results
*/
export function formatCollisionReport(results, options = {}) {
if (results.length === 0)
return '';
const { verbose = false } = options;
const errors = results.filter((r) => r.severity === 'error');
const warnings = results.filter((r) => r.severity === 'warn');
const infos = results.filter((r) => r.severity === 'info');
const lines = [];
if (errors.length > 0) {
lines.push('');
lines.push('ERROR: Deployment blocked for the following skills:');
for (const r of errors) {
lines.push(` ✗ ${r.skillName}: ${r.reason}`);
}
}
if (warnings.length > 0) {
lines.push('');
lines.push('WARNING: The following skills will overwrite content from a different namespace:');
for (const r of warnings) {
lines.push(` ⚠ ${r.skillName}: ${r.reason}`);
}
lines.push('');
lines.push(' Use --force to overwrite, or --skip-conflicts to skip these skills.');
}
// Info-level (same-namespace updates) only shown in verbose mode
if (verbose && infos.length > 0 && errors.length === 0 && warnings.length === 0) {
for (const r of infos) {
lines.push(` ℹ ${r.skillName}: ${r.reason}`);
}
}
return lines.join('\n');
}
/**
* Check if any collision results block deployment.
*/
export function hasBlockingCollisions(results) {
return results.some((r) => r.blocksDeployment);
}
//# sourceMappingURL=collision-detector.js.map