UNPKG

@alavida/agentpack

Version:

Compiler-driven lifecycle CLI for source-backed agent skills

351 lines (305 loc) 11.4 kB
import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { basename, dirname, join, relative } from 'node:path'; import { compileSkillDocument } from '../compiler/skill-compiler.js'; import { extractFrontmatter, hasLegacyFrontmatterFields } from '../compiler/skill-document-parser.js'; import { NotFoundError, ValidationError } from '../../utils/errors.js'; function parseScalar(value) { const trimmed = value.trim(); if ( (trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'")) ) { return trimmed.slice(1, -1); } return trimmed; } function foldBlockScalar(lines, startIndex, baseIndent) { const values = []; let index = startIndex + 1; while (index < lines.length) { const rawLine = lines[index]; if (!rawLine.trim()) { values.push(''); index += 1; continue; } const indentMatch = rawLine.match(/^(\s*)/); const indent = indentMatch ? indentMatch[1].length : 0; if (indent <= baseIndent) break; values.push(rawLine.slice(baseIndent + 2).trimEnd()); index += 1; } const folded = values .join('\n') .split('\n\n') .map((chunk) => chunk.split('\n').join(' ').trim()) .filter((chunk, idx, arr) => chunk.length > 0 || idx < arr.length - 1) .join('\n\n') .trim(); return { value: folded, nextIndex: index }; } function ensureContainer(target, key) { if (!target[key] || typeof target[key] !== 'object' || Array.isArray(target[key])) { target[key] = {}; } return target[key]; } export function parseSkillFrontmatterFile(skillFilePath) { if (!existsSync(skillFilePath)) { throw new NotFoundError(`skill file not found: ${skillFilePath}`, { code: 'skill_not_found' }); } const content = readFileSync(skillFilePath, 'utf-8'); if (!content.startsWith('---\n')) { throw new ValidationError('SKILL.md missing frontmatter', { code: 'missing_frontmatter' }); } const fmEnd = content.indexOf('\n---', 4); if (fmEnd === -1) { throw new ValidationError('SKILL.md has unclosed frontmatter', { code: 'unclosed_frontmatter' }); } const lines = content.slice(4, fmEnd).split('\n'); const fields = {}; let activeArrayKey = null; let activeArrayTarget = null; let activeParentKey = null; for (let index = 0; index < lines.length; index += 1) { const rawLine = lines[index]; const line = rawLine.trimEnd(); if (!line.trim()) continue; const listMatch = rawLine.match(/^(\s*)-\s+(.+)$/); if (listMatch && activeArrayKey && activeArrayTarget) { activeArrayTarget[activeArrayKey].push(parseScalar(listMatch[2])); continue; } const nestedKeyMatch = rawLine.match(/^\s{2}([A-Za-z][\w-]*):\s*(.*)$/); if (nestedKeyMatch && activeParentKey) { const [, key, value] = nestedKeyMatch; const parent = ensureContainer(fields, activeParentKey); if (value === '') { parent[key] = []; activeArrayKey = key; activeArrayTarget = parent; continue; } parent[key] = parseScalar(value); activeArrayKey = null; activeArrayTarget = null; continue; } const keyMatch = rawLine.match(/^([A-Za-z][\w-]*):\s*(.*)$/); if (!keyMatch) continue; const [, key, value] = keyMatch; if (value === '>' || value === '|') { const { value: blockValue, nextIndex } = foldBlockScalar(lines, index, 0); fields[key] = blockValue; activeParentKey = null; activeArrayKey = null; activeArrayTarget = null; index = nextIndex - 1; continue; } if (value === '') { fields[key] = fields[key] && typeof fields[key] === 'object' && !Array.isArray(fields[key]) ? fields[key] : []; activeParentKey = key; activeArrayKey = Array.isArray(fields[key]) ? key : null; activeArrayTarget = Array.isArray(fields[key]) ? fields : null; continue; } fields[key] = parseScalar(value); activeParentKey = null; activeArrayKey = null; activeArrayTarget = null; } if (!fields.name) { throw new ValidationError('SKILL.md frontmatter missing "name" field', { code: 'missing_name' }); } if (!fields.description) { throw new ValidationError('SKILL.md frontmatter missing "description" field', { code: 'missing_description' }); } return { name: fields.name, description: fields.description, sources: Array.isArray(fields.metadata?.sources) ? fields.metadata.sources : (Array.isArray(fields.sources) ? fields.sources : []), requires: Array.isArray(fields.metadata?.requires) ? fields.metadata.requires : (Array.isArray(fields.requires) ? fields.requires : []), status: typeof fields.status === 'string' ? fields.status : (typeof fields.metadata?.status === 'string' ? fields.metadata.status : null), replacement: typeof fields.replacement === 'string' ? fields.replacement : (typeof fields.metadata?.replacement === 'string' ? fields.metadata.replacement : null), message: typeof fields.message === 'string' ? fields.message : (typeof fields.metadata?.message === 'string' ? fields.metadata.message : null), wraps: typeof fields.metadata?.wraps === 'string' ? fields.metadata.wraps : (typeof fields.wraps === 'string' ? fields.wraps : null), overrides: Array.isArray(fields.overrides) ? fields.overrides : (Array.isArray(fields.metadata?.overrides) ? fields.metadata.overrides : []), }; } export function normalizeDisplayPath(repoRoot, absolutePath) { return relative(repoRoot, absolutePath).split('\\').join('/'); } export function normalizeRepoPath(repoRoot, absolutePath) { return normalizeDisplayPath(repoRoot, absolutePath); } export function readPackageMetadata(packageDir) { const packageJsonPath = join(packageDir, 'package.json'); if (!existsSync(packageJsonPath)) { return { packageName: null, packageVersion: null, dependencies: {}, devDependencies: {}, files: null, repository: null, publishConfigRegistry: null, exportedSkills: null, }; } const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf-8')); return { packageName: pkg.name || null, packageVersion: pkg.version || null, dependencies: pkg.dependencies || {}, devDependencies: pkg.devDependencies || {}, files: Array.isArray(pkg.files) ? pkg.files : null, repository: pkg.repository || null, publishConfigRegistry: pkg.publishConfig?.registry || null, skillRoot: typeof pkg.agentpack?.root === 'string' ? pkg.agentpack.root : null, exportedSkills: pkg.agentpack?.skills || null, }; } export function buildCanonicalSkillRequirement(packageName, skillName) { if (!packageName || !skillName) return null; return `${packageName}:${skillName}`; } export function inferPackageRuntimeNamespace(packageName) { return packageName?.split('/').pop() || null; } export function inferSkillModuleName(skillEntry) { if (skillEntry?.isPrimary || skillEntry?.kind === 'primary') { return inferPackageRuntimeNamespace(skillEntry?.packageName || null); } if (skillEntry?.moduleName) return skillEntry.moduleName; if (skillEntry?.skillDir) return basename(skillEntry.skillDir); return null; } export function buildExpectedRuntimeSkillName(packageName, skillEntry) { const namespace = inferPackageRuntimeNamespace(packageName); if (!namespace) return skillEntry?.declaredName || skillEntry?.name || null; if (skillEntry?.isPrimary || skillEntry?.kind === 'primary') return namespace; const moduleName = inferSkillModuleName(skillEntry); return moduleName ? `${namespace}:${moduleName}` : namespace; } function readCompilerSkillExport(skillFile) { const content = readFileSync(skillFile, 'utf-8'); const { frontmatterText } = extractFrontmatter(content); if (!content.includes('```agentpack') && hasLegacyFrontmatterFields(frontmatterText)) { throw new ValidationError( 'Legacy SKILL.md authoring is not supported. Use an `agentpack` declaration block and explicit body references.', { code: 'legacy_authoring_not_supported', path: skillFile, } ); } const compiled = compileSkillDocument(content); const metadata = parseSkillFrontmatterFile(skillFile); return { name: compiled.metadata.name, description: compiled.metadata.description, sources: Object.values(compiled.sourceBindings).map((entry) => entry.sourcePath), requires: Object.values(compiled.skillImports).map((entry) => entry.target), status: metadata.status, replacement: metadata.replacement, message: metadata.message, wraps: metadata.wraps, overrides: metadata.overrides, }; } function listNestedSkillFiles(rootDir) { if (!existsSync(rootDir)) return []; const stack = [rootDir]; const files = []; while (stack.length > 0) { const current = stack.pop(); let entries = []; try { entries = readdirSync(current, { withFileTypes: true }); } catch { continue; } for (const entry of entries) { const entryPath = join(current, entry.name); if (entry.isDirectory()) { stack.push(entryPath); continue; } if (entry.name === 'SKILL.md') files.push(entryPath); } } return files.sort((a, b) => a.localeCompare(b)); } export function listPackageSkillEntries(packageDir) { const packageMetadata = readPackageMetadata(packageDir); const entries = []; const rootSkillFile = join(packageDir, 'SKILL.md'); if (existsSync(rootSkillFile)) { entries.push({ kind: 'primary', skillDir: packageDir, skillFile: rootSkillFile, relativeSkillFile: 'SKILL.md', }); } if (packageMetadata.skillRoot) { const skillRootDir = join(packageDir, packageMetadata.skillRoot); for (const skillFile of listNestedSkillFiles(skillRootDir)) { entries.push({ kind: 'named', skillDir: dirname(skillFile), skillFile, relativeSkillFile: relative(packageDir, skillFile).split('\\').join('/'), }); } } return entries; } export function readInstalledSkillExports(packageDir) { const exports = []; const skillEntries = listPackageSkillEntries(packageDir); const packageMetadata = readPackageMetadata(packageDir); for (const entry of skillEntries) { const metadata = readCompilerSkillExport(entry.skillFile); const moduleName = entry.kind === 'primary' ? inferPackageRuntimeNamespace(packageMetadata.packageName) : basename(entry.skillDir); exports.push({ declaredName: metadata.name, name: moduleName, moduleName, runtimeName: buildExpectedRuntimeSkillName(packageMetadata.packageName, { ...entry, moduleName, }), description: metadata.description, sources: metadata.sources, requires: metadata.requires, status: metadata.status, replacement: metadata.replacement, message: metadata.message, wraps: metadata.wraps, overrides: metadata.overrides, skillDir: entry.skillDir, skillFile: entry.skillFile, relativeSkillFile: entry.relativeSkillFile, isPrimary: entry.kind === 'primary', }); } return exports.sort((a, b) => a.name.localeCompare(b.name)); }