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
262 lines • 10.2 kB
JavaScript
/**
* Namespace-aware Skill Deployment Adapter
*
* Implements per-platform namespace deployment strategy per ADR:
* .aiwg/architecture/adr-skill-namespace-strategy.md
*
* Platform groups:
* Group A (deep-recursion): Claude Code, Cursor, Codex, OpenCode, OpenClaw
* → Deploy both canonical subdir layout AND prefixed slug fallback.
* Group B (one-level): Factory AI, Warp, Windsurf
* → Deploy only the prefixed slug; no subdir (would break one-level discovery).
* Group C (unknown): GitHub Copilot
* → Deploy only the prefixed slug (safe default until recursion depth confirmed).
* Group D (mcp-skip): Hermes
* → Skip file deployment, emit guidance pointing to hermes-quickstart.md.
*
* @implements #704
* @see collision-detector.ts for pre-deployment collision checking (#697)
*/
import * as os from 'os';
import * as path from 'path';
// ============================================
// Adapter Definitions
// ============================================
/**
* Per-platform adapter table.
* Keyed by Platform value; includes a 'generic' fallback.
*/
export const NAMESPACE_ADAPTERS = {
// ── Group A — Deep Recursion ────────────────────────────────────────────
// These platforms support unlimited subdirectory recursion.
// Deploy BOTH the canonical subdir layout AND the prefixed slug fallback.
claude: {
platform: 'claude',
deploymentGroup: 'deep-recursion',
pathType: 'project',
skillsBaseDir: '.claude/skills',
subdirLayout: true,
},
cursor: {
platform: 'cursor',
deploymentGroup: 'deep-recursion',
pathType: 'project',
skillsBaseDir: '.cursor/skills',
subdirLayout: true,
},
codex: {
platform: 'codex',
deploymentGroup: 'deep-recursion',
pathType: 'home-dir',
skillsBaseDir: '.codex/skills', // resolves to ~/.codex/skills
maxNameLength: 100,
maxDescriptionLength: 500,
subdirLayout: true,
},
opencode: {
platform: 'opencode',
deploymentGroup: 'deep-recursion',
pathType: 'project',
skillsBaseDir: '.opencode/skill',
subdirLayout: true,
},
openclaw: {
platform: 'openclaw',
deploymentGroup: 'deep-recursion',
pathType: 'home-dir',
skillsBaseDir: '.openclaw/skills', // resolves to ~/.openclaw/skills
subdirLayout: true,
},
// Source-confirmed deep recursion (ADR §Platform Compatibility Matrix, #695 cycle #2):
// Factory AI: .factory/skills/, .agent/skills/, ~/.factory/skills/
// Warp: .agents/skills/, .warp/skills/, .claude/skills/, all platform dirs
// Copilot: .github/skills/, .claude/skills/, .agents/skills/, ~/.copilot/skills/
factory: {
platform: 'factory',
deploymentGroup: 'deep-recursion',
pathType: 'project',
skillsBaseDir: '.factory/skills',
appendToDescription: 'Use when relevant to the task.',
subdirLayout: true,
},
warp: {
platform: 'warp',
deploymentGroup: 'deep-recursion',
pathType: 'project',
skillsBaseDir: '.warp/skills',
subdirLayout: true,
},
copilot: {
platform: 'copilot',
deploymentGroup: 'deep-recursion',
pathType: 'project',
skillsBaseDir: '.github/skills',
subdirLayout: true,
},
// ── Group B — One-Level Subdirs ─────────────────────────────────────────
// Windsurf is the sole platform with 1-level subdirectory discovery.
// Deploy ONLY the prefixed slug — adding a second level would break discovery.
windsurf: {
platform: 'windsurf',
deploymentGroup: 'one-level',
pathType: 'project',
skillsBaseDir: '.windsurf/skills',
subdirLayout: false,
},
// ── Group D — MCP / Not Direct Deploy Target ────────────────────────────
// Hermes manages its own skill system via MCP sidecar.
// AIWG provides a template but does NOT deploy skills directly.
hermes: {
platform: 'hermes',
deploymentGroup: 'mcp-skip',
pathType: 'home-dir',
skillsBaseDir: '.hermes/skills',
subdirLayout: false,
},
// ── Generic Fallback ────────────────────────────────────────────────────
generic: {
platform: 'generic',
deploymentGroup: 'deep-recursion',
pathType: 'project',
skillsBaseDir: 'skills',
subdirLayout: true,
},
};
// ============================================
// Helpers
// ============================================
/**
* Resolve absolute skills root for a platform + project combination.
* Home-dir platforms (Codex, OpenClaw, Hermes) use os.homedir().
*/
export function resolveSkillsRoot(adapter, projectPath) {
if (adapter.pathType === 'home-dir') {
return path.join(os.homedir(), adapter.skillsBaseDir);
}
return path.join(projectPath, adapter.skillsBaseDir);
}
/**
* Compute canonical namespaced slug.
* Idempotent: if `skillName` already starts with `{namespace}-`, returns it unchanged.
*
* @example
* computePrefixedSlug('sync', 'aiwg') // → 'aiwg-sync'
* computePrefixedSlug('aiwg-sync', 'aiwg') // → 'aiwg-sync'
*/
export function computePrefixedSlug(skillName, namespace) {
const prefix = `${namespace}-`;
return skillName.startsWith(prefix) ? skillName : `${prefix}${skillName}`;
}
/**
* Apply platform-specific frontmatter mutations to SKILL.md content.
*
* Mutations applied (when configured):
* - Inject `namespace: {namespace}` into frontmatter if not present
* - Truncate `name` to `maxNameLength`
* - Append `appendToDescription` suffix to `description`
* - Truncate `description` to `maxDescriptionLength`
*
* Returns the original content unchanged when no mutations are needed.
*/
export function mutateFrontmatter(content, adapter, namespace) {
const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
if (!fmMatch)
return content;
let [, frontmatter, body] = fmMatch;
let changed = false;
// Inject namespace field if missing
if (!/^namespace:\s*/m.test(frontmatter)) {
frontmatter = `namespace: ${namespace}\n${frontmatter}`;
changed = true;
}
// Truncate name
if (adapter.maxNameLength) {
frontmatter = frontmatter.replace(/^(name:\s*)(.+)$/m, (_, key, val) => {
const truncated = val.slice(0, adapter.maxNameLength);
if (truncated !== val)
changed = true;
return `${key}${truncated}`;
});
}
// Append to description and/or truncate
if (adapter.appendToDescription || adapter.maxDescriptionLength) {
frontmatter = frontmatter.replace(/^(description:\s*)(.+)$/m, (_, key, val) => {
let desc = val;
if (adapter.appendToDescription && !desc.includes(adapter.appendToDescription)) {
desc = `${desc} ${adapter.appendToDescription}`;
changed = true;
}
if (adapter.maxDescriptionLength && desc.length > adapter.maxDescriptionLength) {
desc = desc.slice(0, adapter.maxDescriptionLength);
changed = true;
}
return `${key}${desc}`;
});
}
if (!changed)
return content;
return `---\n${frontmatter}\n---\n${body}`;
}
// ============================================
// Public API
// ============================================
/**
* Get the namespace adapter for a platform.
* Falls back to 'generic' for unrecognised platforms.
*/
export function getAdapter(platform) {
return NAMESPACE_ADAPTERS[platform] ?? NAMESPACE_ADAPTERS.generic;
}
/**
* Resolve deployment plans for a skill on a given platform.
*
* Calls `checkCollisions()` before writing should be done at the call site
* (see collision-detector.ts, #697).
*
* @param platform - Target platform
* @param projectPath - Absolute project root directory
* @param skillName - Skill folder name (e.g. 'sync', 'aiwg-sync')
* @param sourceContent - Raw content of the source SKILL.md file
* @param namespace - Namespace prefix (default: 'aiwg')
* @returns NamespaceDeployResult with plans to execute (or skip=true for Hermes)
*/
export function getDeploymentPlans(platform, projectPath, skillName, sourceContent, namespace = 'aiwg') {
const adapter = getAdapter(platform);
const plat = platform;
// Group D: MCP Skip — emit guidance, write no files
if (adapter.deploymentGroup === 'mcp-skip') {
return {
platform: plat,
skillName,
plans: [],
skip: true,
skipMessage: `Hermes skills are not deployed by aiwg use.\n` +
` See docs/integrations/hermes-quickstart.md for manual skill setup.`,
};
}
const skillsRoot = resolveSkillsRoot(adapter, projectPath);
const slug = computePrefixedSlug(skillName, namespace);
const content = mutateFrontmatter(sourceContent, adapter, namespace);
const plans = [];
// Group A: canonical subdir layout — {baseDir}/{namespace}/{slug}/SKILL.md
if (adapter.subdirLayout) {
plans.push({
targetPath: path.join(skillsRoot, namespace, slug, 'SKILL.md'),
content,
label: `${namespace}/${slug}/SKILL.md (canonical subdir)`,
});
}
// Groups A, B, C: universal prefixed slug — {baseDir}/{slug}/SKILL.md
plans.push({
targetPath: path.join(skillsRoot, slug, 'SKILL.md'),
content,
label: `${slug}/SKILL.md (universal slug)`,
});
return {
platform: plat,
skillName,
plans,
skip: false,
};
}
//# sourceMappingURL=namespace-adapter.js.map