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,147 lines 105 kB
/** * Use Command Handler * * Deploys AIWG frameworks (SDLC, Marketing, Writing) to the current project. * After deployment, registers deployed extensions in the extension registry. * * @implements @.aiwg/architecture/decisions/ADR-001-unified-extension-system.md * @implements #56, #57 * @source @src/cli/router.ts * @issue #33 */ import fs from 'fs/promises'; import path from 'path'; import os from 'os'; import { createScriptRunner } from './script-runner.js'; import { getFrameworkRoot, getVersionInfo } from '../../channel/manager.mjs'; import { getRegistry } from '../../extensions/registry.js'; import { registerDeployedExtensions } from '../../extensions/deployment-registration.js'; import { registerCliCommands, registerHooks } from '../cli-extension-loader.js'; import { translateSkillsToCommands, providerNeedsCommands } from '../../plugin/skill-command-translator.js'; import * as ui from '../ui.js'; import { readAiwgConfig, writeAiwgConfig, updateInstalled, hashManifest, emptyConfig, getProjectDir } from '../../config/aiwg-config.js'; import { getLogger } from '../log.js'; import { initHandler } from './init.js'; import { checkCollisions, formatCollisionReport, hasBlockingCollisions, } from '../../smiths/skillsmith/collision-detector.js'; import { discoverProjectLocalBundles, } from '../../extensions/project-local-discovery.js'; import { buildUpstreamRegistry } from '../../extensions/upstream-registry.js'; import { resolveShadows, formatShadowReport, } from '../../extensions/shadow-resolver.js'; import { appendProjectLocalActivity, emitDiscoverEventsDeduped, } from '../../extensions/project-local-activity.js'; import { hashBundleArtifacts } from '../../extensions/project-local-remove.js'; import { installAiwgHooks } from '../../extensions/claude-hooks-installer.js'; import { detectScope, mirrorToUserScope, rejectOpenClawProjectScope } from '../scope-resolver.js'; import { maybeWarnProjectIsolation } from '../project-isolation/index.js'; import { formatWorkspaceSignalPlan, includedBundleIds, resolveWorkspaceSignalPlan, writeWorkspaceSignalPlan, } from '../workspace-signals.js'; // Module-level guard so the iteration loops further down (which re-enter // execute() per framework/provider) don't re-emit the warning each pass. // Reset is not needed: a single CLI process is one user invocation. let projectIsolationChecked = false; // Context-pipeline: emits AIWG.md + AGENTS.md as the last step of `aiwg use` // for non-Claude providers per ADR-1 (.aiwg/architecture/adr-agents-md-aggregation.md). // Distinct from agentsmith (which creates subagent personas). import { generate as generateContextFiles, discoverDeployedArtifacts, shouldEmitContextFiles, } from '../../smiths/context-pipeline/index.js'; /** * Valid framework identifiers */ const VALID_FRAMEWORKS = ['sdlc', 'marketing', 'media-curator', 'research', 'forensics', 'security-engineering', 'ops', 'knowledge-base', 'writing', 'general', 'all']; /** * Framework name to deploy mode mapping. * Mode is passed as `--mode <value>` to deploy-agents.mjs, which resolves * to the actual framework via discoverFrameworks() + modeAliases. */ const MODE_MAP = { sdlc: 'sdlc', marketing: 'marketing', 'media-curator': 'media-curator', research: 'research', forensics: 'forensics', 'security-engineering': 'security-engineering', ops: 'ops-complete', // ops-complete manifest id is 'ops-complete' (modeAlias: ops) 'knowledge-base': 'knowledge-base', writing: 'general', general: 'general', all: 'all', }; /** * Framework name to actual directory name under agentic/code/frameworks/. * Used for path construction in collision checks, CI hooks, and version tracking. * Frameworks without a dedicated directory (writing, general) map to undefined — * those code paths are skipped gracefully. */ const FRAMEWORK_DIR_MAP = { sdlc: 'sdlc-complete', marketing: 'media-marketing-kit', 'media-curator': 'media-curator', research: 'research-complete', forensics: 'forensics-complete', 'security-engineering': 'security-engineering', ops: 'ops-complete', 'knowledge-base': 'knowledge-base', // 'writing' and 'general' have no backing framework directory // 'all' falls back to sdlc for manifest/CI purposes all: 'sdlc-complete', }; /** Resolve actual framework directory name for a given user-facing name. */ function resolveFrameworkDir(framework) { return FRAMEWORK_DIR_MAP[framework]; } /** * Addons excluded from `aiwg use all`. * aiwg-dev is contributor-only tooling — not for end users. */ export const USE_ALL_DISALLOW = new Set(['aiwg-dev']); /** * Discover all addon names from the filesystem, minus the disallow list. */ export async function getAllAddons(frameworkRoot) { const addonsDir = path.join(frameworkRoot, 'agentic/code/addons'); const entries = await fs.readdir(addonsDir, { withFileTypes: true }); return entries .filter(e => e.isDirectory() && !USE_ALL_DISALLOW.has(e.name)) .map(e => e.name); } /** * Extensions excluded from `aiwg use all` deployment. * `api-adapter` is an OpenAPI spec, not a deployable artifact bundle. */ export const USE_ALL_EXTENSIONS_DISALLOW = new Set(['api-adapter']); /** * Discover all extension names from `agentic/code/extensions/*` (#1221). * * Extensions are addon-shaped bundles with their own `manifest.json`, * `skills/`, `rules/`, and `templates/` directories. Only directories * containing a `manifest.json` are considered deployable; bare directories * (e.g. `api-adapter` which only ships an OpenAPI spec) are skipped. */ export async function getAllExtensions(frameworkRoot) { const extensionsDir = path.join(frameworkRoot, 'agentic/code/extensions'); let entries; try { entries = await fs.readdir(extensionsDir, { withFileTypes: true }); } catch { return []; } const result = []; for (const entry of entries) { if (!entry.isDirectory()) continue; if (USE_ALL_EXTENSIONS_DISALLOW.has(entry.name)) continue; const manifestPath = path.join(extensionsDir, entry.name, 'manifest.json'); try { await fs.access(manifestPath); result.push(entry.name); } catch { // Directory without a manifest is not deployable as an extension. continue; } } return result; } /** * Resolve extension source path from its name. */ export function extensionPath(frameworkRoot, name) { return path.join(frameworkRoot, 'agentic/code/extensions', name); } /** * Check whether a given addon name exists on disk. * The USE_ALL_DISALLOW list does NOT block explicit single-addon installs — * contributors can still run `aiwg use aiwg-dev` directly. */ /** Resolve canonical addon folder name from user-supplied alias. */ function resolveAddonFolderName(name) { const ADDON_ALIASES = { // ring-methodology has always been invokable as 'ring' 'ring': 'ring-methodology', // agent-loop addon — 'al' and 'ralph' are legacy aliases 'al': 'agent-loop', 'ralph': 'agent-loop', }; return ADDON_ALIASES[name] ?? name; } export async function isValidAddon(frameworkRoot, name) { try { const folderName = resolveAddonFolderName(name); const stat = await fs.stat(path.join(frameworkRoot, 'agentic/code/addons', folderName)); return stat.isDirectory(); } catch { return false; } } /** * Resolve addon source path from its name. * Handles known aliases (ring, al, agent-loop). */ export function addonPath(frameworkRoot, name) { const folderName = resolveAddonFolderName(name); return path.join(frameworkRoot, 'agentic/code/addons', folderName); } /** * Provider to deployment paths mapping * * The `behaviors` field tracks where behavior artifacts are deployed per provider. * OpenClaw is the first platform with native behavior support (~/.openclaw/behaviors/). * Other providers receive behaviors via emulation: Claude Code via .claude/hooks/, * Warp via aggregation into WARP.md (empty string = aggregated, not file-per-behavior), * all others via the provider rules directory. * * @implements #609 */ const PROVIDER_PATHS = { claude: { agents: '.claude/agents', // Skills hidden under .claude/.aiwg/skills so Claude Code's flat-namespace // skill-listing budget doesn't truncate them. Discovery is index-driven // via `aiwg index` (epic #1212). Kernel skills (always-loaded set) deploy // separately to .claude/skills/ for native platform discovery. skills: '.claude/.aiwg/skills', commands: '.claude/commands', rules: '.claude/rules', behaviors: '.claude/hooks', // Emulated via hook wrapper }, factory: { agents: '.factory/droids', // Skills hidden under .aiwg/ for index-driven discovery (#1212) skills: '.factory/.aiwg/skills', commands: '.factory/commands', rules: '.factory/rules', behaviors: '.factory/rules', // Emulated via session wrapper in rules dir }, codex: { agents: '.codex/agents', skills: '.codex/.aiwg/skills', commands: '.codex/commands', rules: '.codex/rules', behaviors: '.codex/rules', // Emulated via session wrapper }, opencode: { agents: '.opencode/agent', // Discovered via {agent,agents}/**/*.md glob (#773) skills: '.opencode/.aiwg/skill', commands: '.opencode/command', // OpenCode scans .opencode/command/**/*.md via ConfigCommand.load() (PUW-006 #1107) rules: '.opencode/rule', behaviors: '.opencode/rule', // Emulated via session wrapper }, copilot: { agents: '.github/agents', skills: '.github/.aiwg/skills', commands: '.github/commands', rules: '.github/copilot-rules', behaviors: '.github/copilot-rules', // Emulated via session wrapper }, cursor: { agents: '.cursor/agents', skills: '.cursor/.aiwg/skills', commands: '.cursor/commands', rules: '.cursor/rules', behaviors: '.cursor/rules', // Emulated via session wrapper }, warp: { agents: '.warp/agents', skills: '.warp/.aiwg/skills', commands: '.warp/commands', rules: '.warp/rules', behaviors: '', // Aggregated into WARP.md behaviors section }, windsurf: { agents: '.windsurf/agents', skills: '.windsurf/.aiwg/skills', commands: '.windsurf/workflows', rules: '.windsurf/rules', behaviors: '.windsurf/rules', // Emulated via session wrapper }, hermes: { agents: '', // Aggregated into AGENTS.md at project root // The .aiwg/ subdir is the legacy sequester (#1212) for non-kernel // standard skills. Post-rc.14 kernel pivot, kernel skills land one // level up at ~/.hermes/skills/<name>/ where Hermes natively scans; // this `.aiwg/skills/` path stays empty in current deploys but is // preserved here for the legacy mirror code path. See #1241. skills: path.join(os.homedir(), '.hermes', '.aiwg', 'skills'), commands: '', // Served via MCP, not file-deployed rules: '', // Not applicable — Hermes uses AGENTS.md behaviors: '', // Not yet supported }, openclaw: { agents: path.join(os.homedir(), '.openclaw', 'agents'), // Sequestered under ~/.openclaw/.aiwg/skills/ for index-driven discovery // (#1212). OpenClaw's 150-skill cap is the binding constraint; the kernel // set goes to ~/.openclaw/skills/aiwg/<name> (preserved by the provider's // own deploySkills, not represented here). skills: path.join(os.homedir(), '.openclaw', '.aiwg', 'skills'), commands: path.join(os.homedir(), '.openclaw', 'commands'), rules: path.join(os.homedir(), '.openclaw', 'rules'), behaviors: path.join(os.homedir(), '.openclaw', 'behaviors'), // Native behavior support }, }; const PROVIDER_KERNEL_SKILL_PATHS = { claude: '.claude/skills', factory: '.factory/skills', codex: path.join(os.homedir(), '.codex', 'skills'), opencode: '.opencode/skill', copilot: '.github/skills', cursor: '.cursor/skills', warp: '.warp/skills', windsurf: '.windsurf/skills', openclaw: path.join(os.homedir(), '.openclaw', 'skills', 'aiwg'), }; const MIRRORED_STANDARD_COMMAND_SKILLS = new Set([ 'aiwg-setup-project', 'aiwg-update-claude', 'aiwg-update-agents-md', 'sdlc-accelerate', 'project-status', 'intake-wizard', 'intake-from-codebase', 'intake-start', ]); const MIRRORED_KERNEL_COMMAND_SKILLS = new Set([ 'aiwg-refresh', 'aiwg-doctor', 'aiwg-status', 'aiwg-help', 'aiwg-regenerate', 'aiwg-regenerate-claude', 'aiwg-regenerate-codex', 'aiwg-regenerate-opencode', 'aiwg-regenerate-agents', 'aiwg-issue', 'aiwg-pr', 'use', 'steward', ]); function shouldMirrorStandardCommandSkill(skillName) { return skillName.startsWith('flow-') || MIRRORED_STANDARD_COMMAND_SKILLS.has(skillName); } function shouldMirrorKernelCommandSkill(skillName) { return MIRRORED_KERNEL_COMMAND_SKILLS.has(skillName); } function resolveProviderPath(target, providerPath) { return path.isAbsolute(providerPath) ? providerPath : path.join(target, providerPath); } /** * List skill folder names from a source skills directory. * Returns empty array if the directory doesn't exist. */ async function listSourceSkillNames(skillsDir) { try { const entries = await fs.readdir(skillsDir, { withFileTypes: true }); return entries.filter(e => e.isDirectory()).map(e => e.name); } catch { return []; } } async function mirrorStandardCommandSkills(opts) { const sourceDirs = [ opts.targetSkillsDir, path.join(opts.frameworkRoot, 'agentic/code/frameworks/sdlc-complete/skills'), ]; const seen = new Set(); let count = 0; for (const sourceDir of sourceDirs) { if (!sourceDir || seen.has(sourceDir)) continue; seen.add(sourceDir); const result = await translateSkillsToCommands(sourceDir, { provider: opts.provider, targetDir: opts.targetCommandsDir, projectPath: opts.target, dryRun: opts.dryRun, verbose: opts.verbose, nameFilter: shouldMirrorStandardCommandSkill, }); count += result.translated.length; } return count; } /** * Run pre-deployment collision check for a framework or addon. * Emits warnings/errors to stderr. Returns false if deployment should be blocked. */ async function runPreDeployCollisionCheck(opts) { const { frameworkRoot, framework, target, provider, force, verbose = false } = opts; // Resolve source skills dir for this framework const frameworkDirName = resolveFrameworkDir(framework); if (!frameworkDirName) return true; // no backing directory — skip collision check const sourceSkillsDir = path.join(frameworkRoot, 'agentic/code/frameworks', frameworkDirName, 'skills'); const skillNames = await listSourceSkillNames(sourceSkillsDir); if (skillNames.length === 0) return true; // nothing to check const providerPaths = PROVIDER_PATHS[provider] ?? PROVIDER_PATHS.claude; const skillsBaseDir = path.isAbsolute(providerPaths.skills) ? providerPaths.skills : path.join(target, providerPaths.skills); const results = await checkCollisions({ platform: provider, projectPath: target, skillNames, namespace: 'aiwg', skillsBaseDir, sourceSkillsDir, }); const report = formatCollisionReport(results, { verbose }); if (report) { process.stderr.write(report + '\n'); } if (hasBlockingCollisions(results) && !force) { process.stderr.write('\nDeployment blocked. Use --force to override.\n'); return false; } return true; } /** * Framework-specific next steps guidance * * Keyed as `<provider>/<framework>` with fallback to `<framework>`. * The 'claude' provider is the default (shown for all unrecognized providers). */ const NEXT_STEPS = { // Claude Code (default) // // Standard skills no longer deploy as slash commands. Reach AIWG // capabilities through `aiwg discover` (CLI) or natural-language // requests (the agent queries the index for you). 'sdlc': [ 'Find a skill: aiwg discover "<what you want to do>"', 'Browse via Claude: open Claude and ask for the "accelerated SDLC" or "create intake"', 'Direct CLI: aiwg sdlc-accelerate "Your project idea"', 'Check health: aiwg doctor', ], 'marketing': [ 'Find a skill: aiwg discover "<marketing need>"', 'Browse via Claude: ask for "marketing intake" or "campaign kickoff" — agent queries the index', 'Check health: aiwg doctor', ], 'media-curator': [ 'Find a skill: aiwg discover "<media task>"', 'Browse via Claude: ask "analyze [artist] discography" or "find sources for [content]"', 'Check health: aiwg doctor', ], 'research': [ 'Find a skill: aiwg discover "<research task>"', 'Browse via Claude: ask "research workflow" or "induct [paper]" — agent queries the index', 'Check health: aiwg doctor', ], 'security-engineering': [ 'Find a skill: aiwg discover "<security decision>"', 'Crypto primitives: ask "choose AEAD" or "ad-hoc KDF review"', 'Chain of trust: ask "review the boot chain" or "signed bootstrap design"', 'Decision template: agentic/code/frameworks/security-engineering/templates/cryptographic-decisions.md', 'Check health: aiwg doctor', ], 'all': [ 'Find a skill: aiwg discover "<what you want to do>"', 'Browse via Claude: open Claude and describe your need — agent queries the index', 'Direct CLI: aiwg sdlc-accelerate "Your project idea"', 'Check health: aiwg doctor', ], // Hermes Agent (MCP-based) // Verified against Hermes v0.4.0+ source (hermes_cli/main.py:10860 — // mcp subcommand surface is `serve`, `add`, `remove`, `list`, `test`, // `configure`; no `install` subcommand). #1243. 'hermes/sdlc': [ 'Connect via MCP: hermes mcp add aiwg --command aiwg --args mcp serve', ' (or manual: add aiwg to ~/.hermes/config.yaml — see `aiwg mcp info`)', 'Start Hermes: hermes chat "Create an architecture decision for..."', 'MCP guide: docs/integrations/hermes-quickstart.md', ], 'hermes/marketing': [ 'Connect via MCP: hermes mcp add aiwg --command aiwg --args mcp serve', ' (or manual: add aiwg to ~/.hermes/config.yaml — see `aiwg mcp info`)', 'Start Hermes: hermes chat "Create a marketing campaign for..."', 'MCP guide: docs/integrations/hermes-quickstart.md', ], 'hermes/all': [ 'Connect via MCP: hermes mcp add aiwg --command aiwg --args mcp serve', ' (or manual: add aiwg to ~/.hermes/config.yaml — see `aiwg mcp info`)', 'Start Hermes: hermes chat', 'AIWG MCP guide: docs/integrations/hermes-quickstart.md', ], // Factory AI 'factory/sdlc': [ 'Find a skill: aiwg discover "<what you want to do>"', 'Open Factory: factory (droids deployed; ask for "accelerated SDLC")', 'Direct CLI: aiwg sdlc-accelerate "Your project idea"', 'Check health: aiwg doctor', ], // Cursor 'cursor/sdlc': [ 'Find a skill: aiwg discover "<what you want to do>"', 'Open Cursor: cursor . (agents in .cursor/agents/; ask for "accelerated SDLC")', 'Direct CLI: aiwg sdlc-accelerate "Your project idea"', 'Check health: aiwg doctor', ], // Warp Terminal 'warp/sdlc': [ 'Find a skill: aiwg discover "<what you want to do>"', 'Open Warp: warp (agents/commands aggregated into WARP.md)', 'Direct CLI: aiwg sdlc-accelerate "Your project idea"', 'Check health: aiwg doctor', ], // GitHub Copilot 'copilot/sdlc': [ 'Find a skill: aiwg discover "<what you want to do>"', 'Open VS Code: code . (Copilot agents in .github/agents/)', 'Copilot chat: @workspace use the SDLC workflow agents', 'Check health: aiwg doctor', ], // OpenAI Codex 'codex/sdlc': [ 'Find a skill: aiwg discover "<what you want to do>"', 'Open Codex: codex (agents in .codex/agents/, prompts in ~/.codex/prompts/)', 'Direct CLI: aiwg sdlc-accelerate "Your project idea"', 'Check health: aiwg doctor', ], // Windsurf 'windsurf/sdlc': [ 'Find a skill: aiwg discover "<what you want to do>"', 'Open Windsurf: AGENTS.md and .windsurf/ are ready', 'Ask Cascade: "accelerated SDLC for my project" — agent queries the index', 'Check health: aiwg doctor', ], // OpenClaw 'openclaw/sdlc': [ 'Configure MCP: Add aiwg to ~/.openclaw/config.yaml (see docs/openclaw-guide.md)', 'Start OpenClaw: openclaw (agents, skills, commands, rules, behaviors deployed)', 'Verify: openclaw skills list | grep aiwg', ], 'openclaw/marketing': [ 'Configure MCP: Add aiwg to ~/.openclaw/config.yaml (see docs/openclaw-guide.md)', 'Start OpenClaw: openclaw (marketing agents and skills deployed)', 'Verify: openclaw skills list | grep aiwg', ], 'openclaw/all': [ 'Configure MCP: Add aiwg to ~/.openclaw/config.yaml (see docs/openclaw-guide.md)', 'Start OpenClaw: openclaw (all frameworks deployed)', 'Full guide: docs/openclaw-guide.md', ], }; function printNextSteps(framework, provider = 'claude') { // Try provider-specific first, fall back to generic const providerKey = `${provider}/${framework}`; const steps = NEXT_STEPS[providerKey] ?? NEXT_STEPS[framework] ?? NEXT_STEPS.sdlc; ui.section('Next steps:', steps); } /** * Per-provider session-reload requirement after `aiwg use`. * * Most agentic platforms read their `<provider>/agents/` directory at session * start and cache the agent registry for the lifetime of the session. A * deploy that lands new agent files is invisible to any session that was * already running when the deploy completed — the Agent / Task tool will * still report `Agent type 'foo' not found` until the session reloads. * * Issue #1240: surfacing this requirement in `aiwg use` output and in the * Steward FAQ so operators stop hitting the "fallback to general-purpose" * path silently. */ const SESSION_RELOAD_NOTICE = { claude: { action: 'Restart your Claude Code session (close and reopen) to load the newly deployed agents.', rationale: 'Claude Code reads .claude/agents/ at session start. A running session retains its old registry until reloaded.', }, codex: { action: 'Restart your Codex session to pick up newly deployed agents and home-dir skills.', rationale: 'Codex caches its agent and skill registry per session. ~/.codex/skills/ and .codex/agents/ are scanned on startup.', }, copilot: { action: 'Reload the VS Code window (`Developer: Reload Window`) so Copilot picks up the new .github/agents/ entries.', rationale: 'VS Code/Copilot caches workspace agent definitions until the window reloads.', }, cursor: { action: 'Restart Cursor (close and reopen the project) to load the newly deployed agents.', rationale: 'Cursor reads .cursor/agents/ and .cursor/rules/ on workspace open.', }, warp: { action: 'Open a fresh Warp tab — WARP.md is re-read on tab start.', rationale: 'Warp aggregates context from WARP.md when a new tab spawns; existing tabs keep the prior version.', }, windsurf: { action: 'Restart Windsurf or reload the workspace so the aggregated AGENTS.md is re-parsed.', rationale: 'Windsurf reads AGENTS.md once per workspace session.', }, factory: { action: 'Restart your Factory droid runtime to pick up new entries in .factory/droids/.', rationale: 'Factory caches the droid manifest at runtime start.', }, opencode: { action: 'Restart your OpenCode session — `.opencode/agent/` is scanned on startup.', rationale: 'OpenCode loads agent files on session start and does not hot-reload.', }, hermes: { action: 'In an active Hermes session, run /reload-skills to pick up new skills in ~/.hermes/skills/ and /reload-mcp to pick up MCP server changes (~/.hermes/config.yaml) — both are in-session slash commands, no chat restart needed. Restart the chat only as a fallback if the slash commands are unavailable.', rationale: 'Hermes loads skills and MCP config at session start (verified in hermes_cli/commands.py:178 and hermes_cli/config.py:1228). The /reload-skills and /reload-mcp slash commands re-scan in place; /reload-mcp prompts for confirmation by default.', symptom: 'Until reloaded, newly deployed kernel skills are missing from `hermes skills list` and unreachable via natural-language invocation; new MCP servers (incl. AIWG) are missing from the tool surface.', }, openclaw: { action: 'Restart OpenClaw — ~/.openclaw/agents/ and ~/.openclaw/skills/ are loaded on startup.', rationale: 'OpenClaw reads its home-dir registry once per process.', }, }; function printSessionReloadNotice(provider) { const notice = SESSION_RELOAD_NOTICE[provider]; if (!notice) return; const defaultSymptom = 'Until reloaded, the Agent/Task tool will report "Agent type not found" for the newly deployed agents.'; ui.section('Session reload required:', [ notice.action, `Why: ${notice.rationale}`, notice.symptom ?? defaultSymptom, ]); } /** * Count deployed artifacts in target directories * * @implements #609 */ async function countDeployedArtifacts(target, paths) { const countMd = async (dir) => { if (!dir) return 0; try { // Support absolute paths (openclaw deploys to home dir) const resolvedDir = path.isAbsolute(dir) ? dir : path.join(target, dir); const entries = await fs.readdir(resolvedDir); return entries.filter(f => f.endsWith('.md')).length; } catch { return 0; } }; const countDirs = async (dir) => { if (!dir) return 0; try { const resolvedDir = path.isAbsolute(dir) ? dir : path.join(target, dir); const entries = await fs.readdir(resolvedDir, { withFileTypes: true }); return entries.filter(e => e.isDirectory()).length; } catch { return 0; } }; // Count rules by parsing declared counts from RULES-INDEX.md files rather // than counting .md files on disk. When deployIndexOnly is true, only one // RULES-INDEX.md is deployed but it declares the total count of rules across // all installed components via section headers like "## Name (N rules — ...)". const countRules = async (dir) => { if (!dir) return 0; try { const resolvedDir = path.isAbsolute(dir) ? dir : path.join(target, dir); const entries = await fs.readdir(resolvedDir); const indexFiles = entries.filter(f => f.endsWith('RULES-INDEX.md')); if (indexFiles.length === 0) { // No index files — fall back to counting individual rule .md files return entries.filter(f => f.endsWith('.md')).length; } let total = 0; for (const indexFile of indexFiles) { const content = await fs.readFile(path.join(resolvedDir, indexFile), 'utf-8'); // Match section headers: "## Name (N rules — ..." or "— N rules*" const matches = content.matchAll(/\((\d+) rules[^)]*\)/g); for (const m of matches) { total += parseInt(m[1], 10); } } return total > 0 ? total : entries.filter(f => f.endsWith('.md')).length; } catch { return 0; } }; // Kernel skills deploy to the platform-native skills dir (always-loaded // set) while standard skills sequester under <provider>/.aiwg/skills (the // index-driven discovery tier). Both contribute to the deployed surface, // so both must be counted (#1228). Derive the kernel path by stripping // the `.aiwg/` segment from the standard path. const kernelSkillsPath = paths.skills ? paths.skills.replace(/(^|\/)\.aiwg\/skills?$/, '$1skills') : ''; return { agents: await countMd(paths.agents), commands: await countMd(paths.commands), skills: (await countDirs(paths.skills)) + (kernelSkillsPath && kernelSkillsPath !== paths.skills ? await countDirs(kernelSkillsPath) : 0), rules: await countRules(paths.rules), behaviors: await countDirs(paths.behaviors), }; } async function countDiscoverableSkills(aiwgRoot) { try { const { loadGraphIndexFile } = await import('../../artifacts/index-reader.js'); const index = loadGraphIndexFile(aiwgRoot, 'metadata.json', 'framework'); if (!index?.entries) return null; return Object.values(index.entries).filter(entry => entry.type === 'skill').length; } catch { return null; } } /** * Detect forge targets from .git/config remote URLs. * Returns a list of forge types found: 'github' | 'gitea' * * @implements #661 */ async function detectForges(projectDir) { const forges = new Set(); try { const gitConfig = await fs.readFile(path.join(projectDir, '.git', 'config'), 'utf-8'); if (/github\.com/i.test(gitConfig)) forges.add('github'); // Gitea: any non-github remote host (self-hosted instances) const remoteUrls = [...gitConfig.matchAll(/url\s*=\s*(.+)/g)].map(m => m[1].trim()); for (const url of remoteUrls) { if (!url.includes('github.com') && (url.includes('git.') || url.includes('.net') || url.includes('.io'))) { forges.add('gitea'); } } } catch { // No .git/config — default to github only forges.add('github'); } return [...forges]; } /** * Deploy CI workflow files to .github/workflows/ and/or .gitea/workflows/ * when --ci-hooks-enabled is set. * * @implements #661 */ async function deployCiHooks(opts) { const { frameworkRoot, framework, target, dryRun } = opts; // Resolve framework source dir const ciFrameworkDir = resolveFrameworkDir(framework); if (!ciFrameworkDir) return; // no backing directory — nothing to deploy const frameworkDir = path.join(frameworkRoot, 'agentic/code/frameworks', ciFrameworkDir); // Read CI manifest from framework manifest.json let ciSpec = {}; try { const manifestPath = path.join(frameworkDir, 'manifest.json'); const manifestContent = await fs.readFile(manifestPath, 'utf-8'); const manifest = JSON.parse(manifestContent); ciSpec = manifest.ci ?? {}; } catch { // No CI spec in manifest — nothing to deploy return; } if (Object.keys(ciSpec).length === 0) return; const forges = await detectForges(target); const ciSourceDir = path.join(frameworkDir, 'ci'); const forgeMap = [ { forge: 'github', targetDir: path.join(target, '.github', 'workflows'), files: ciSpec.github ?? [] }, { forge: 'gitea', targetDir: path.join(target, '.gitea', 'workflows'), files: ciSpec.gitea ?? [] }, ]; let deployed = 0; for (const { forge, targetDir, files } of forgeMap) { if (!forges.includes(forge) || files.length === 0) continue; if (!dryRun) { await fs.mkdir(targetDir, { recursive: true }); } for (const file of files) { const src = path.join(ciSourceDir, forge, file); const dest = path.join(targetDir, path.basename(file)); if (dryRun) { console.log(` [dry-run] Would copy CI file: ${src} → ${dest}`); } else { try { await fs.copyFile(src, dest); deployed++; } catch { ui.warn(`Could not copy CI file: ${file} (source missing in framework — skipping)`); } } } } if (!dryRun && deployed > 0) { ui.blank(); ui.warn(`CI hooks installed (${deployed} file(s)). Review before committing — they affect your CI pipeline.`); } } /** * Count artifacts contributed by a single project-local bundle by reading the * bundle's source directories. Approximates what deploy-agents.mjs writes to * the provider deploy paths for this specific bundle (skills are subdirs; * everything else is .md files). * * @implements #1035 */ async function countBundleSourceArtifacts(bundlePath) { const countMd = async (dir) => { try { const entries = await fs.readdir(path.join(bundlePath, dir)); return entries.filter(f => f.endsWith('.md')).length; } catch { return 0; } }; const countDirs = async (dir) => { try { const entries = await fs.readdir(path.join(bundlePath, dir), { withFileTypes: true }); return entries.filter(e => e.isDirectory()).length; } catch { return 0; } }; return { agents: await countMd('agents'), commands: await countMd('commands'), skills: await countDirs('skills'), rules: await countMd('rules'), }; } /** * Deploy a single project-local bundle to one provider via deploy-agents.mjs. * Runs the same script and flags used for upstream addons, with the bundle * directory as the `--source`. Idempotent — overwrites prior deploys. * * @implements #1035 */ async function deployOneProjectLocalBundle(opts) { const { bundle, ctx, frameworkRoot, provider, target, dryRun, verbose, quiet } = opts; const runner = createScriptRunner(frameworkRoot); const args = [ '--source', bundle.bundlePath, '--deploy-commands', '--deploy-skills', '--deploy-rules', '--provider', provider, '--target', target, // Project-local skills MUST land in the per-project skills tier // (#1228 follow-up). Default deploy mode after #1217 is no-copy + // index-driven discovery, but that model assumes upstream skills at // $AIWG_ROOT — project-local bundles live under the project's .aiwg/ // tree and aren't reachable via `aiwg discover` of the framework // graph. Without --copy-all, the bundle's rules deploy but its skills // never reach <provider>/.aiwg/skills/, leaving them invisible to // both the platform and the index. '--copy-all', ]; if (dryRun) args.push('--dry-run'); if (verbose) args.push('--verbose'); if (quiet && !verbose) args.push('--quiet'); // Project-local bundles are addon-shaped — never trigger the legacy commands // migration prompt (which is only relevant for full-framework deploys). args.push('--skip-commands-migration'); const captureOpts = quiet && !verbose ? { capture: true } : {}; const result = await runner.run('tools/agents/deploy-agents.mjs', args, captureOpts); // Approximate counts from the bundle's source dirs (deploy-agents.mjs is // idempotent and copies file-for-file from these dirs) const counts = await countBundleSourceArtifacts(bundle.bundlePath); void ctx; return { exitCode: result.exitCode, counts }; } /** * Discover and deploy all project-local bundles from `.aiwg/{extensions,addons, * frameworks,plugins}/<id>/` for one provider. Updates `aiwg.config.installed` * with `source: 'project-local'` entries. * * Returns the number of bundles deployed and any deploy errors. * * @implements #1035 */ async function deployProjectLocalBundles(opts) { const { ctx, frameworkRoot, projectDir, provider, target, dryRun, verbose, quiet, onlyBundleId } = opts; const discovery = await discoverProjectLocalBundles(projectDir); if (discovery.errors.length > 0 && !quiet) { ui.warn(`Project-local discovery surfaced ${discovery.errors.length} validation error(s) — run 'aiwg list --project-local' for details`); } const targetBundles = onlyBundleId ? discovery.bundles.filter(b => b.id === onlyBundleId) : discovery.bundles; if (targetBundles.length === 0) { return { deployed: 0, failed: 0, bundles: [] }; } // #1037/#1049 — Activity log: emit `discover` for newly-seen bundles // (deduped against recent log tail to avoid spam on repeated commands). if (!dryRun) { await emitDiscoverEventsDeduped(targetBundles.map(b => ({ id: b.id, type: b.type }))); } // #1036 — Resolve shadows against the upstream registry before any deploy. // Refuse to deploy bundles that contain a safety-critical shadow without an // explicit `overrides:` declaration, or that share an artifact id with another // project-local bundle, or that declare a phantom override. const upstream = await buildUpstreamRegistry({ frameworkRoot }); const shadowResult = await resolveShadows(targetBundles, upstream); const report = formatShadowReport(shadowResult); if (report) { process.stderr.write(report + '\n'); } // #1037/#1049 — Activity log per shadow resolution if (!dryRun) { for (const r of shadowResult.resolutions) { if (r.verdict === 'deploy') continue; // no-collision case is silent const bundle = targetBundles.find(b => b.id === r.bundleId); if (!bundle) continue; const event = r.verdict === 'deploy-acknowledged' ? 'shadow-acknowledged' : r.verdict === 'refuse-unsafe' || r.verdict === 'refuse-phantom' || r.verdict === 'refuse-duplicate' ? 'shadow-refused' : 'conflict'; await appendProjectLocalActivity({ event, name: bundle.id, type: bundle.type, summary: `${r.verdict}: ${r.artifactType}/${r.artifactId}${r.upstream ? ` overrides ${r.upstream.source}` : ''}`, }); } } let deployed = 0; let failed = 0; for (const bundle of targetBundles) { if (shadowResult.blockedBundleIds.has(bundle.id)) { failed++; ui.warn(`Refused to deploy project-local bundle '${bundle.id}' due to shadow-resolution policy (see ── above ──)`); continue; } if (verbose || dryRun) { const action = dryRun ? '[dry-run] Would deploy' : 'Deploying'; console.log(`${action} project-local ${bundle.type} '${bundle.id}' from ${bundle.localPath} → ${provider}`); } const result = await deployOneProjectLocalBundle({ bundle, ctx, frameworkRoot, provider, target, dryRun, verbose, quiet, }); if (result.exitCode !== 0) { failed++; ui.warn(`Failed to deploy project-local bundle '${bundle.id}' (exit ${result.exitCode})`); if (!dryRun) { await appendProjectLocalActivity({ event: 'deploy-failed', name: bundle.id, type: bundle.type, summary: `${provider}: exit ${result.exitCode}`, }); } continue; } deployed++; if (!dryRun) { const c = result.counts; await appendProjectLocalActivity({ event: 'deploy', name: bundle.id, type: bundle.type, summary: `${provider}: agents=${c.agents} commands=${c.commands} skills=${c.skills} rules=${c.rules}`, }); } // Persist registry entry (skip in dry-run — no side effects) if (!dryRun) { try { const config = await readAiwgConfig(projectDir); if (!config) continue; // Hash the bundle's manifest.json for stale detection const manifestAbsPath = path.join(bundle.bundlePath, 'manifest.json'); const mHash = await hashManifest(manifestAbsPath); // #1037 — record per-artifact source hashes so `aiwg remove` can // detect pristine vs mutated vs replaced deployed files. const artifactHashes = await hashBundleArtifacts(bundle.bundlePath); const updated = updateInstalled(config, bundle.id, provider, result.counts, { version: bundle.manifest.version, source: 'project-local', manifestHash: mHash, localPath: bundle.localPath, localType: bundle.type, manifestVersion: bundle.manifest.manifestVersion, artifactHashes, }); await writeAiwgConfig(projectDir, updated); } catch (err) { // Non-fatal: deploy already succeeded ui.warn(`Project-local registry update failed for '${bundle.id}': ${err instanceof Error ? err.message : String(err)}`); } } } return { deployed, failed, bundles: targetBundles }; } const USE_FLAGS_WITH_VALUES = new Set([ '--profile', '--provider', '--platform', '--providers', '--prefix', '--scope', '--target', ]); function firstUsePositional(args) { for (let i = 0; i < args.length; i++) { const arg = args[i]; if (USE_FLAGS_WITH_VALUES.has(arg)) { i++; continue; } if (!arg.startsWith('-')) return arg; } return undefined; } function removeFirstPositional(args) { let skipped = false; const result = []; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (USE_FLAGS_WITH_VALUES.has(arg)) { result.push(arg); if (i + 1 < args.length) result.push(args[++i]); continue; } if (!skipped && !arg.startsWith('-')) { skipped = true; continue; } result.push(arg); } return result; } async function deploySourceDirectory(opts) { const args = [ '--source', opts.source, '--deploy-commands', '--deploy-skills', '--deploy-rules', '--provider', opts.provider, '--target', opts.target, ]; if (opts.dryRun) args.push('--dry-run'); if (opts.verbose) args.push('--verbose'); if (opts.force) args.push('--force'); if (opts.copyAll) args.push('--copy-all'); if (opts.quiet) args.unshift('--quiet'); const runner = createScriptRunner(opts.frameworkRoot); return runner.run('tools/agents/deploy-agents.mjs', args, opts.quiet ? { capture: true } : {}); } /** * Use command handler * * Deploys framework agents, commands, and skills to the current project, * then registers them in the extension registry for discovery. */ export class UseHandler { id = 'use'; name = 'Use Framework'; description = 'Deploy AIWG framework to current project'; category = 'framework'; aliases = []; async execute(ctx) { const explicitTarget = firstUsePositional(ctx.args); if (ctx.args.includes('--workspace-signals')) { const signalArgs = ctx.args.filter((a) => a !== '--workspace-signals'); const profileIdx = signalArgs.findIndex((a) => a === '--profile'); const profile = profileIdx >= 0 && signalArgs[profileIdx + 1] ? signalArgs[profileIdx + 1] : undefined; const requestedTarget = firstUsePositional(signalArgs); const remainingSignalArgs = requestedTarget ? removeFirstPositional(signalArgs) : signalArgs; const projectDir = getProjectDir(ctx, remainingSignalArgs); const plan = await resolveWorkspaceSignalPlan(projectDir, { profile, requestedTarget }); return { exitCode: 0, message: formatWorkspaceSignalPlan(plan), }; } let framework = ctx.args[0]; let remainingArgs = ctx.args.slice(1); if (framework === '--profile') { framework = 'all'; remainingArgs = ctx.args; } // Structured logger for this invocation. Records go to both stderr (if // verbose level) and ~/.aiwg/logs/aiwg-YYYY-MM-DD.jsonl with full // provenance (invocation_id, aiwg_version, git_sha, etc.). #925. const log = getLogger('cli:use', { framework: framework ?? '<all>' }); const span = log.span('use'); // Resolve --prefix as alias for --target (#734) // --prefix is more intuitive for "deploy to a project directory" in cloud-init/CI const prefixIdx = remainingArgs.findIndex(a => a === '--prefix'); if (prefixIdx >= 0 && remainingArgs[prefixIdx + 1]) { // Rewrite --prefix to --target for downstream compatibility remainingArgs[prefixIdx] = '--target'; } // Project-isolation warning (UC-NUA-002 / SAD §5.1). Fires once per CLI // process. Skipped when --target/--prefix is explicit (the user named a // destination) so the warning never fights with intentional out-of-cwd // deploys. Skipped on recursive iteration via the module-level guard. if (!projectIsolationChecked) { projectIsolationChecked = true; const userTargetedExplicitDir = remainingArgs.includes('--target'); if (!userTargetedExplicitDir) { const isolationResult = await maybeWarnProjectIsolation({ cwd: ctx.cwd ?? process.cwd() }); if (isolationResult.cancelled) { // User pressed Ctrl-C during the delay — exit cleanly with no // artifacts written (UC-NUA-002 Alt A2). return { exitCode: 130, message: 'Cancelled.' }; } } } // Read project config for config-first resolution (#621). // projectDir resolution uses the shared helper so --target/--prefix, // ctx.cwd, and process.cwd() fallback are handled consistently across // handlers (#919 cleanup). const targetFlagIdx = remainingArgs.findIndex(a => a === '--target'); const targetDir = targetFlagIdx >= 0 && remainingArgs[targetFlagIdx + 1] ? remainingArgs[targetFlagIdx + 1] : null; const projectDir = getProjectDir(ctx, remainingArgs); let config = await readAiwgConfig(projectDir); // Auto-init when no config found (#720) // Check early for --provider/--platform and --providers flags const _providerFlagIdx = remainingArgs.findIndex(a => a === '--provider' || a === '--platform'); const _hasExplicitProvider = _providerFlagIdx >= 0 && !!remainingArgs[_providerFlagIdx + 1]; const _providersFlagIdx = remainingArgs.findIndex(a => a === '--providers'); const _providersValue = _providersFlagIdx >= 0 ? remainingArgs[_providersFlagIdx + 1] : null; const _isDryRun = remainingArgs.includes('--dry-run'); // Bulk/automation intent: `aiwg use all` and `aiwg use --yes` skip the // init wizard and use sensible defaults so CLI calls never hang waiting // on a detached terminal. Users who want the wizard run `aiwg init`. const _isBulkIntent = framework === 'all' || remainingArgs.includes('--yes') || remainingArgs.includes('-y') || remainingArgs.includes('--non-interactive'); if (!config) { if (_providersValue) { // --providers shorthand: write config without wizard const pList = _providersValue === 'default' ? ['claude'] : _providersValue.split(',').map(s => s.trim()).filter(Boolean); config = emptyConfig(pList.length > 0 ? pList : ['claude']); if (!_isDryRun) await writeAiwgConfig(projectDir, config); } else if (_isBulkIntent || targetDir || _hasExplicitProvider || !process.stdin.isTTY) { // Non-interactive: auto-create minimal config with explicit provider or default (#734) // When --prefix/--target is set, or `use all`, or --yes is passed, we're in // automated mode — no wizard, no prompts, no way to hang on stdin. const autoProvider = _hasExplicitProvider ? remainingArgs[_providerFlagIdx + 1] : 'claude'; config = emptyConfig([autoProvider]); if (!_isDryRun) await writeAiwgConfi