UNPKG

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

1,196 lines 57.8 kB
/** * Subcommand Handlers * * Handlers for MCP, catalog, plugin, and other subcommands. * Handles CLI subcommand routing. * * @implements @.aiwg/architecture/decisions/ADR-001-unified-extension-system.md * @implements #56, #57 * @source @src/cli/router.ts * @tests @test/unit/cli/handlers/subcommands.test.ts * @issue #33 */ import { createScriptRunner } from "./script-runner.js"; import { getFrameworkRoot } from "../../channel/manager.mjs"; import { getRegistry } from "../../extensions/registry.js"; import { registerDeployedExtensions } from "../../extensions/deployment-registration.js"; import { discoverProjectLocalBundles } from "../../extensions/project-local-discovery.js"; import { buildUpstreamRegistry } from "../../extensions/upstream-registry.js"; import { resolveShadows } from "../../extensions/shadow-resolver.js"; import { sessionHandler } from "./session.js"; import { feedbackHandler } from "./feedback.js"; import { handlerResultFromError } from "../errors.js"; import { getProjectDir } from "../../config/aiwg-config.js"; import { formatDeployedWorkspaceSignalPlan, readWorkspaceSignalPlan } from "../workspace-signals.js"; /** * MCP server command handler * * Dynamically imports and delegates to src/mcp/cli.mjs. * Handles subcommands: serve, install, info */ export const mcpHandler = { id: "aiwg-mcp-server", name: "AIWG MCP Server", description: "AIWG MCP server commands (serve, install, add, remove, update, list, inject, info)", category: "mcp", aliases: ["mcp", "aiwg-mcp"], async execute(ctx) { try { // Dynamic import to avoid loading MCP dependencies unless needed const { main } = await import("../../mcp/cli.mjs"); await main(ctx.args); return { exitCode: 0, }; } catch (error) { const result = handlerResultFromError(error); return { ...result, message: `MCP command failed: ${result.message}` }; } }, }; /** * Model catalog command handler * * Dynamically imports and delegates to src/catalog/cli.mjs. * Handles subcommands: list, info, search */ export const catalogHandler = { id: "catalog", name: "Model Catalog", description: "Model catalog commands (list, info, search)", category: "catalog", aliases: [], async execute(ctx) { try { // Dynamic import to avoid loading catalog dependencies unless needed const { main } = await import("../../catalog/cli.mjs"); await main(ctx.args); return { exitCode: 0, }; } catch (error) { const result = handlerResultFromError(error); return { ...result, message: `Catalog command failed: ${result.message}` }; } }, }; /** * List frameworks handler * * Lists deployed extensions from the registry. * Falls back to legacy plugin-status script if needed. */ export const listHandler = { id: "list", name: "List Frameworks", description: "List installed frameworks and plugins", category: "framework", aliases: ["ls"], async execute(ctx) { // Filter args: positional type filter, plus --project-local flag (#1034) const projectLocalOnly = ctx.args.includes('--project-local'); const shadowsOnly = ctx.args.includes('--shadows'); const deployedOnly = ctx.args.includes('--deployed'); const filterType = ctx.args.find((a) => !a.startsWith('--')); // 'agents'|'skills'|'commands'|'all'|undefined // #1156 Phase 1 — --scope user / --user surfaces the per-user registry // (~/.aiwg/installed.json) instead of the project-scope deployed-extensions // registry. Works from any cwd; does not require a project to be present. const userScopeRequested = ctx.args.includes('--user') || isScopeUser(ctx.args); if (userScopeRequested) { return await formatUserScopeRegistry(); } // Project-local bundle discovery (#1034) — read-only scan, no deploy const projectLocal = await discoverProjectLocalBundles(ctx.cwd); if (shadowsOnly) { // #1036 — surface only artifacts that shadow upstream return await formatShadowsOnly(projectLocal); } if (projectLocalOnly) { // --project-local: only show project-local bundles; skip the deployed- // extension registry read entirely return formatProjectLocalOnly(projectLocal); } // Ensure registry is populated with deployed extensions const registry = getRegistry(); // If registry is empty, try to populate it if (registry.size === 0) { try { await registerDeployedExtensions(registry, { agentsPath: '.claude/agents', skillsPath: '.claude/skills', commandsPath: '.claude/commands', provider: 'claude', cwd: ctx.cwd, }); } catch (error) { // If registry population fails, fall back to legacy script const frameworkRoot = await getFrameworkRoot(); const runner = createScriptRunner(frameworkRoot); return runner.run("tools/plugin/plugin-status-cli.mjs", ctx.args, { cwd: ctx.cwd, }); } } // Determine what to show const showAgents = !filterType || filterType === 'agents' || filterType === 'all'; const showSkills = !filterType || filterType === 'skills' || filterType === 'all'; const showCommands = !filterType || filterType === 'commands' || filterType === 'all'; let output = ''; if (showAgents) { const agents = registry.getByType('agent'); output += `\nAgents (${agents.length}):\n`; output += '─'.repeat(60) + '\n'; if (agents.length === 0) { output += ' No agents deployed\n'; } else { for (const agent of agents.slice(0, 20)) { // Limit to 20 for readability output += ` ${agent.name}\n`; output += ` ID: ${agent.id}\n`; output += ` Description: ${agent.description.slice(0, 80)}${agent.description.length > 80 ? '...' : ''}\n`; if (agent.installation) { output += ` Path: ${agent.installation.installedPath}\n`; } output += '\n'; } if (agents.length > 20) { output += ` ... and ${agents.length - 20} more\n`; } } } if (showSkills) { const skills = registry.getByType('skill'); output += `\nSkills (${skills.length}):\n`; output += '─'.repeat(60) + '\n'; if (skills.length === 0) { output += ' No skills deployed\n'; } else { for (const skill of skills.slice(0, 20)) { output += ` ${skill.name}\n`; output += ` ID: ${skill.id}\n`; output += ` Description: ${skill.description.slice(0, 80)}${skill.description.length > 80 ? '...' : ''}\n`; if (skill.installation) { output += ` Path: ${skill.installation.installedPath}\n`; } output += '\n'; } if (skills.length > 20) { output += ` ... and ${skills.length - 20} more\n`; } } } if (showCommands) { const commands = registry.getByType('command'); output += `\nCommands (${commands.length}):\n`; output += '─'.repeat(60) + '\n'; if (commands.length === 0) { output += ' No commands registered\n'; } else { for (const command of commands.slice(0, 20)) { output += ` ${command.name}\n`; output += ` ID: ${command.id}\n`; output += ` Description: ${command.description.slice(0, 80)}${command.description.length > 80 ? '...' : ''}\n`; output += '\n'; } if (commands.length > 20) { output += ` ... and ${commands.length - 20} more\n`; } } } // Project-local bundles (#1034) — surfaced as a separate section with // [project] source label const totalProjectLocal = projectLocal.counts.extension + projectLocal.counts.addon + projectLocal.counts.framework + projectLocal.counts.plugin; if (totalProjectLocal > 0 || projectLocal.errors.length > 0) { output += `\nProject-local bundles (${totalProjectLocal}):\n`; output += '─'.repeat(60) + '\n'; for (const b of projectLocal.bundles) { output += ` ${b.id} [project] [${b.type}]\n`; output += ` Path: ${b.localPath}\n`; output += ` Description: ${b.manifest.description.slice(0, 80)}${b.manifest.description.length > 80 ? '...' : ''}\n\n`; } if (projectLocal.errors.length > 0) { output += ` ⚠ ${projectLocal.errors.length} validation error(s) — see "aiwg doctor" for details\n`; } } // Summary const totalAgents = registry.getByType('agent').length; const totalSkills = registry.getByType('skill').length; const totalCommands = registry.getByType('command').length; const total = totalAgents + totalSkills + totalCommands; output += '\n' + '═'.repeat(60) + '\n'; output += `Total: ${total} extensions (${totalAgents} agents, ${totalSkills} skills, ${totalCommands} commands)`; if (totalProjectLocal > 0) { output += ` + ${totalProjectLocal} project-local`; } output += '\n'; if (total === 0 && totalProjectLocal === 0) { output += '\nTip: Deploy a framework with "aiwg use sdlc" to get started\n'; } else if (totalCommands === 0 && totalAgents > 0) { // Skill-only model (Claude Code default): commands aren't deployed as // slash commands; capabilities reach the agent via natural language or // `aiwg discover` (#1228). Surface this so 0 commands doesn't look like // a deploy failure. output += '\nNote: 0 commands is expected on Claude Code — capabilities are reached via natural language or `aiwg discover "<phrase>"`.\n'; } if (deployedOnly) { const projectDir = getProjectDir(ctx, ctx.args); const plan = await readWorkspaceSignalPlan(projectDir); if (plan) { output += formatDeployedWorkspaceSignalPlan(plan); } else { output += '\nWorkspace skill filter: no recorded plan found.\n'; output += 'Run `aiwg use --profile <name>` to record workspace-aware include/exclude reasons.\n'; } } return { exitCode: 0, message: output, }; }, }; /** * Format `aiwg list --project-local` output: only project-local bundles, with * per-type breakdown and any validation errors surfaced. (#1034) */ function formatProjectLocalOnly(result) { let output = ''; const total = result.counts.extension + result.counts.addon + result.counts.framework + result.counts.plugin; if (total === 0 && result.errors.length === 0) { output += '\nNo project-local bundles found.\n'; output += '\nTip: place a manifest.json under .aiwg/{extensions,addons,frameworks,plugins}/<name>/ to author a project-local artifact.\n'; return { exitCode: 0, message: output }; } output += `\nProject-local bundles (${total}):\n`; output += '─'.repeat(60) + '\n'; for (const b of result.bundles) { output += ` ${b.id} [project] [${b.type}] v${b.manifest.version}\n`; output += ` Path: ${b.localPath}\n`; output += ` Description: ${b.manifest.description.slice(0, 80)}${b.manifest.description.length > 80 ? '...' : ''}\n\n`; } if (result.errors.length > 0) { output += '\nValidation errors:\n'; output += '─'.repeat(60) + '\n'; for (const e of result.errors.slice(0, 10)) { output += ` [${e.severity}] ${e.path}\n`; output += ` ${e.field}: expected ${e.expected}, got ${e.actual}\n`; if (e.hint) output += ` hint: ${e.hint}\n`; } if (result.errors.length > 10) { output += ` ... and ${result.errors.length - 10} more\n`; } } output += '\n' + '═'.repeat(60) + '\n'; output += `Counts by type: extension=${result.counts.extension} addon=${result.counts.addon} framework=${result.counts.framework} plugin=${result.counts.plugin}\n`; return { exitCode: 0, message: output }; } /** * Format `aiwg list --shadows` output: only artifacts that currently shadow * an upstream artifact, with safety-critical and override status. (#1036) */ async function formatShadowsOnly(projectLocal) { let output = ''; if (projectLocal.bundles.length === 0) { output += '\nNo project-local bundles — no shadows possible.\n'; return { exitCode: 0, message: output }; } const { getFrameworkRoot: gfr } = await import('../../channel/manager.mjs'); const frameworkRoot = await gfr(); const upstream = await buildUpstreamRegistry({ frameworkRoot }); const result = await resolveShadows(projectLocal.bundles, upstream); if (result.shadows.length === 0) { output += '\nNo active shadows.\n'; output += '\nProject-local bundles deploy alongside upstream without collision.\n'; return { exitCode: 0, message: output }; } output += `\nActive shadows (${result.shadows.length}):\n`; output += '─'.repeat(60) + '\n'; for (const r of result.shadows) { const sc = r.upstream?.safetyCritical ? ' [SAFETY-CRITICAL]' : ''; output += ` ${r.artifactType}/${r.artifactId}${sc}\n`; output += ` Bundle: ${r.bundleId} (${r.bundleLocalPath})\n`; output += ` Project-local: ${r.artifactSourcePath}\n`; if (r.upstream) { output += ` Shadows ${r.upstream.source}: ${r.upstream.sourcePath}\n`; } output += ` Verdict: ${r.verdict}\n\n`; } if (result.blockedBundleIds.size > 0) { output += `\n⚠ ${result.blockedBundleIds.size} bundle(s) blocked from deployment due to unsafe shadows.\n`; } return { exitCode: 0, message: output }; } /** * #1156 Phase 1 — `--scope user` detection. Mirrors `detectScope()` from * scope-resolver but tolerates the absence of the flag (no throw on absent). * Returns true when args contain `--scope user`. */ function isScopeUser(args) { const idx = args.findIndex((a) => a === '--scope'); if (idx === -1) return false; return args[idx + 1] === 'user'; } /** * #1156 Phase 1 — Format the per-user registry (~/.aiwg/installed.json) as a * human-readable inventory. Used by `aiwg list --scope user` / `aiwg list * --user`. Independent of any project — works from any cwd. */ async function formatUserScopeRegistry() { const { readUserRegistry, userRegistryPath } = await import('../../config/user-registry.js'); const registry = await readUserRegistry(); const frameworks = Object.entries(registry.installed); let output = ''; if (frameworks.length === 0) { output += '\nNo frameworks deployed at user scope.\n'; output += `\nRegistry path: ${userRegistryPath()}\n`; output += '\nTip: run `aiwg use <framework> --provider <p> --scope user` to install at user scope.\n'; return { exitCode: 0, message: output }; } output += `\nUser-scope deployments (${frameworks.length}):\n`; output += '─'.repeat(60) + '\n'; for (const [name, entry] of frameworks) { output += ` ${name} v${entry.version} [${entry.source}]\n`; output += ` Installed: ${entry.installedAt}\n`; const providers = Object.entries(entry.deployedTo); for (const [provider, counts] of providers) { const parts = []; if (counts.agents > 0) parts.push(`${counts.agents} agents`); if (counts.commands > 0) parts.push(`${counts.commands} commands`); if (counts.skills > 0) parts.push(`${counts.skills} skills`); if (counts.rules > 0) parts.push(`${counts.rules} rules`); output += ` ${provider}: ${parts.length > 0 ? parts.join(', ') : '(empty)'}\n`; } output += '\n'; } output += '═'.repeat(60) + '\n'; output += `Registry path: ${userRegistryPath()}\n`; return { exitCode: 0, message: output }; } /** * #1156 Phase 1 — Revert a user-scope mirror. * * Reads `~/.aiwg/installed.json`, looks up the framework + provider entry, * and deletes the specific artifact entries this deploy created (recorded * by the mirror in Cycle 3). With `--dry-run`, lists what would be removed * without touching the filesystem. * * Back-compat: registry entries written before Cycle 3 lack the `entries` * snapshot. For those, the handler falls back to the conservative * registry-only revert and tells the operator to clean up manually. * * Multi-provider: if `--provider` is omitted, all of the framework's * provider deployments are reverted in one pass. */ async function removeUserScopeDeploy(args) { const positional = args.find(a => !a.startsWith('-')); if (!positional) { return { exitCode: 1, message: 'Error: framework name required\n\nUsage: aiwg remove <framework> --scope user [--provider <p>] [--dry-run]', }; } const dryRun = args.includes('--dry-run'); const provIdx = args.findIndex(a => a === '--provider' || a === '--platform'); const provider = provIdx >= 0 ? args[provIdx + 1] : undefined; const { readUserRegistry, removeUserDeploy } = await import('../../config/user-registry.js'); const { USER_SCOPE_PATHS } = await import('../scope-resolver.js'); const registry = await readUserRegistry(); const entry = registry.installed[positional]; if (!entry) { return { exitCode: 1, message: `Error: framework '${positional}' is not deployed at user scope.\n\nRun 'aiwg list --scope user' to see installed frameworks.`, }; } const providersToRevert = provider ? [provider] : Object.keys(entry.deployedTo); if (provider && !entry.deployedTo[provider]) { return { exitCode: 1, message: `Error: framework '${positional}' is not deployed at user scope for provider '${provider}'.\n\nDeployed providers: ${Object.keys(entry.deployedTo).join(', ') || '(none)'}`, }; } const fs = await import('node:fs/promises'); const path = await import('node:path'); const linesOut = []; if (dryRun) linesOut.push(`[dry-run] Plan for user-scope remove of '${positional}':`); else linesOut.push(`Removing user-scope mirror of '${positional}':`); let totalDeleted = 0; let conservativeFallbackUsed = false; for (const p of providersToRevert) { const userPaths = USER_SCOPE_PATHS[p]; if (!userPaths) { linesOut.push(` ⚠ ${p}: no user-scope paths registered — skipping (manual cleanup may be needed)`); continue; } // Cast through unknown to access the optional `entries` snapshot recorded // by the Cycle 3 mirror. Older registry entries won't have it. const providerEntry = entry.deployedTo[p]; const recorded = providerEntry?.entries; if (!recorded) { // Pre-Cycle-3 entry: surface the deploy paths and let the operator // clean them up manually. We can't safely auto-delete because the dirs // are shared with other frameworks. conservativeFallbackUsed = true; const existingDirs = [ { type: 'agents', path: userPaths.agents }, { type: 'commands', path: userPaths.commands }, { type: 'skills', path: userPaths.skills }, { type: 'rules', path: userPaths.rules }, { type: 'behaviors', path: userPaths.behaviors }, ].filter(t => t.path); linesOut.push(` ⚠ ${p}: registry entry has no per-artifact manifest (deployed before Cycle 3). Manual cleanup of these dirs may be needed:`); for (const t of existingDirs) { const stat = await fs.stat(t.path).catch(() => null); if (stat && stat.isDirectory()) { linesOut.push(` - ${t.path}`); } } continue; } // Precise revert — walk the recorded entry names and delete each one // from its corresponding user-scope dir. const sets = [ ['agents', userPaths.agents, recorded.agents], ['commands', userPaths.commands, recorded.commands], ['skills', userPaths.skills, recorded.skills], ['rules', userPaths.rules, recorded.rules], ['behaviors', userPaths.behaviors, recorded.behaviors], ]; for (const [type, dir, names] of sets) { if (!dir || !names || names.length === 0) continue; let deletedHere = 0; for (const name of names) { const target = path.join(dir, name); if (dryRun) { linesOut.push(` · ${p} ${type}: ${target}`); deletedHere++; continue; } try { await fs.rm(target, { recursive: true, force: true }); deletedHere++; totalDeleted++; } catch (err) { linesOut.push(` ⚠ ${p} ${type}: failed to remove ${target}: ${err instanceof Error ? err.message : String(err)}`); } } if (!dryRun && deletedHere > 0) { linesOut.push(` ✓ ${p} ${type}: removed ${deletedHere} entry/entries from ${dir}`); } else if (dryRun) { linesOut.push(` (${deletedHere} ${type} would be removed from ${dir})`); } } } if (!dryRun) { if (provider) { await removeUserDeploy({ framework: positional, provider }); } else { await removeUserDeploy({ framework: positional }); } linesOut.push(''); if (conservativeFallbackUsed) { linesOut.push('Registry updated. Some providers had no per-artifact manifest (pre-Cycle-3 deploys); inspect the dirs listed above and clean up manually if needed.'); } else { linesOut.push(`Registry updated. ${totalDeleted} artifact entry/entries removed.`); } } return { exitCode: 0, message: linesOut.join('\n') + '\n', }; } /** * Remove framework handler * * Delegates to tools/plugin/plugin-uninstaller-cli.mjs */ export const removeHandler = { id: "remove", name: "Remove Framework", description: "Remove installed framework, plugin, or project-local bundle", category: "framework", aliases: [], async execute(ctx) { // #1156 Phase 1 — `--scope user` / `--user`: revert the user-scope mirror // for the given framework. Independent of any project; reads the per-user // registry at ~/.aiwg/installed.json to find what was deployed, deletes // the mirrored artifacts under the provider's USER_SCOPE_PATHS, and // updates the registry. if (ctx.args.includes('--user') || isScopeUser(ctx.args)) { return await removeUserScopeDeploy(ctx.args); } // #1037 — Project-local-aware remove. If the first positional arg matches // a project-local entry in `installed`, route to the new handler. // Otherwise fall through to the existing plugin-uninstaller flow. const positionalArg = ctx.args.find(a => !a.startsWith('-')); if (positionalArg) { try { const { readAiwgConfig, writeAiwgConfig, getProjectDir } = await import('../../config/aiwg-config.js'); const { removeProjectLocalBundle } = await import('../../extensions/project-local-remove.js'); const projectDir = getProjectDir({ cwd: ctx.cwd }, ctx.args); const config = await readAiwgConfig(projectDir); const entry = config?.installed?.[positionalArg]; if (config && entry?.source === 'project-local') { const force = ctx.args.includes('--force'); const dryRun = ctx.args.includes('--dry-run'); const keepRegistry = ctx.args.includes('--keep-registry'); const provIdx = ctx.args.findIndex(a => a === '--provider'); const provider = provIdx >= 0 ? ctx.args[provIdx + 1] : undefined; const result = await removeProjectLocalBundle(config, projectDir, positionalArg, { force, dryRun, keepRegistry, provider }); // Print outcome summary const lines = []; if (dryRun) lines.push(`[dry-run] Plan for project-local '${positionalArg}':`); for (const o of result.outcomes) { const marker = o.reverted ? '✓' : '⚠'; lines.push(` ${marker} ${o.provider} :: ${o.artifactPath} [${o.case}] ${o.message}`); } if (result.revertedProviders.length > 0) { lines.push(`Fully reverted: ${result.revertedProviders.join(', ')}`); } if (result.partialProviders.length > 0) { lines.push(`Partial (registry preserved): ${result.partialProviders.join(', ')}`); } if (lines.length > 0) console.log(lines.join('\n')); if (!dryRun) { await writeAiwgConfig(projectDir, config); } // Note: source under .aiwg/<type>/<name>/ is intentionally NOT // deleted (load-bearing invariant from #1048 design). return { exitCode: result.partialProviders.length > 0 ? 1 : 0, message: result.partialProviders.length > 0 ? `Some artifacts skipped (see above). Use --force to override mutation refusal.` : '', }; } } catch (err) { // Fall through to upstream remove on any error in the project-local path const msg = err instanceof Error ? err.message : String(err); process.stderr.write(`project-local remove pre-check failed (falling through): ${msg}\n`); } } const frameworkRoot = await getFrameworkRoot(); const runner = createScriptRunner(frameworkRoot); return runner.run("tools/plugin/plugin-uninstaller-cli.mjs", ctx.args, { cwd: ctx.cwd, }); }, }; /** * Promote handler — graduate a project-local bundle to upstream or to a * private corpus path. Implements the design at * @.aiwg/architecture/design-doctor-log-promote.md (#1049). * * Usage: * aiwg promote <name> # default: --to upstream * aiwg promote <name> --to upstream * aiwg promote <name> --to corpus <path> * aiwg promote <name> --dry-run * aiwg promote <name> --cleanup * aiwg promote <name> --force * * @implements #1037 */ export const promoteHandler = { id: 'promote', name: 'Promote', description: 'Graduate a project-local bundle to upstream or a corpus path', category: 'framework', aliases: ['-promote', '--promote', 'graduate'], async execute(ctx) { const args = ctx.args; const positional = args.find(a => !a.startsWith('-')); if (!positional) { return { exitCode: 1, message: 'Error: bundle name required\n\nUsage: aiwg promote <name> [--to upstream|corpus <path>] [--dry-run] [--cleanup] [--force]' }; } const toIdx = args.findIndex(a => a === '--to'); const toValue = toIdx >= 0 ? args[toIdx + 1] : 'upstream'; let corpusPath; if (toValue === 'corpus') { // The argument *after* "corpus" is the path corpusPath = args[toIdx + 2]; if (!corpusPath || corpusPath.startsWith('-')) { return { exitCode: 1, message: 'Error: --to corpus requires a path argument' }; } } else if (toValue !== 'upstream') { return { exitCode: 1, message: `Error: --to must be 'upstream' or 'corpus' (got '${toValue}')` }; } const dryRun = args.includes('--dry-run'); const cleanup = args.includes('--cleanup'); const force = args.includes('--force'); try { const { readAiwgConfig, writeAiwgConfig, getProjectDir } = await import('../../config/aiwg-config.js'); const { promoteProjectLocalBundle } = await import('../../extensions/project-local-promote.js'); const projectDir = getProjectDir({ cwd: ctx.cwd }, args); const config = await readAiwgConfig(projectDir); if (!config) { return { exitCode: 1, message: 'Error: no .aiwg/aiwg.config found — run `aiwg init` first' }; } const fr = await getFrameworkRoot(); const result = await promoteProjectLocalBundle(config, projectDir, positional, { to: toValue, corpusPath, dryRun, cleanup, force, frameworkRoot: fr, }); if (!result.ok) { return { exitCode: 1, message: `Error: ${result.message ?? result.failureReason}` }; } if (dryRun && result.plan) { console.log('[dry-run] Would copy:'); console.log(` ${result.plan.source} → ${result.plan.destination}`); console.log(` Files: ${result.plan.files.length}, ${result.plan.totalBytes} bytes`); console.log(' Pre-flight: ✓ manifest valid ✓ destination clean'); console.log(' Hash verification: skipped (dry-run)'); console.log(` Registry update: source: project-local → ${toValue === 'upstream' ? 'bundled' : 'corpus'}`); console.log(` Cleanup: ${cleanup ? 'will remove .aiwg source after copy' : 'skipped (--cleanup not set)'}`); return { exitCode: 0 }; } await writeAiwgConfig(projectDir, config); console.log(`✓ Promoted '${positional}' → ${result.plan?.destination}`); if (cleanup) { console.log(' Source removed from .aiwg/'); } return { exitCode: 0 }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); return { exitCode: 1, message: `promote failed: ${msg}` }; } }, }; /** * New bundle handler — scaffolds a project-local bundle under * `.aiwg/{type}/{name}/` with a valid manifest, starter artifact, and a * README that includes the identical-form portability reminder. * * Usage: * aiwg new-bundle <name> # default: --type extension --starter skill * aiwg new-bundle <name> --type addon * aiwg new-bundle <name> --type framework --starter minimal * aiwg new-bundle <name> --starter rule --description "Custom rule" * * @implements #1050 */ export const newBundleHandler = { id: 'new-bundle', name: 'New Bundle', description: 'Scaffold a project-local bundle under .aiwg/{type}/{name}/', category: 'scaffolding', aliases: ['new-extension', 'new-addon', 'new-framework', 'new-plugin'], async execute(ctx) { const args = ctx.args; const positional = args.find(a => !a.startsWith('-')); if (!positional) { return { exitCode: 1, message: 'Error: bundle name required\n\nUsage: aiwg new-bundle <name> [--type extension|addon|framework|plugin] [--starter skill|rule|agent|minimal] [--description "..."]', }; } // Type can be inferred from the alias used to invoke (new-extension etc.) const aliasMap = { 'new-extension': 'extension', 'new-addon': 'addon', 'new-framework': 'framework', 'new-plugin': 'plugin', }; const invoked = ctx.rawArgs[0] ?? ''; const aliasType = aliasMap[invoked]; const typeIdx = args.findIndex(a => a === '--type'); const typeFlag = typeIdx >= 0 ? args[typeIdx + 1] : undefined; const type = (typeFlag ?? aliasType ?? 'extension'); if (!['extension', 'addon', 'framework', 'plugin'].includes(type)) { return { exitCode: 1, message: `Error: --type must be one of extension|addon|framework|plugin (got '${type}')` }; } const starterIdx = args.findIndex(a => a === '--starter'); const starter = starterIdx >= 0 ? args[starterIdx + 1] : undefined; if (starter && !['skill', 'rule', 'agent', 'minimal'].includes(starter)) { return { exitCode: 1, message: `Error: --starter must be one of skill|rule|agent|minimal (got '${starter}')` }; } const descIdx = args.findIndex(a => a === '--description'); const description = descIdx >= 0 ? args[descIdx + 1] : undefined; const dryRun = args.includes('--dry-run'); const { scaffoldProjectLocalBundle } = await import('../../extensions/project-local-scaffold.js'); try { const result = await scaffoldProjectLocalBundle({ type, name: positional, description, starter, projectDir: ctx.cwd, dryRun, }); if (result.alreadyExists) { return { exitCode: 1, message: `Refused: bundle already exists at ${result.bundlePath}. Remove it first or pick a different name.`, }; } if (dryRun) { console.log(`[DRY RUN] Would scaffold project-local ${type} '${positional}' at ${result.bundlePath}`); console.log(' Files that would be created:'); for (const f of result.filesCreated) console.log(` + ${f}`); console.log(''); console.log('No files written. Re-run without --dry-run to create.'); return { exitCode: 0 }; } console.log(`✓ Scaffolded project-local ${type} '${positional}' at ${result.bundlePath}`); console.log(' Files created:'); for (const f of result.filesCreated) console.log(` + ${f}`); // #1085 — when the project blanket-ignores .aiwg/, the new bundle's // source would be silently dropped from version control. Detect and // self-heal idempotently. try { const { appendAiwgSourceTrackBlock } = await import('../../extensions/project-local-gitignore.js'); const ig = await appendAiwgSourceTrackBlock(ctx.cwd); if (ig.added) { console.log(''); console.log(` → ${ig.reason}`); console.log(' .aiwg/{addons,extensions,frameworks,plugins}/ now tracked by git.'); console.log(' Generated state under .aiwg/ (working/, ralph/, research/, ...) stays ignored.'); } } catch { // .gitignore management is best-effort; don't fail the scaffold } // #1235 — auto-rebuild project graph so the new bundle is immediately // discoverable via `aiwg discover`. Best-effort: don't fail the scaffold // if the index build hiccups. let indexedOk = false; try { const { buildIndex } = await import('../../artifacts/index-builder.js'); await buildIndex(ctx.cwd, { graph: 'project', force: false, explicit: true }); indexedOk = true; } catch { // ignore — surface via next-steps hint instead } console.log(''); console.log('Next steps:'); console.log(` 1. Edit manifest.json (description, version, keywords)`); console.log(` 2. Customize the starter artifact under ${result.bundlePath}/`); if (indexedOk) { console.log(` 3. Discoverable now via: aiwg discover "${positional}"`); } else { console.log(` 3. Rebuild project index: aiwg index build --graph project`); console.log(` then: aiwg discover "${positional}"`); } console.log(` 4. Deploy: aiwg use ${positional}`); console.log(` 5. Inspect: aiwg doctor --project-local`); console.log(` 6. When mature, promote: aiwg promote ${positional}`); return { exitCode: 0 }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); return { exitCode: 1, message: `Error: ${msg}` }; } }, }; /** * New project handler * * Delegates to tools/install/new-project.mjs */ export const newProjectHandler = { id: "new", name: "New Project", description: "Scaffold a new project with AIWG", category: "project", aliases: ["-new", "--new"], async execute(ctx) { const frameworkRoot = await getFrameworkRoot(); const runner = createScriptRunner(frameworkRoot); return runner.run("tools/install/new-project.mjs", ctx.args, { cwd: ctx.cwd, }); }, }; /** * Install plugin handler * * Delegates to tools/plugin/plugin-installer-cli.mjs */ export const installPluginHandler = { id: "install-plugin", name: "Install Plugin", description: "Install a plugin from the registry", category: "plugin", aliases: ["-install-plugin", "--install-plugin"], async execute(ctx) { const frameworkRoot = await getFrameworkRoot(); const runner = createScriptRunner(frameworkRoot); return runner.run("tools/plugin/plugin-installer-cli.mjs", ctx.args, { cwd: ctx.cwd, }); }, }; /** * Uninstall plugin handler * * Delegates to tools/plugin/plugin-uninstaller-cli.mjs */ export const uninstallPluginHandler = { id: "uninstall-plugin", name: "Uninstall Plugin", description: "Uninstall a plugin", category: "plugin", aliases: ["-uninstall-plugin", "--uninstall-plugin"], async execute(ctx) { const frameworkRoot = await getFrameworkRoot(); const runner = createScriptRunner(frameworkRoot); return runner.run("tools/plugin/plugin-uninstaller-cli.mjs", ctx.args, { cwd: ctx.cwd, }); }, }; /** * Plugin status handler * * Delegates to tools/plugin/plugin-status-cli.mjs */ export const pluginStatusHandler = { id: "plugin-status", name: "Plugin Status", description: "Show plugin status and installation details", category: "plugin", aliases: ["-plugin-status", "--plugin-status"], async execute(ctx) { const frameworkRoot = await getFrameworkRoot(); const runner = createScriptRunner(frameworkRoot); return runner.run("tools/plugin/plugin-status-cli.mjs", ctx.args, { cwd: ctx.cwd, }); }, }; /** * Package plugin handler * * Delegates to tools/plugin/package-plugins.mjs */ export const packagePluginHandler = { id: "package-plugin", name: "Package Plugin", description: "Package a plugin for distribution", category: "plugin", aliases: ["-package-plugin", "--package-plugin"], async execute(ctx) { const frameworkRoot = await getFrameworkRoot(); const runner = createScriptRunner(frameworkRoot); return runner.run("tools/plugin/package-plugins.mjs", ctx.args, { cwd: ctx.cwd, }); }, }; /** * Package all plugins handler * * Delegates to tools/plugin/package-plugins.mjs with --all flag */ export const packageAllPluginsHandler = { id: "package-all-plugins", name: "Package All Plugins", description: "Package all plugins for distribution", category: "plugin", aliases: ["-package-all-plugins", "--package-all-plugins"], async execute(ctx) { const frameworkRoot = await getFrameworkRoot(); const runner = createScriptRunner(frameworkRoot); return runner.run("tools/plugin/package-plugins.mjs", ["--all", ...ctx.args], { cwd: ctx.cwd, }); }, }; /** * Artifact index command handler * * Dynamically imports and delegates to src/artifacts/cli.mjs. * Handles subcommands: build, query, deps, stats * * @implements #420 */ export const indexHandler = { id: "index", name: "Artifact Index", description: "Artifact index commands (build, query, deps, stats)", category: "index", aliases: [], async execute(ctx) { try { const { main } = await import("../../artifacts/cli.js"); await main(ctx.args); return { exitCode: 0, }; } catch (error) { const result = handlerResultFromError(error); return { ...result, message: `Index command failed: ${result.message}` }; } }, }; /** * Discovery command handler — first-class top-level verb (#1212). * * Forwards to the same implementation as `aiwg index discover` but * exposes discovery as its own command so agents don't conflate it * with the project's general-purpose artifact graph indices. * * aiwg discover "<phrase>" [--limit N] [--type skill,agent,...] [--json] */ export const discoverHandler = { id: "discover", name: "Discover", description: "Find AIWG skills, agents, commands, and rules by capability — index-driven on-demand discovery", category: "index", aliases: [], async execute(ctx) { try { // Delegate to the same handler `aiwg index discover` uses, by // prepending the `discover` subcommand and reusing the artifacts // CLI router. This keeps a single implementation path. const { main } = await import("../../artifacts/cli.js"); await main(["discover", ...ctx.args]); return { exitCode: 0 }; } catch (error) { const result = handlerResultFromError(error); return { ...result, message: `Discover command failed: ${result.message}` }; } }, }; /** * Features command handler — manage AIWG's optional runtime features (#1219). * * Subcommands: status / info / install / remove. Cycle 1 ships * status + info; install + remove arrive in Cycle 3 once install-mode * detection is designed. */ export const featuresHandler = { id: "features", name: "Features", description: "List, inspect, and (eventually) install AIWG's optional runtime features", category: "maintenance", aliases: [], async execute(ctx) { try { const { main } = await import("../../features/cli.js"); await main(ctx.args); return { exitCode: 0 }; } catch (error) { const result = handlerResultFromError(error); return { ...result, message: `Features command failed: ${result.message}` }; } }, }; /** * Show command handler — fetch the full text of a specific artifact (#1218). * * Forwards to the same implementation as `aiwg index show`. Companion to * `aiwg discover`: where discover ranks candidates, show fetches the body * so consumers don't need to navigate AIWG's storage paths themselves. * * aiwg show <name> [--type skill,agent,...] [--json] [--first] */ export const showHandler = { id: "show", name: "Show", description: "Print the full text of a specific AIWG skill, agent, command, or rule by name", category: "index", aliases: [], async execute(ctx) { try { const { main } = await import("../../artifacts/cli.js"); await main(["show", ...ctx.args]); return { exitCode: 0 }; } catch (error) { const result = handlerResultFromError(error); return { ...result, message: `Show command failed: ${result.message}` }; } }, }; /** * Skills command handler * * Dynamically imports and delegates to src/skills/cli.ts. * Handles subcommands: search, info, list, install, publish * * @implements #539 */ export const skillsHandler = { id: "skills", name: "Skills Registry", description: "Skill commands (search, info, list, install, publish)", category: "catalog", aliases: [], async execute(ctx) { try { const { main } = await import("../../skills/cli.js"); await main(ctx.args); return { exitCode: 0, }; } catch (error) { const result = handlerResultFromError(error); return { ...result, message: `Skills command failed: ${result.message}` }; } }, }; /** * Config command handler * * Dynamically imports and delegates to src/config/cli.ts. * Handles subcommands: get, set, list, validate, reset, path, edit * * @implements #545 */ export const configHandler = { id: "config", name: "Config", description: "User config commands (get, set, list, validate, reset, path, edit)", category: "config", aliases: [], async execute(ctx) { try { const { main } = await import("../../config/cli.js"); await main(ctx.args); return { exitCode: 0, }; } catch (error) { const result = handlerResultFromError(error); return { ...result, message: `Config command failed: ${result.message}` }; } }, }; /** * Ops command handler * * Dynamically imports and delegates to src/ops/cli.ts. * Handles subcommands: init, status, use, list, push * * @implements #544 */ export const opsHandler = { id: "ops", name: "Ops", description: "Ops ecosystem commands (init, status, use, list, push)", category: "ops", aliases: [], async execute(ctx) { try { const { main } = await import("../../ops/cli.js"); await main(ctx.args); return { exitCode: 0, }; } catch (error) { const result = handlerResultFromError(error); return { ...result, message: `Ops command failed: ${result.message}` }; } }, }; /** * Activity-log command handler * * Dynamically imports and delegates to src/activity-log/cli.ts. * Handles subcommands: show, append, stats. Persistence routes through * resolveStorage('activity_log') so the log honors any storage.config * override (#934). * * @implements #934 * @implements #964 */ export const activityLogHandler = { id: 'activity-log', name: 'Activity Log', description: 'Query and manage .aiwg/activity.log (show, append, stats)', category: 'utility', aliases: [], async execute(ctx) { try { const { main } = await import('../../activity-log/cli.js'); await main(ctx.args); return { exitCode: 0 }; } catch (error) { const result = handlerResultFromError(error); return { ...result, message: `activity-log command failed: ${result.message}` }; } }, }; /** * Provenance command handler — routes \`aiwg provenance\` through * resolveStorage('provenance') for provenance-* skills (#968). * * @implements #934 * @implements #968 */ export const provenanceHandler = { id: 'provenance', name: 'Provenance', description: 'Provenance subsystem storage operations (path, list, get, put, delete, append-log)', category: 'utility', aliases: [], async execute(ctx) { try { const { main } = await import('../../provenance/cli.js'); await main(ctx.args); return { exitCode: 0 }; } catch (error) { const result = handlerResultFromError(error); return { ...result, message: `provenance command failed: ${result.message}` }; } }, }; /** * Research storage command handler — routes \`aiwg research-store\` * through resolveStorage('research') for research-acquire / corpus-* * skills (#968). Disambiguated from existing research-* workflow * commands by the \`-store\` suffix. * * @implements #934 * @implements #968 */ export const researchStoreHandler = { id: 'research-store', name: 'Researc