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
JavaScript
/**
* 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