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

244 lines 8.62 kB
/** * CLI Extension Loader * * Resolves addon-contributed CLI commands via `.aiwg/cli-extensions.json`. * When an addon declares `cli_commands` in its manifest, `aiwg use <addon>` * registers the namespace here. The router falls through to this loader * when no built-in command matches. * * @implements #478 * @architecture Addon manifests → cli-extensions.json → dynamic import → execute */ import fs from 'fs/promises'; import path from 'path'; import { pathToFileURL } from 'url'; import * as ui from './ui.js'; /** * Read the cli-extensions registry from the current project */ async function readRegistry(cwd) { const registryPath = path.join(cwd, '.aiwg', 'cli-extensions.json'); try { const content = await fs.readFile(registryPath, 'utf-8'); return JSON.parse(content); } catch { return null; } } /** * Write the cli-extensions registry to the current project */ export async function writeRegistry(cwd, registry) { const dir = path.join(cwd, '.aiwg'); await fs.mkdir(dir, { recursive: true }); await fs.writeFile(path.join(dir, 'cli-extensions.json'), JSON.stringify(registry, null, 2) + '\n'); } /** * Register an addon's CLI commands into the project's cli-extensions.json * * Called by `aiwg use <addon>` after reading the addon manifest. */ export async function registerCliCommands(cwd, namespace, description, source, subcommands) { const existing = await readRegistry(cwd) ?? {}; existing[namespace] = { source, description, subcommands }; await writeRegistry(cwd, existing); } /** * Try to execute an addon-contributed CLI command * * Returns null if the namespace is not registered (caller should show "unknown command"). * Returns a CliCommandResult if the namespace is found. */ export async function tryExecuteCliExtension(rawCommand, commandArgs, cwd, frameworkRoot) { const registry = await readRegistry(cwd); if (!registry || !registry[rawCommand]) { return null; } const ns = registry[rawCommand]; const subcommand = commandArgs[0]; // `aiwg <addon> --help` or `aiwg <addon>` with no subcommand if (!subcommand || subcommand === '--help' || subcommand === '-h') { printNamespaceHelp(rawCommand, ns); return { exitCode: 0 }; } // Look up subcommand const sub = ns.subcommands[subcommand]; if (!sub) { ui.error(`Unknown subcommand: ${rawCommand} ${subcommand}`); printNamespaceHelp(rawCommand, ns); return { exitCode: 1 }; } // Resolve the mjs file path const mjsPath = path.resolve(ns.source, path.basename(sub.file)); try { await fs.access(mjsPath); } catch { ui.error(`CLI extension file not found: ${mjsPath}`); return { exitCode: 1, message: `Missing: ${mjsPath}` }; } // Dynamic import the mjs module const moduleUrl = pathToFileURL(mjsPath).href; const mod = await import(moduleUrl); if (typeof mod.default !== 'function') { ui.error(`CLI extension ${sub.file} does not export a default function`); return { exitCode: 1 }; } // Build context const ctx = { cwd, frameworkRoot, namespace: rawCommand, subcommand, }; // Execute const result = await mod.default(commandArgs.slice(1), ctx); // Normalize result if (typeof result === 'object' && result !== null) { return { exitCode: result.exitCode ?? 0, message: result.message, }; } return { exitCode: 0 }; } /** * Hook events that should be auto-registered. * FeatureComplete is excluded — it's a deliberate user action, not a background event. */ const AUTO_HOOK_EVENTS = new Set(['Stop', 'SessionStart', 'SessionEnd', 'PreToolUse', 'PostToolUse']); /** * Migrate legacy array-shaped hooks to the object form Claude Code expects. * See #107. */ function normalizeClaudeHooks(raw) { if (Array.isArray(raw)) { const out = {}; for (const m of raw) { if (!m || typeof m.matcher !== 'string' || !Array.isArray(m.hooks)) continue; if (!out[m.matcher]) out[m.matcher] = []; out[m.matcher].push({ hooks: m.hooks }); } return out; } if (raw && typeof raw === 'object') return raw; return {}; } /** * Register Claude Code hooks from addon manifest hook_event annotations. * * Reads .claude/settings.json, merges hook entries for subcommands that * declare hook_event, and writes back. Idempotent — duplicate entries * are skipped. Only registers events in AUTO_HOOK_EVENTS. * * Schema: writes the object-keyed form Claude Code requires (#107). If * an existing settings.json has a legacy array-shaped `hooks` field, it * is migrated to the object shape during this write. * * @implements #480 */ export async function registerHooks(cwd, namespace, subcommands) { const settingsPath = path.join(cwd, '.claude', 'settings.json'); let settings; try { const content = await fs.readFile(settingsPath, 'utf-8'); settings = JSON.parse(content); } catch { // No settings file yet — start fresh settings = {}; } const wasLegacy = Array.isArray(settings.hooks); const hooksObj = normalizeClaudeHooks(settings.hooks); settings.hooks = hooksObj; const registered = []; for (const [name, sub] of Object.entries(subcommands)) { if (!sub.hook_event || !AUTO_HOOK_EVENTS.has(sub.hook_event)) { continue; } const command = `aiwg ${namespace} ${name}`; const event = sub.hook_event; if (!hooksObj[event]) hooksObj[event] = []; const groups = hooksObj[event]; // Skip if any existing group already has this command const exists = groups.some((g) => Array.isArray(g.hooks) && g.hooks.some((h) => h.command === command)); if (exists) continue; groups.push({ hooks: [{ type: 'command', command }] }); registered.push(`${event}${command}`); } // Write if we registered anything OR migrated an existing legacy field. if (registered.length > 0 || wasLegacy) { await fs.mkdir(path.join(cwd, '.claude'), { recursive: true }); await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n'); } return registered; } /** * Remove Claude Code hooks for a given namespace. * * Strips hook entries whose command matches `aiwg <namespace> *`. * Removes empty groups. Writes back only if changes were made. * * Migrates legacy array-shaped `hooks` fields to object form on write * (#107). */ export async function unregisterHooks(cwd, namespace) { const settingsPath = path.join(cwd, '.claude', 'settings.json'); let settings; try { const content = await fs.readFile(settingsPath, 'utf-8'); settings = JSON.parse(content); } catch { return 0; } if (!settings.hooks) return 0; const wasLegacy = Array.isArray(settings.hooks); const hooksObj = normalizeClaudeHooks(settings.hooks); settings.hooks = hooksObj; const prefix = `aiwg ${namespace} `; let removed = 0; for (const event of Object.keys(hooksObj)) { const groups = hooksObj[event]; for (const group of groups) { const before = group.hooks.length; group.hooks = group.hooks.filter((h) => !h.command.startsWith(prefix)); removed += before - group.hooks.length; } // Drop empty groups hooksObj[event] = groups.filter((g) => g.hooks.length > 0); // Drop the event entirely if no groups remain if (hooksObj[event].length === 0) delete hooksObj[event]; } if (removed > 0 || wasLegacy) { await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n'); } return removed; } /** * Print help for a CLI extension namespace */ function printNamespaceHelp(namespace, ns) { ui.blank(); console.log(` ${ui.bold(`aiwg ${namespace}`)}${ns.description}`); ui.blank(); console.log(' Subcommands:'); ui.blank(); const entries = Object.entries(ns.subcommands); const maxLen = Math.max(...entries.map(([name]) => name.length)); for (const [name, sub] of entries) { console.log(` ${name.padEnd(maxLen + 2)} ${sub.description}`); } ui.blank(); console.log(` Usage: aiwg ${namespace} <subcommand> [args]`); ui.blank(); } //# sourceMappingURL=cli-extension-loader.js.map