/**
* Agent lifecycle CLI — status, update, diff.
*
* `soleri agent status` — health report with version, packs, vault stats.
* `soleri agent update` — OTA engine upgrade with migration support.
*/
import { join, dirname } from 'node:path';
import {
existsSync,
readFileSync,
readdirSync,
writeFileSync,
mkdirSync,
renameSync,
cpSync,
rmSync,
} from 'node:fs';
import { homedir } from 'node:os';
import { execFileSync } from 'node:child_process';
import type { Command } from 'commander';
import * as p from '@clack/prompts';
import { parse as parseYaml } from 'yaml';
import { PackLockfile, checkNpmVersion, checkVersionCompat, SOLERI_HOME } from '@soleri/core';
import {
generateClaudeMdTemplate,
generateInjectClaudeMd,
generateAgentsMd,
generateSkills,
} from '@soleri/forge/lib';
import { composeClaudeMd, getModularEngineRules, AgentYamlSchema } from '@soleri/forge/lib';
import type { AgentConfig, EngineFeature } from '@soleri/forge/lib';
import { detectAgent } from '../utils/agent-context.js';
import { installClaude } from './install.js';
/**
* Read engine.features from agent.yaml at the given agent path.
* Returns undefined if not specified (= include all modules).
*/
function readEngineFeatures(agentPath: string): EngineFeature[] | undefined {
try {
const yamlPath = join(agentPath, 'agent.yaml');
if (!existsSync(yamlPath)) return undefined;
const parsed = AgentYamlSchema.parse(parseYaml(readFileSync(yamlPath, 'utf-8')));
return parsed.engine?.features as EngineFeature[] | undefined;
} catch {
return undefined;
}
}
function readFileTreeAgentConfig(agentPath: string): AgentConfig | null {
try {
const yamlPath = join(agentPath, 'agent.yaml');
if (!existsSync(yamlPath)) return null;
const parsed = AgentYamlSchema.parse(parseYaml(readFileSync(yamlPath, 'utf-8')));
return {
id: parsed.id,
name: parsed.name,
role: parsed.role,
description: parsed.description,
domains: parsed.domains,
principles: parsed.principles,
tone: parsed.tone,
greeting: parsed.greeting,
persona: parsed.persona,
vaults: parsed.vaults,
setupTarget: parsed.setup?.target ?? 'claude',
} as AgentConfig;
} catch {
return null;
}
}
function readAgentCoreVersion(agentPath: string): string | null {
const corePkgPath = join(agentPath, 'node_modules', '@soleri', 'core', 'package.json');
if (!existsSync(corePkgPath)) return null;
try {
const pkg = JSON.parse(readFileSync(corePkgPath, 'utf-8')) as { version?: unknown };
return typeof pkg.version === 'string' ? pkg.version : null;
} catch {
return null;
}
}
export function registerAgent(program: Command): void {
const agent = program.command('agent').description('Agent lifecycle management');
// ─── status ─────────────────────────────────────────────────
agent
.command('status')
.option('--json', 'Output as JSON')
.option('--path
', 'Path to agent directory (default: cwd)')
.description('Show agent health: version, packs, vault, and update availability')
.action((opts: { json?: boolean; path?: string }) => {
const ctx = detectAgent(opts.path);
if (!ctx) {
p.log.error('No agent project detected in current directory.');
process.exit(1);
return;
}
if (ctx.format === 'filetree') {
// ─── File-tree agent (v7+) ─────────────────────────────
const yamlPath = join(ctx.agentPath, 'agent.yaml');
const yaml = parseYaml(readFileSync(yamlPath, 'utf-8'));
const agentName = yaml.name || yaml.id || 'unknown';
const agentId = yaml.id || 'unknown';
const domains: string[] = Array.isArray(yaml.domains) ? yaml.domains : [];
// Count skills (directories inside skills/)
const skillsDir = join(ctx.agentPath, 'skills');
let skillsCount = 0;
if (existsSync(skillsDir)) {
skillsCount = readdirSync(skillsDir, { withFileTypes: true }).filter((d) =>
d.isDirectory(),
).length;
}
// Resolve engine version via require.resolve
const engineVersion = readAgentCoreVersion(ctx.agentPath) ?? 'not installed';
// Check for core update
const latestCore = checkNpmVersion('@soleri/core');
// Count vault entries if db exists
const dbPath = join(ctx.agentPath, 'data', 'vault.db');
const hasVault = existsSync(dbPath);
if (opts.json) {
console.log(
JSON.stringify(
{
agent: agentName,
id: agentId,
format: 'file-tree',
engine: engineVersion,
engineLatest: latestCore,
domains,
skills: skillsCount,
vault: { exists: hasVault },
},
null,
2,
),
);
return;
}
console.log(`\n Agent: ${agentName}`);
console.log(` ID: ${agentId}`);
console.log(` Format: file-tree`);
console.log(
` Engine: @soleri/core ${engineVersion}${latestCore && latestCore !== engineVersion ? ` (update available: ${latestCore})` : ''}`,
);
console.log(` Domains: ${domains.length > 0 ? domains.join(', ') : 'none'}`);
console.log(` Skills: ${skillsCount}`);
console.log(`\n Vault: ${hasVault ? 'initialized' : 'not initialized'}`);
console.log('');
return;
}
// ─── Legacy TypeScript agent ──────────────────────────────
// Read agent package.json
const pkgPath = join(ctx.agentPath, 'package.json');
const pkg = existsSync(pkgPath) ? JSON.parse(readFileSync(pkgPath, 'utf-8')) : {};
const agentName = pkg.name || 'unknown';
const agentVersion = pkg.version || '0.0.0';
// Read @soleri/core version
const corePkgPath = join(ctx.agentPath, 'node_modules', '@soleri', 'core', 'package.json');
const coreVersion = existsSync(corePkgPath)
? JSON.parse(readFileSync(corePkgPath, 'utf-8')).version || 'unknown'
: pkg.dependencies?.['@soleri/core'] || 'not installed';
// Check for core update
const latestCore = checkNpmVersion('@soleri/core');
// Read lockfile
const lockfilePath = join(ctx.agentPath, 'soleri.lock');
const lockfile = new PackLockfile(lockfilePath);
const packs = lockfile.list();
// Count vault entries if db exists
const dbPath = join(ctx.agentPath, 'data', 'vault.db');
const hasVault = existsSync(dbPath);
if (opts.json) {
console.log(
JSON.stringify(
{
agent: agentName,
version: agentVersion,
engine: coreVersion,
engineLatest: latestCore,
packs: packs.map((pk) => ({
id: pk.id,
version: pk.version,
type: pk.type,
source: pk.source,
})),
vault: { exists: hasVault },
},
null,
2,
),
);
return;
}
console.log(`\n Agent: ${agentName} v${agentVersion}`);
console.log(
` Engine: @soleri/core ${coreVersion}${latestCore && latestCore !== coreVersion ? ` (update available: ${latestCore})` : ''}`,
);
if (packs.length > 0) {
console.log(`\n Packs (${packs.length} installed):`);
for (const pack of packs) {
const badge =
pack.source === 'npm' ? ' [npm]' : pack.source === 'built-in' ? ' [built-in]' : '';
console.log(` ${pack.id}@${pack.version} ${pack.type}${badge}`);
}
} else {
console.log('\n Packs: none installed');
}
console.log(`\n Vault: ${hasVault ? 'initialized' : 'not initialized'}`);
console.log('');
});
// ─── update ─────────────────────────────────────────────────
agent
.command('update')
.option('--check', 'Show what would change without updating')
.option('--dry-run', 'Preview migration steps')
.option('--path ', 'Path to agent directory (default: cwd)')
.description('Update agent engine to latest compatible version')
.action((opts: { check?: boolean; dryRun?: boolean; path?: string }) => {
const ctx = detectAgent(opts.path);
if (!ctx) {
p.log.error('No agent project detected in current directory.');
process.exit(1);
return;
}
// ─── File-tree agent (v7+) ────────────────────────────────
if (ctx.format === 'filetree') {
// Resolve installed @soleri/core version
const installedVersion = readAgentCoreVersion(ctx.agentPath);
// Check latest version on npm
const latestCore = checkNpmVersion('@soleri/core');
if (opts.check) {
const installed = installedVersion ?? 'unknown';
const latest = latestCore ?? 'unknown';
if (installed === latest) {
console.log(`\n Engine: @soleri/core ${installed} (up to date)`);
} else {
console.log(`\n Engine: @soleri/core ${installed} → ${latest}`);
}
console.log(` Format: file-tree (updates via template refresh)`);
console.log('');
return;
}
if (opts.dryRun) {
p.log.info('Would refresh templates (regenerate _engine.md, CLAUDE.md, sync skills)');
if (latestCore && installedVersion && latestCore !== installedVersion) {
p.log.info(
`Engine: ${installedVersion} → ${latestCore} (update @soleri/core globally to upgrade)`,
);
}
return;
}
// Run refresh: regenerate _engine.md, recompose CLAUDE.md, sync skills
const enginePath = join(ctx.agentPath, 'instructions', '_engine.md');
const claudeMdPath = join(ctx.agentPath, 'CLAUDE.md');
const agentSkillsDir = join(ctx.agentPath, 'skills');
const skillFiles = generateSkills({
id: ctx.agentId,
targetDir: existsSync(agentSkillsDir) ? agentSkillsDir : undefined,
});
// 1. Sync skills (only write forge skills — custom skills already on disk)
if (skillFiles.length > 0) {
let newCount = 0;
let updatedCount = 0;
let customSkipped = 0;
for (const [relPath, content] of skillFiles) {
// Extract skill name from path: "skills/{name}/SKILL.md" → "{name}"
const skillName = relPath.split('/')[1] ?? '';
// Custom skills (non-soleri-* prefix) are already on disk — skip writing
if (
skillName &&
!skillName.startsWith('soleri-') &&
existsSync(join(ctx.agentPath, relPath))
) {
customSkipped++;
continue;
}
const fullPath = join(ctx.agentPath, relPath);
const dirPath = dirname(fullPath);
const isNew = !existsSync(fullPath);
mkdirSync(dirPath, { recursive: true });
writeFileSync(fullPath, content, 'utf-8');
if (isNew) newCount++;
else updatedCount++;
}
const customMsg = customSkipped > 0 ? `, ${customSkipped} custom preserved` : '';
p.log.success(
`Synced ${skillFiles.length} skills (${newCount} new, ${updatedCount} updated${customMsg})`,
);
}
// 2. Regenerate _engine.md
mkdirSync(join(ctx.agentPath, 'instructions'), { recursive: true });
writeFileSync(
enginePath,
getModularEngineRules(readEngineFeatures(ctx.agentPath)),
'utf-8',
);
p.log.success(`Regenerated ${enginePath}`);
// 3. Recompose CLAUDE.md
const result = composeClaudeMd(ctx.agentPath);
writeFileSync(claudeMdPath, result.content, 'utf-8');
p.log.success(
`Regenerated ${claudeMdPath} (${result.sources.length} sources, ${result.content.length} bytes)`,
);
// 4. Show engine version status
const installed = installedVersion ?? 'unknown';
const latest = latestCore ?? 'unknown';
if (installed !== 'unknown' && latest !== 'unknown' && installed !== latest) {
p.log.info(
`Engine: ${installed} → ${latest} (run \`npm update -g @soleri/cli\` to upgrade)`,
);
} else if (installed !== 'unknown') {
p.log.info(`Engine: @soleri/core ${installed} (up to date)`);
}
p.log.info('File-tree agents update by refreshing templates from the installed engine.');
return;
}
// ─── Legacy TypeScript agent ──────────────────────────────
const pkgPath = join(ctx.agentPath, 'package.json');
if (!existsSync(pkgPath)) {
p.log.error('No package.json found in agent directory.');
process.exit(1);
return;
}
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
const currentRange = pkg.dependencies?.['@soleri/core'] || '';
const latestCore = checkNpmVersion('@soleri/core');
if (!latestCore) {
p.log.error('Could not check npm for latest @soleri/core version.');
process.exit(1);
return;
}
// Check compatibility
const compatible = checkVersionCompat(latestCore, currentRange);
if (opts.check) {
console.log(`\n Current: @soleri/core ${currentRange}`);
console.log(` Latest: @soleri/core ${latestCore}`);
console.log(` Compatible: ${compatible ? 'yes' : 'no (range: ' + currentRange + ')'}`);
console.log('');
return;
}
if (opts.dryRun) {
p.log.info(`Would update @soleri/core to ${latestCore}`);
p.log.info('Would run: npm install @soleri/core@' + latestCore);
return;
}
const s = p.spinner();
s.start(`Updating @soleri/core to ${latestCore}...`);
try {
execFileSync('npm', ['install', `@soleri/core@${latestCore}`], {
cwd: ctx.agentPath,
stdio: 'pipe',
timeout: 120_000,
});
s.stop(`Updated to @soleri/core@${latestCore}`);
p.log.info('Run `soleri test` to verify the update.');
} catch (err) {
s.stop('Update failed');
p.log.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
});
// ─── refresh ────────────────────────────────────────────────
agent
.command('refresh')
.option('--dry-run', 'Preview what would change without writing')
.option('--skip-skills', 'Skip skill sync (only regenerate activation files)')
.option('--path ', 'Path to agent directory (default: cwd)')
.description('Regenerate activation files and sync skills from latest forge templates')
.action((opts: { dryRun?: boolean; skipSkills?: boolean; path?: string }) => {
const ctx = detectAgent(opts.path);
if (!ctx) {
p.log.error('No agent project detected in current directory.');
process.exit(1);
return;
}
// ─── File-tree agent (v7+) ────────────────────────────────
if (ctx.format === 'filetree') {
const enginePath = join(ctx.agentPath, 'instructions', '_engine.md');
const claudeMdPath = join(ctx.agentPath, 'CLAUDE.md');
const agentsMdPath = join(ctx.agentPath, 'AGENTS.md');
const config = readFileTreeAgentConfig(ctx.agentPath);
if (!config) {
p.log.error('Could not parse agent.yaml for AGENTS.md regeneration.');
process.exit(1);
return;
}
// Generate skills from latest forge templates (preserve custom skills)
const ftSkillsDir = join(ctx.agentPath, 'skills');
const skillFiles = opts.skipSkills
? []
: generateSkills({
id: ctx.agentId,
targetDir: existsSync(ftSkillsDir) ? ftSkillsDir : undefined,
});
if (opts.dryRun) {
p.log.info(`Would regenerate: ${enginePath}`);
p.log.info(`Would regenerate: ${claudeMdPath}`);
p.log.info(`Would regenerate: ${agentsMdPath}`);
if (skillFiles.length > 0) {
const newSkills = skillFiles.filter(
([relPath]) => !existsSync(join(ctx.agentPath, relPath)),
);
const updatedSkills = skillFiles.filter(([relPath]) =>
existsSync(join(ctx.agentPath, relPath)),
);
p.log.info(
`Skills: ${skillFiles.length} total (${newSkills.length} new, ${updatedSkills.length} updated)`,
);
for (const [relPath] of newSkills) {
p.log.info(` + ${relPath}`);
}
}
p.log.info(`Agent: ${ctx.agentId} (file-tree format)`);
return;
}
// 1. Sync skills from forge templates (skip writing custom skills already on disk)
if (skillFiles.length > 0) {
let newCount = 0;
let updatedCount = 0;
let customSkipped = 0;
for (const [relPath, content] of skillFiles) {
const skillName = relPath.split('/')[1] ?? '';
// Custom skills already on disk — don't overwrite
if (
skillName &&
!skillName.startsWith('soleri-') &&
existsSync(join(ctx.agentPath, relPath))
) {
customSkipped++;
continue;
}
const fullPath = join(ctx.agentPath, relPath);
const dirPath = dirname(fullPath);
const isNew = !existsSync(fullPath);
mkdirSync(dirPath, { recursive: true });
writeFileSync(fullPath, content, 'utf-8');
if (isNew) newCount++;
else updatedCount++;
}
const customMsg = customSkipped > 0 ? `, ${customSkipped} custom preserved` : '';
p.log.success(
`Synced ${skillFiles.length} skills (${newCount} new, ${updatedCount} updated${customMsg})`,
);
}
// 2. Regenerate _engine.md from latest shared-rules
mkdirSync(join(ctx.agentPath, 'instructions'), { recursive: true });
writeFileSync(
enginePath,
getModularEngineRules(readEngineFeatures(ctx.agentPath)),
'utf-8',
);
p.log.success(`Regenerated ${enginePath}`);
// 3. Recompose CLAUDE.md from agent.yaml + instructions + workflows + skills
const result = composeClaudeMd(ctx.agentPath);
writeFileSync(claudeMdPath, result.content, 'utf-8');
p.log.success(
`Regenerated ${claudeMdPath} (${result.sources.length} sources, ${result.content.length} bytes)`,
);
// 4. Regenerate AGENTS.md for Codex/OpenCode
const agentsMd = generateAgentsMd(config);
writeFileSync(agentsMdPath, agentsMd, 'utf-8');
p.log.success(`Regenerated ${agentsMdPath} (${agentsMd.length} bytes)`);
return;
}
// ─── Legacy TypeScript agent ──────────────────────────────
const config = readAgentConfig(ctx.agentPath, ctx.agentId);
if (!config) {
p.log.error('Could not read agent config from persona.ts and entry point.');
process.exit(1);
return;
}
const contentPath = join(ctx.agentPath, 'src', 'activation', 'claude-md-content.ts');
const injectPath = join(ctx.agentPath, 'src', 'activation', 'inject-claude-md.ts');
const newContent = generateClaudeMdTemplate(config);
const newInject = generateInjectClaudeMd(config);
// Generate skills from latest forge templates (preserve custom skills)
const legacySkillsDir = join(ctx.agentPath, 'skills');
const skillFiles = opts.skipSkills
? []
: generateSkills({
...config,
targetDir: existsSync(legacySkillsDir) ? legacySkillsDir : undefined,
});
if (opts.dryRun) {
p.log.info(`Would regenerate: ${contentPath}`);
p.log.info(`Would regenerate: ${injectPath}`);
p.log.info(`Agent: ${config.name} (${config.domains.length} domains)`);
p.log.info(`Domains: ${config.domains.join(', ')}`);
if (skillFiles.length > 0) {
const newSkills = skillFiles.filter(
([relPath]) => !existsSync(join(ctx.agentPath, relPath)),
);
const updatedSkills = skillFiles.filter(([relPath]) =>
existsSync(join(ctx.agentPath, relPath)),
);
p.log.info(
`Skills: ${skillFiles.length} total (${newSkills.length} new, ${updatedSkills.length} updated)`,
);
for (const [relPath] of newSkills) {
p.log.info(` + ${relPath}`);
}
}
return;
}
// Write activation files
writeFileSync(contentPath, newContent, 'utf-8');
writeFileSync(injectPath, newInject, 'utf-8');
p.log.success(`Regenerated ${contentPath}`);
p.log.success(`Regenerated ${injectPath}`);
// Sync skills
if (skillFiles.length > 0) {
let newCount = 0;
let updatedCount = 0;
for (const [relPath, content] of skillFiles) {
const fullPath = join(ctx.agentPath, relPath);
const dirPath = join(fullPath, '..');
const isNew = !existsSync(fullPath);
mkdirSync(dirPath, { recursive: true });
writeFileSync(fullPath, content, 'utf-8');
if (isNew) newCount++;
else updatedCount++;
}
p.log.success(
`Synced ${skillFiles.length} skills (${newCount} new, ${updatedCount} updated)`,
);
}
p.log.info('Run `npm run build` to compile, then re-inject CLAUDE.md.');
});
// ─── diff ───────────────────────────────────────────────────
agent
.command('diff')
.option('--path ', 'Path to agent directory (default: cwd)')
.description('Show drift between agent templates and latest engine templates')
.action((opts: { path?: string }) => {
const ctx = detectAgent(opts.path);
if (!ctx) {
p.log.error('No agent project detected in current directory.');
process.exit(1);
return;
}
p.log.info('Template diff is available after `soleri agent update --check`.');
p.log.info('Full template comparison will be added in a future release.');
});
// ─── capabilities ──────────────────────────────────────────
agent
.command('capabilities')
.description('List all capabilities declared by installed packs')
.action(() => {
const ctx = detectAgent();
if (!ctx) {
p.log.error('No agent project detected in current directory.');
process.exit(1);
return;
}
const lockfilePath = join(ctx.agentPath, 'soleri.lock');
const lockfile = new PackLockfile(lockfilePath);
const packs = lockfile.list();
if (packs.length === 0) {
console.log('\n No packs installed.\n');
return;
}
let totalCapabilities = 0;
let packsWithCaps = 0;
for (const pack of packs) {
// Read soleri-pack.json from the installed pack directory
const manifestPath = join(pack.directory, 'soleri-pack.json');
if (!existsSync(manifestPath)) continue;
let manifest: { capabilities?: Array<{ id: string; description: string }> };
try {
manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
} catch {
continue;
}
const caps = manifest.capabilities ?? [];
if (caps.length === 0) continue;
packsWithCaps++;
totalCapabilities += caps.length;
console.log(`\n ${pack.id} (${caps.length}):`);
// Group by domain (first segment of capability ID)
const grouped = new Map();
for (const cap of caps) {
const domain = cap.id.split('.')[0];
const list = grouped.get(domain) ?? [];
list.push(cap.id);
grouped.set(domain, list);
}
for (const [, ids] of grouped) {
console.log(` ${ids.join(', ')}`);
}
}
if (totalCapabilities === 0) {
console.log('\n No capabilities declared by any installed pack.\n');
} else {
console.log(
`\n Total: ${totalCapabilities} capabilities across ${packsWithCaps} pack(s)\n`,
);
}
});
// ─── migrate ──────────────────────────────────────────────
// Temporary command — moves agent data from ~/.{agentId}/ to ~/.soleri/{agentId}/.
// Will be removed in the next major version after all users migrate.
agent
.command('migrate')
.argument('', 'Agent ID to migrate (e.g. ernesto, salvador)')
.option('--dry-run', 'Preview what would be moved without executing')
.description('Move agent data from ~/.{agentId}/ to ~/.soleri/{agentId}/ (one-time migration)')
.action((agentId: string, opts: { dryRun?: boolean }) => {
const legacyHome = join(homedir(), `.${agentId}`);
const newHome = join(SOLERI_HOME, agentId);
// Data files to migrate (relative to agent home)
const dataFiles = [
'vault.db',
'vault.db-shm',
'vault.db-wal',
'plans.json',
'keys.json',
'flags.json',
];
const dataDirs = ['templates'];
// Check if legacy data exists
if (!existsSync(legacyHome)) {
p.log.info(`No legacy data found at ${legacyHome} — nothing to migrate.`);
return;
}
// Check if already migrated
if (existsSync(join(newHome, 'vault.db'))) {
p.log.warn(`Data already exists at ${newHome}/vault.db — migration may have already run.`);
p.log.info('If you want to force re-migration, remove the new directory first.');
return;
}
// Discover what to move
const toMove: Array<{ src: string; dst: string; type: 'file' | 'dir' }> = [];
for (const file of dataFiles) {
const src = join(legacyHome, file);
if (existsSync(src)) {
toMove.push({ src, dst: join(newHome, file), type: 'file' });
}
}
for (const dir of dataDirs) {
const src = join(legacyHome, dir);
if (existsSync(src)) {
toMove.push({ src, dst: join(newHome, dir), type: 'dir' });
}
}
if (toMove.length === 0) {
p.log.info(`No data files found in ${legacyHome} — nothing to migrate.`);
return;
}
// Preview
console.log(`\n Migration: ${legacyHome} → ${newHome}\n`);
for (const item of toMove) {
const label = item.type === 'dir' ? '(dir) ' : '';
console.log(` ${label}${item.src} → ${item.dst}`);
}
console.log('');
if (opts.dryRun) {
p.log.info(
`Dry run — ${toMove.length} items would be moved. Run without --dry-run to execute.`,
);
return;
}
// Execute migration
const s = p.spinner();
s.start('Migrating agent data...');
try {
// Create new home directory
mkdirSync(newHome, { recursive: true });
let moved = 0;
for (const item of toMove) {
mkdirSync(dirname(item.dst), { recursive: true });
try {
// Try atomic rename first (same filesystem)
renameSync(item.src, item.dst);
} catch {
// Cross-filesystem: copy then remove
if (item.type === 'dir') {
cpSync(item.src, item.dst, { recursive: true });
rmSync(item.src, { recursive: true });
} else {
cpSync(item.src, item.dst);
rmSync(item.src);
}
}
moved++;
}
s.stop(`Migrated ${moved} items to ${newHome}`);
// Detect agent definition (agent.yaml) to re-register MCP
const agentYaml = join(newHome, 'agent.yaml');
const legacyAgentYaml = join(legacyHome, 'agent.yaml');
if (existsSync(agentYaml) || existsSync(legacyAgentYaml)) {
// If agent.yaml is still in legacy dir, move it too
if (!existsSync(agentYaml) && existsSync(legacyAgentYaml)) {
p.log.info(
'Note: agent.yaml is still at the old location. Move the entire agent folder if needed.',
);
}
}
// Re-register MCP pointing to new location
const agentDir = existsSync(agentYaml) ? newHome : legacyHome;
if (existsSync(join(agentDir, 'agent.yaml'))) {
installClaude(agentId, agentDir, true);
p.log.success('MCP registration updated to new path.');
}
p.log.info(
`Legacy directory preserved at ${legacyHome} (safe to remove manually after verifying).`,
);
} catch (err) {
s.stop('Migration failed');
p.log.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
});
// ─── validate ──────────────────────────────────────────────
agent
.command('validate')
.description('Validate flow capability requirements against installed packs')
.action(() => {
const ctx = detectAgent();
if (!ctx) {
p.log.error('No agent project detected in current directory.');
process.exit(1);
return;
}
// Collect all capability IDs from installed packs
const lockfilePath = join(ctx.agentPath, 'soleri.lock');
const lockfile = new PackLockfile(lockfilePath);
const packs = lockfile.list();
const installedCapabilities = new Set();
for (const pack of packs) {
const manifestPath = join(pack.directory, 'soleri-pack.json');
if (!existsSync(manifestPath)) continue;
let manifest: { capabilities?: Array<{ id: string }> };
try {
manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
} catch {
continue;
}
for (const cap of manifest.capabilities ?? []) {
installedCapabilities.add(cap.id);
}
}
// Load flow YAML files from the agent's data/flows/ directory
const flowsDir = join(ctx.agentPath, 'data', 'flows');
if (!existsSync(flowsDir)) {
console.log('\n No data/flows/ directory found.\n');
return;
}
const flowFiles = readdirSync(flowsDir).filter((f: string) => f.endsWith('.flow.yaml'));
if (flowFiles.length === 0) {
console.log('\n No flow files found in data/flows/.\n');
return;
}
let fullyAvailable = 0;
let degradedCount = 0;
console.log('');
for (const file of flowFiles) {
const content = readFileSync(join(flowsDir, file), 'utf-8');
// Simple YAML parsing for needs: fields
const needed = new Set();
const lines = content.split('\n');
let inNeeds = false;
for (const line of lines) {
if (/^\s+needs:\s*$/.test(line)) {
inNeeds = true;
continue;
}
if (inNeeds) {
const match = line.match(/^\s+-\s+(.+)$/);
if (match) {
needed.add(match[1].trim().replace(/^['"]|['"]$/g, ''));
} else {
inNeeds = false;
}
}
// Inline array format: needs: [a, b, c]
const inlineMatch = line.match(/^\s+needs:\s*\[([^\]]+)\]/);
if (inlineMatch) {
for (const cap of inlineMatch[1].split(',')) {
needed.add(cap.trim().replace(/^['"]|['"]$/g, ''));
}
}
}
// Extract flow id from file content
const idMatch = content.match(/^id:\s*(.+)$/m);
const flowName = idMatch ? idMatch[1].trim() : file.replace('.flow.yaml', '');
if (needed.size === 0) {
console.log(` ${flowName}: (no capabilities declared)`);
continue;
}
const missing = [...needed].filter((cap) => !installedCapabilities.has(cap));
if (missing.length === 0) {
console.log(` ${flowName}: \u2713 all ${needed.size} capabilities available`);
fullyAvailable++;
} else {
console.log(` ${flowName}: \u26A0 ${missing.length} missing (${missing.join(', ')})`);
degradedCount++;
}
}
console.log(
`\n ${flowFiles.length} flows checked: ${fullyAvailable} fully satisfied, ${degradedCount} degraded\n`,
);
});
}
/**
* Reconstruct an AgentConfig from an existing scaffolded agent.
*
* Reads persona from src/activation/persona.ts (PERSONA constant)
* and domains from src/index.ts (createDomainFacades call).
*/
function readAgentConfig(agentPath: string, agentId: string): AgentConfig | null {
// Read persona.ts source to extract PERSONA fields
// Try both locations: v6+ (src/identity/) and v5 (src/activation/)
const personaCandidates = [
join(agentPath, 'src', 'identity', 'persona.ts'),
join(agentPath, 'src', 'activation', 'persona.ts'),
];
const personaPath = personaCandidates.find((candidate) => existsSync(candidate));
if (!personaPath) return null;
const personaSrc = readFileSync(personaPath, 'utf-8');
const name = extractStringField(personaSrc, 'name') ?? agentId;
const role = extractStringField(personaSrc, 'role') ?? '';
const description = extractStringField(personaSrc, 'description') ?? '';
const tone =
(extractStringField(personaSrc, 'tone') as 'precise' | 'mentor' | 'pragmatic') ?? 'pragmatic';
const greeting = extractStringField(personaSrc, 'greeting') ?? `Hello! I'm ${name}.`;
const principles = extractArrayField(personaSrc, 'principles');
// Read domains from entry point
const indexPath = join(agentPath, 'src', 'index.ts');
const domains = existsSync(indexPath) ? extractDomains(readFileSync(indexPath, 'utf-8')) : [];
// Read hookPacks from .claude/ if present
const hookPacks: string[] = [];
// Read package.json for outputDir
const pkg = JSON.parse(readFileSync(join(agentPath, 'package.json'), 'utf-8'));
return {
id: agentId,
name,
role,
description,
domains,
principles,
tone,
greeting,
outputDir: agentPath,
hookPacks,
setupTarget: pkg.soleri?.setupTarget ?? 'claude',
telegram: pkg.soleri?.telegram ?? false,
} as AgentConfig;
}
function extractStringField(src: string, field: string): string | undefined {
const re = new RegExp(`${field}:\\s*'([^']*)'`);
const m = src.match(re);
return m ? m[1].replace(/\\'/g, "'") : undefined;
}
function extractArrayField(src: string, field: string): string[] {
const re = new RegExp(`${field}:\\s*\\[([\\s\\S]*?)\\]`);
const m = src.match(re);
if (!m) return [];
return [...m[1].matchAll(/'([^']*)'/g)].map((x) => x[1]);
}
function extractDomains(indexSrc: string): string[] {
const m = indexSrc.match(/createDomainFacades\(runtime,\s*['"][^'"]+['"]\s*,\s*\[([\s\S]*?)\]\)/);
if (!m) return [];
// Match both single and double quoted strings
return [...m[1].matchAll(/['"]([^'"]+)['"]/g)].map((x) => x[1]);
}