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
445 lines • 16.6 kB
JavaScript
/**
* Deployment Registration
*
* Scans deployed agent and skill directories, extracts metadata from frontmatter,
* and registers them in the extension registry for discovery.
*
* @implements #56, #57
* @architecture @.aiwg/architecture/unified-extension-schema.md
* @tests @test/unit/extensions/deployment-registration.test.ts
*/
import fs from 'fs';
import path from 'path';
/**
* Parse frontmatter from markdown content
*
* Extracts YAML frontmatter between --- delimiters.
*
* @param content - Markdown content
* @returns Parsed frontmatter and remaining content
*/
function parseFrontmatter(content) {
const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n/);
if (!fmMatch) {
return { frontmatter: {}, content };
}
const yamlContent = fmMatch[1];
const remainingContent = content.slice(fmMatch[0].length);
// Simple YAML parser (basic key: value pairs)
const frontmatter = {};
const lines = yamlContent.split('\n');
for (const line of lines) {
const match = line.match(/^(\w+):\s*(.+)$/);
if (match) {
const [, key, value] = match;
// Remove quotes if present
frontmatter[key] = value.replace(/^['"]|['"]$/g, '');
}
}
return { frontmatter, content: remainingContent };
}
/**
* Extract description from markdown content
*
* Looks for first paragraph after frontmatter.
*
* @param content - Markdown content (without frontmatter)
* @returns Extracted description
*/
function extractDescription(content) {
// Find first paragraph
const paragraphMatch = content.match(/^[^\n#*-]+/m);
if (paragraphMatch) {
return paragraphMatch[0].trim().slice(0, 200); // Limit to 200 chars
}
return 'No description available';
}
/**
* Extract capabilities from agent content
*
* Parses ## Capabilities or ## Skills sections.
*
* @param content - Markdown content
* @returns Array of capabilities
*/
function extractCapabilities(content) {
const capabilitiesMatch = content.match(/##\s+(?:Capabilities|Skills)\s*\n([\s\S]*?)(?=\n##|\n$)/i);
if (!capabilitiesMatch)
return [];
const capSection = capabilitiesMatch[1];
const items = capSection.match(/^[-*]\s+(.+)$/gm);
if (!items)
return [];
return items.map(item => {
const text = item.replace(/^[-*]\s+/, '').trim();
// Extract just the capability name (before colon if present)
const colonIdx = text.indexOf(':');
return colonIdx > 0 ? text.slice(0, colonIdx).trim().toLowerCase() : text.toLowerCase();
}).slice(0, 10); // Limit to 10 capabilities
}
/**
* Extract keywords from content
*
* Simple keyword extraction from headings and first paragraph.
*
* @param content - Markdown content
* @param name - Extension name
* @returns Array of keywords
*/
function extractKeywords(content, name) {
const keywords = new Set();
// Add name words
name.toLowerCase().split(/[-\s]+/).forEach(word => {
if (word.length > 2)
keywords.add(word);
});
// Extract from headings
const headings = content.match(/^#+\s+(.+)$/gm);
if (headings) {
headings.slice(0, 5).forEach(heading => {
const text = heading.replace(/^#+\s+/, '');
text.toLowerCase().split(/[\s,]+/).forEach(word => {
if (word.length > 3 && !/^(and|the|for|with|from)$/.test(word)) {
keywords.add(word);
}
});
});
}
return Array.from(keywords).slice(0, 10);
}
/**
* Parse model specification from frontmatter
*
* Handles both simple (opus/sonnet/haiku) and full model names.
*
* @param modelValue - Model value from frontmatter
* @returns Model metadata
*/
function parseModel(modelValue) {
const modelStr = String(modelValue || 'sonnet').toLowerCase();
let tier = 'sonnet';
if (/haiku/i.test(modelStr))
tier = 'haiku';
else if (/opus/i.test(modelStr))
tier = 'opus';
return { tier };
}
/**
* Scan deployed agents directory
*
* Reads agent markdown files from the deployed directory and creates Extension
* objects with metadata extracted from frontmatter.
*
* @param agentsPath - Path to .claude/agents or equivalent
* @param provider - Provider platform name
* @param cwd - Working directory for relative path resolution
* @returns Array of agent extensions
*/
export async function scanDeployedAgents(agentsPath, provider, cwd = process.cwd()) {
const absolutePath = path.isAbsolute(agentsPath) ? agentsPath : path.join(cwd, agentsPath);
// Check if directory exists
if (!fs.existsSync(absolutePath) || !fs.statSync(absolutePath).isDirectory()) {
return [];
}
const agents = [];
const files = fs.readdirSync(absolutePath);
for (const file of files) {
if (!file.endsWith('.md'))
continue;
const filePath = path.join(absolutePath, file);
const content = fs.readFileSync(filePath, 'utf8');
const { frontmatter, content: bodyContent } = parseFrontmatter(content);
// Extract ID from filename (remove .md extension)
const id = path.basename(file, '.md');
// Extract name (prefer frontmatter, fallback to title-cased ID)
const name = String(frontmatter.name ||
id.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '));
// Extract description
const description = String(frontmatter.description || extractDescription(bodyContent));
// Extract capabilities
const capabilities = extractCapabilities(bodyContent);
// Extract keywords
const keywords = extractKeywords(bodyContent, name);
// Parse model
const model = parseModel(frontmatter.model);
// Extract tools (default to common tools)
const toolsValue = frontmatter.tools || 'Read, Write, Bash';
const tools = String(toolsValue).split(',').map(t => t.trim());
// Extract role
const role = String(frontmatter.role || frontmatter.description || description);
// Build agent extension
const agent = {
id,
type: 'agent',
name,
description,
version: String(frontmatter.version || '1.0.0'),
capabilities,
keywords,
category: String(frontmatter.category || 'agent'),
platforms: {
[provider]: 'full',
},
deployment: {
pathTemplate: `${agentsPath}/{id}.md`,
core: false,
},
metadata: {
type: 'agent',
role,
model,
tools,
},
installation: {
installedAt: new Date().toISOString(),
installedFrom: 'local',
installedPath: filePath,
enabled: true,
},
};
agents.push(agent);
}
return agents;
}
/**
* Scan deployed skills directory
*
* Reads skill directories from the deployed directory and creates Extension
* objects with metadata extracted from skill.md files.
*
* @param skillsPath - Path to .claude/skills or equivalent
* @param provider - Provider platform name
* @param cwd - Working directory for relative path resolution
* @returns Array of skill extensions
*/
export async function scanDeployedSkills(skillsPath, provider, cwd = process.cwd()) {
const absolutePath = path.isAbsolute(skillsPath) ? skillsPath : path.join(cwd, skillsPath);
// Check if directory exists
if (!fs.existsSync(absolutePath) || !fs.statSync(absolutePath).isDirectory()) {
return [];
}
const skills = [];
const entries = fs.readdirSync(absolutePath, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory())
continue;
const skillDir = path.join(absolutePath, entry.name);
const skillFile = path.join(skillDir, 'SKILL.md');
const skillFileLower = path.join(skillDir, 'skill.md');
const actualSkillFile = fs.existsSync(skillFile) ? skillFile : skillFileLower;
if (!fs.existsSync(actualSkillFile))
continue;
const content = fs.readFileSync(actualSkillFile, 'utf8');
const { frontmatter, content: bodyContent } = parseFrontmatter(content);
// Extract ID from directory name
const id = entry.name;
// Extract name
const name = String(frontmatter.name ||
id.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '));
// Extract description
const description = String(frontmatter.description || extractDescription(bodyContent));
// Extract trigger phrases
const triggersValue = frontmatter.triggers || frontmatter.triggerPhrases || '';
const triggerPhrases = String(triggersValue)
.split(',')
.map(t => t.trim())
.filter(t => t.length > 0);
// Extract tools
const toolsValue = frontmatter.tools || '';
const tools = toolsValue ? String(toolsValue).split(',').map(t => t.trim()) : undefined;
// Build skill extension
const skill = {
id,
type: 'skill',
name,
description,
version: String(frontmatter.version || '1.0.0'),
capabilities: extractCapabilities(bodyContent),
keywords: extractKeywords(bodyContent, name),
category: String(frontmatter.category || 'skill'),
platforms: {
[provider]: 'full',
},
deployment: {
pathTemplate: `${skillsPath}/{id}/skill.md`,
additionalFiles: ['references.md'].filter(f => fs.existsSync(path.join(skillDir, f))),
core: false,
},
metadata: {
type: 'skill',
triggerPhrases: triggerPhrases.length > 0 ? triggerPhrases : [`use ${id}`, name.toLowerCase()],
tools,
},
installation: {
installedAt: new Date().toISOString(),
installedFrom: 'local',
installedPath: skillDir,
enabled: true,
},
};
skills.push(skill);
}
return skills;
}
function inferKernelSkillsPath(skillsPath) {
const parsed = path.parse(skillsPath);
const rel = parsed.root ? path.relative(parsed.root, skillsPath) : skillsPath;
const segments = rel.split(/[\\/]+/).filter(Boolean);
const aiwgIdx = segments.lastIndexOf('.aiwg');
if (aiwgIdx < 0)
return null;
const next = segments[aiwgIdx + 1];
if (next !== 'skills' && next !== 'skill')
return null;
const kernelSegments = [...segments.slice(0, aiwgIdx), ...segments.slice(aiwgIdx + 1)];
const kernelPath = parsed.root
? path.join(parsed.root, ...kernelSegments)
: path.join(...kernelSegments);
return kernelPath && kernelPath !== skillsPath ? kernelPath : null;
}
function uniqueSkillPaths(skillsPath) {
const inputs = Array.isArray(skillsPath) ? skillsPath : [skillsPath];
const paths = [];
for (const p of inputs) {
if (!p)
continue;
paths.push(p);
const kernelPath = inferKernelSkillsPath(p);
if (kernelPath)
paths.push(kernelPath);
}
return [...new Set(paths)];
}
/**
* Scan deployed behaviors directory
*
* Reads behavior directories from the deployed path and creates Extension objects
* with metadata extracted from BEHAVIOR.md frontmatter.
*
* Behaviors are directories containing a BEHAVIOR.md file and optionally a scripts/
* subdirectory. On OpenClaw this is the native format; on other providers behaviors
* are emulated via hook wrappers or session injection.
*
* @param behaviorsPath - Path to deployed behaviors directory (e.g., ~/.openclaw/behaviors/)
* @param provider - Provider platform name
* @param cwd - Working directory for relative path resolution
* @returns Array of behavior extensions
*
* @implements #609
*/
export async function scanDeployedBehaviors(behaviorsPath, provider, cwd = process.cwd()) {
if (!behaviorsPath)
return [];
const absolutePath = path.isAbsolute(behaviorsPath) ? behaviorsPath : path.join(cwd, behaviorsPath);
if (!fs.existsSync(absolutePath) || !fs.statSync(absolutePath).isDirectory()) {
return [];
}
const behaviors = [];
const entries = fs.readdirSync(absolutePath, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory())
continue;
const behaviorDir = path.join(absolutePath, entry.name);
const behaviorFile = path.join(behaviorDir, 'BEHAVIOR.md');
if (!fs.existsSync(behaviorFile))
continue;
const content = fs.readFileSync(behaviorFile, 'utf8');
const { frontmatter, content: bodyContent } = parseFrontmatter(content);
const id = entry.name;
const name = String(frontmatter.name ||
id.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '));
const description = String(frontmatter.description || extractDescription(bodyContent));
const keywords = extractKeywords(bodyContent, name);
const behavior = {
id,
type: 'behavior',
name,
description,
version: String(frontmatter.version || '1.0.0'),
capabilities: [],
keywords,
category: String(frontmatter.category || 'behavior'),
platforms: {
[provider]: 'full',
},
deployment: {
pathTemplate: `${behaviorsPath}/{id}/BEHAVIOR.md`,
core: false,
},
metadata: {
type: 'behavior',
},
installation: {
installedAt: new Date().toISOString(),
installedFrom: 'local',
installedPath: behaviorDir,
enabled: true,
},
};
behaviors.push(behavior);
}
return behaviors;
}
/**
* Register deployed extensions in the registry
*
* Scans deployed agent, skill, and behavior directories, creates Extension objects,
* and registers them in the provided registry.
*
* @param registry - Extension registry to populate
* @param options - Registration options
*
* @example
* ```typescript
* import { getRegistry } from './registry.js';
* import { registerDeployedExtensions } from './deployment-registration.js';
*
* const registry = getRegistry();
* await registerDeployedExtensions(registry, {
* agentsPath: '.claude/agents',
* skillsPath: '.claude/skills',
* provider: 'claude',
* });
*
* // Now list all deployed agents
* const agents = registry.getByType('agent');
* console.log(`Deployed ${agents.length} agents`);
* ```
*/
export async function registerDeployedExtensions(registry, options) {
const { agentsPath, skillsPath, behaviorsPath, provider, cwd } = options;
// Scan and register agents
if (agentsPath) {
const agents = await scanDeployedAgents(agentsPath, provider, cwd);
for (const agent of agents) {
registry.register(agent);
}
console.log(`Registered ${agents.length} agents from ${agentsPath}`);
}
// Scan and register skills
if (skillsPath) {
let total = 0;
const perPath = [];
for (const pathToScan of uniqueSkillPaths(skillsPath)) {
const skills = await scanDeployedSkills(pathToScan, provider, cwd);
for (const skill of skills) {
registry.register(skill);
}
total += skills.length;
perPath.push(`${skills.length} from ${pathToScan}`);
}
console.log(`Registered ${total} skills (${perPath.join(', ')})`);
}
// Scan and register behaviors (#609)
if (behaviorsPath) {
const behaviors = await scanDeployedBehaviors(behaviorsPath, provider, cwd);
for (const behavior of behaviors) {
registry.register(behavior);
}
if (behaviors.length > 0) {
console.log(`Registered ${behaviors.length} behaviors from ${behaviorsPath}`);
}
}
// Commands are already registered via command definitions, so we skip scanning
}
//# sourceMappingURL=deployment-registration.js.map