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
686 lines • 29 kB
JavaScript
/**
* Utility Command Handlers
*
* Handlers for utility commands including card prefilling, contribution workflow,
* metadata validation, health diagnostics, and update checking.
*
* @implements @.aiwg/architecture/decisions/ADR-001-unified-extension-system.md
* @source @src/cli/router.ts
* @tests @test/unit/cli/handlers/utilities.test.ts
* @issue #33, #342
*/
import fs from 'fs';
import fsp from 'fs/promises';
import path from 'path';
import { createScriptRunner } from './script-runner.js';
import { getFrameworkRoot } from '../../channel/manager.mjs';
import { forceUpdateCheck } from '../../update/checker.mjs';
import { useHandler as useFrameworkHandler } from './use.js';
import { checkCollisions, } from '../../smiths/skillsmith/collision-detector.js';
/**
* Maps framework registry IDs (e.g. 'sdlc-complete') to `aiwg use` names (e.g. 'sdlc').
*/
const REGISTRY_ID_TO_USE_NAME = {
'sdlc-complete': 'sdlc',
'media-marketing-kit': 'marketing',
'media-curator': 'media-curator',
'research-complete': 'research',
'forensics-complete': 'forensics',
};
/**
* Read the installed frameworks from the on-disk registry.
*/
function readFrameworkRegistry(cwd) {
const registryPath = path.join(cwd, '.aiwg', 'frameworks', 'registry.json');
if (!fs.existsSync(registryPath)) {
return null;
}
try {
return JSON.parse(fs.readFileSync(registryPath, 'utf8'));
}
catch {
return null;
}
}
/**
* Handler for prefill-cards command
*
* Prefills kanban cards with template data for project planning.
*
* Usage:
* aiwg -prefill-cards
* aiwg --prefill-cards
* aiwg -prefill-cards --board <board-name>
*/
export const prefillCardsHandler = {
id: 'prefill-cards',
name: 'Prefill Cards',
description: 'Prefill kanban cards with template data',
category: 'utility',
aliases: ['-prefill-cards', '--prefill-cards'],
async execute(ctx) {
const frameworkRoot = await getFrameworkRoot();
const runner = createScriptRunner(frameworkRoot);
return runner.run('tools/cards/prefill-cards.mjs', ctx.args, {
cwd: ctx.cwd,
});
},
};
/**
* Handler for contribute-start command
*
* Starts a contribution workflow with issue tracking and branching.
*
* Usage:
* aiwg -contribute-start
* aiwg --contribute-start
* aiwg -contribute-start --issue <issue-number>
*/
export const contributeStartHandler = {
id: 'contribute-start',
name: 'Start Contribution',
description: 'Start a contribution workflow',
category: 'utility',
aliases: ['-contribute-start', '--contribute-start'],
async execute(ctx) {
const frameworkRoot = await getFrameworkRoot();
const runner = createScriptRunner(frameworkRoot);
return runner.run('tools/contrib/start-contribution.mjs', ctx.args, {
cwd: ctx.cwd,
});
},
};
/**
* Handler for validate-metadata command
*
* Validates metadata across framework components and artifacts.
*
* Usage:
* aiwg validate-metadata
* aiwg validate-metadata --strict
* aiwg validate-metadata --recursive agentic/code/frameworks/security-engineering/skills
*/
/** Namespace field regex for SKILL.md frontmatter */
const NAMESPACE_RE = /^namespace:\s*(\S+)/m;
/** Description field regex (single-line + multi-line `>` and `|` block scalars) */
const DESCRIPTION_RE = /^description:\s*([>|]?)\s*\n?([\s\S]*?)(?=\n\w+:|\n---|$)/m;
/**
* Count complete sentences in a description string. A sentence is delimited
* by `.`, `?`, or `!` followed by whitespace or end-of-string.
*
* Per the oz-skills two-sentence discipline (PUW-030 / #1131): skills
* should describe themselves in 1-2 sentences. Longer descriptions are
* generally a sign that the skill is doing too much (god-session) or that
* detail belongs in the body, not the frontmatter. Lint-only — does not
* block deploy.
*/
function countSentences(s) {
if (!s)
return 0;
const trimmed = s.trim();
if (trimmed.length === 0)
return 0;
const matches = trimmed.match(/[.!?]+(?:\s|$)/g);
if (!matches)
return 1; // No terminator — treat as one bare sentence
// Trailing terminator counted; if no trailing terminator, the unterminated
// text is also a sentence.
const endsWithTerminator = /[.!?][)\]"'\s]*$/.test(trimmed);
return matches.length + (endsWithTerminator ? 0 : 1);
}
/**
* Scan source SKILL.md files in `agentic/code/` for namespace issues:
* - Missing `namespace: aiwg`
* - Slug (`aiwg-{name}`) that would shadow an AIWG CLI command
*
* Returns lines suitable for console output, or empty array if clean.
*/
async function scanSourceNamespaceIssues(frameworkRoot) {
const issues = [];
const sourceRoot = path.join(frameworkRoot, 'agentic/code');
async function walk(dir) {
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
}
catch {
return;
}
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) {
await walk(full);
}
else if (e.name === 'SKILL.md') {
try {
const content = fs.readFileSync(full, 'utf-8');
const nsMatch = content.match(NAMESPACE_RE);
if (!nsMatch) {
const rel = path.relative(frameworkRoot, full);
issues.push(` WARN missing namespace field: ${rel}`);
}
// PUW-030 (#1131) — two-sentence skill description discipline.
// Lint-only. We extract the description block (handles single-line
// and YAML block scalars) and count sentence terminators.
const fmEnd = content.indexOf('\n---', 4);
const fm = fmEnd > 0 ? content.slice(0, fmEnd) : content;
const descMatch = fm.match(DESCRIPTION_RE);
if (descMatch) {
const desc = (descMatch[2] || '').trim().replace(/\n+/g, ' ');
const sentenceCount = countSentences(desc);
if (sentenceCount > 3) {
const rel = path.relative(frameworkRoot, full);
issues.push(` WARN description too long (${sentenceCount} sentences; oz-skills convention is 1-2): ${rel}`);
}
}
}
catch {
// unreadable
}
}
}
}
await walk(sourceRoot);
return issues;
}
/**
* Scan every `<framework>/<kind>/contributor.md` under `agentic/code/` and
* validate its frontmatter against the registered schema for its kind.
* Returns a list of human-readable issue lines for output, plus a count of
* problems found. Per ADR-023 §Schema validation, malformed contributors
* fail strict validation.
*/
async function scanContributorIssues(frameworkRoot) {
// Lazy-imported so the validate-metadata path stays fast when there are
// no contributors yet; also avoids an upfront dep load when zod is unused.
const [{ parseFrontmatter }, { validateContributor, getRegisteredKinds }] = await Promise.all([
import('../../artifacts/index-builder.js'),
import('../../contributors/validation.js'),
]);
const kinds = new Set(getRegisteredKinds());
const sourceRoot = path.join(frameworkRoot, 'agentic/code');
const lines = [];
let count = 0;
async function walk(dir) {
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
}
catch {
return;
}
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) {
await walk(full);
}
else if (e.name === 'contributor.md') {
// Only validate if the parent directory name is a registered kind.
// Avoids treating unrelated files named `contributor.md` as contributors.
const parent = path.basename(path.dirname(full));
if (!kinds.has(parent))
continue;
try {
const content = fs.readFileSync(full, 'utf-8');
const { data } = parseFrontmatter(content);
const validation = validateContributor(data);
if (!validation.ok) {
const rel = path.relative(frameworkRoot, full);
lines.push(` ERROR ${rel}`);
for (const err of validation.errors) {
lines.push(` ${err}`);
}
count++;
}
}
catch (err) {
const rel = path.relative(frameworkRoot, full);
lines.push(` ERROR ${rel}: ${err.message}`);
count++;
}
}
}
}
await walk(sourceRoot);
return { lines, count };
}
export const validateMetadataHandler = {
id: 'validate-metadata',
name: 'Validate Metadata',
description: 'Validate metadata across components',
category: 'utility',
aliases: ['-validate-metadata', '--validate-metadata'],
async execute(ctx) {
const frameworkRoot = await getFrameworkRoot();
const runner = createScriptRunner(frameworkRoot);
const hasPathArg = ctx.args.some(arg => !arg.startsWith('-'));
const wantsHelp = ctx.args.includes('--help') || ctx.args.includes('-h');
const scriptArgs = wantsHelp || hasPathArg
? ctx.args
: ['--recursive', 'agentic/code'];
// Run the core metadata validation script
const result = await runner.run('tools/cli/validate-metadata.mjs', scriptArgs, {
cwd: ctx.cwd,
});
// Append namespace validation: scan source SKILL.md files
try {
const issues = await scanSourceNamespaceIssues(frameworkRoot);
if (issues.length > 0) {
console.log('\n── Namespace validation ──');
console.log(` ${issues.length} skill(s) missing namespace field:`);
// Show first 20 to avoid flooding output
issues.slice(0, 20).forEach(l => console.log(l));
if (issues.length > 20) {
console.log(` ... and ${issues.length - 20} more`);
}
// Non-zero exit only in strict mode
if (ctx.args.includes('--strict') && result.exitCode === 0) {
return { exitCode: 1, message: `Namespace validation failed: ${issues.length} skill(s) missing namespace field` };
}
}
else {
console.log('\n── Namespace validation: all skills have namespace field ✓');
}
}
catch {
// Namespace scan is non-fatal
}
// Append contributor validation: walk source contributor.md files and
// validate their frontmatter against the kind's zod schema (ADR-023).
try {
const { lines, count } = await scanContributorIssues(frameworkRoot);
if (count > 0) {
console.log('\n── Contributor validation ──');
console.log(` ${count} contributor file(s) failed schema validation:`);
lines.slice(0, 40).forEach(l => console.log(l));
if (lines.length > 40) {
console.log(` ... and ${lines.length - 40} more lines`);
}
// Strict mode escalates to a non-zero exit so CI catches drift.
if (ctx.args.includes('--strict') && result.exitCode === 0) {
return {
exitCode: 1,
message: `Contributor validation failed: ${count} file(s) violated schema`,
};
}
}
else {
console.log('\n── Contributor validation: all contributor.md files conform to schema ✓');
}
}
catch {
// Contributor scan is non-fatal — never break validate-metadata if zod
// import or filesystem walk hits an unexpected condition.
}
return result;
},
};
/**
* #1156 Phase 1 — Validate the per-user registry at `~/.aiwg/installed.json`.
*
* Checks each registered framework+provider deploy:
* - Registry entry parses correctly
* - Recorded artifact entries (Cycle 3 mirrors) still exist on disk
* - Per-artifact-type counts match the actual entry-name list length
* - Pre-Cycle-3 entries (no `entries` snapshot) are surfaced as "limited
* drift detection" rather than failures
*
* Returns exit 1 when drift is detected, exit 0 otherwise. Output is plain
* text suitable for terminal consumption.
*/
async function runUserScopeDoctor(verbose) {
const { readUserRegistry, userRegistryPath } = await import('../../config/user-registry.js');
const { USER_SCOPE_PATHS } = await import('../scope-resolver.js');
const fsp2 = await import('node:fs/promises');
const path2 = await import('node:path');
const registry = await readUserRegistry();
const frameworks = Object.entries(registry.installed);
const lines = [];
lines.push('');
lines.push('── User-scope registry validation ──');
lines.push(`Registry path: ${userRegistryPath()}`);
lines.push('');
if (frameworks.length === 0) {
lines.push('No frameworks deployed at user scope. Run `aiwg use <fw> --scope user` to install.');
return { exitCode: 0, message: lines.join('\n') + '\n' };
}
let driftCount = 0;
let limitedCount = 0;
for (const [name, entry] of frameworks) {
lines.push(`▸ ${name} v${entry.version} [${entry.source}]`);
for (const [provider, providerDeployRaw] of Object.entries(entry.deployedTo)) {
// Cast through unknown for the optional `entries` snapshot.
const providerDeploy = providerDeployRaw;
const userPaths = USER_SCOPE_PATHS[provider];
if (!userPaths) {
lines.push(` ${provider}: ⚠ no user-scope path map registered for this provider`);
driftCount++;
continue;
}
const recorded = providerDeploy.entries;
if (!recorded) {
lines.push(` ${provider}: ⚠ pre-Cycle-3 entry — no per-artifact manifest, drift detection limited`);
lines.push(` counts: agents=${providerDeploy.agents} commands=${providerDeploy.commands} skills=${providerDeploy.skills} rules=${providerDeploy.rules}`);
limitedCount++;
continue;
}
// Walk the recorded entry names and check each one exists on disk.
const checks = [
['agents', userPaths.agents, recorded.agents, providerDeploy.agents],
['commands', userPaths.commands, recorded.commands, providerDeploy.commands],
['skills', userPaths.skills, recorded.skills, providerDeploy.skills],
['rules', userPaths.rules, recorded.rules, providerDeploy.rules],
];
const issues = [];
for (const [type, dir, names, expectedCount] of checks) {
if (!dir || !names)
continue;
let present = 0;
const missing = [];
for (const n of names) {
const target = path2.join(dir, n);
const stat = await fsp2.stat(target).catch(() => null);
if (stat)
present++;
else
missing.push(n);
}
if (names.length !== expectedCount) {
issues.push(`count drift on ${type}: registry says ${expectedCount}, manifest lists ${names.length}`);
}
if (missing.length > 0) {
issues.push(`${type}: ${missing.length}/${names.length} entries missing from ${dir}`);
if (verbose) {
for (const m of missing)
issues.push(` missing: ${path2.join(dir, m)}`);
}
}
else if (verbose) {
issues.push(`${type}: ${present}/${names.length} present at ${dir}`);
}
}
if (issues.length === 0) {
lines.push(` ${provider}: ✓ all recorded artifacts present`);
}
else {
const hasMissing = issues.some(i => i.includes('missing') || i.includes('drift'));
const marker = hasMissing ? '✗' : 'ℹ';
lines.push(` ${provider}: ${marker}`);
for (const i of issues)
lines.push(` ${i}`);
if (hasMissing)
driftCount++;
}
}
}
lines.push('');
lines.push('═'.repeat(60));
lines.push(`Frameworks: ${frameworks.length} Drift: ${driftCount} Limited (pre-Cycle-3): ${limitedCount}`);
if (driftCount > 0) {
lines.push('');
lines.push('Drift detected. To repair, re-run `aiwg use <framework> --scope user --provider <p>`.');
lines.push('To remove a stale registry entry, run `aiwg remove <framework> --scope user`.');
}
return {
exitCode: driftCount > 0 ? 1 : 0,
message: lines.join('\n') + '\n',
};
}
/**
* Handler for doctor command
*
* Runs health diagnostics on the AIWG installation and workspace.
*
* Usage:
* aiwg doctor
* aiwg -doctor
* aiwg --doctor
* aiwg doctor --verbose
* aiwg doctor --scope user # validate ~/.aiwg/installed.json
*/
export const doctorHandler = {
id: 'doctor',
name: 'Doctor',
description: 'Run health diagnostics',
category: 'maintenance',
aliases: ['-doctor', '--doctor'],
async execute(ctx) {
// #1156 Phase 1 — `aiwg doctor --scope user` / `aiwg doctor --user`
// validates the per-user registry (~/.aiwg/installed.json) without
// running the project-scope diagnostics. Operators need this to verify
// their user-scope deployments from any cwd, including shells with no
// project at all.
const userScopeRequested = ctx.args.includes('--user') ||
(ctx.args.includes('--scope') && ctx.args[ctx.args.indexOf('--scope') + 1] === 'user');
if (userScopeRequested) {
return await runUserScopeDoctor(ctx.args.includes('--verbose') || ctx.args.includes('-v'));
}
const frameworkRoot = await getFrameworkRoot();
const runner = createScriptRunner(frameworkRoot);
// Run core doctor diagnostics
const result = await runner.run('tools/cli/doctor.mjs', ctx.args, { cwd: ctx.cwd });
// Surface feedback escape hatch when doctor finds issues
if (result.exitCode !== 0) {
console.log(`
── Recovery options ──
aiwg session --no-repair — launch anyway (skip auto-repair)
aiwg sync — sync to latest and redeploy
aiwg feedback --type bug — report this issue to GitHub
`);
}
// Append collision scan: check deployed skills dir for bad-state collisions
// (platform built-ins and AIWG CLI command shadows)
try {
const projectDir = ctx.cwd || process.cwd();
const skillsDir = path.join(projectDir, '.claude', 'skills');
let deployedSkillNames = [];
try {
const entries = await fsp.readdir(skillsDir, { withFileTypes: true });
deployedSkillNames = entries.filter(e => e.isDirectory()).map(e => e.name);
}
catch {
// No .claude/skills — nothing to check
}
if (deployedSkillNames.length > 0) {
const collisions = await checkCollisions({
platform: 'claude',
projectPath: projectDir,
skillNames: deployedSkillNames,
namespace: 'aiwg',
skillsBaseDir: skillsDir,
});
const errorAndWarn = collisions.filter(r => r.severity === 'error' || r.severity === 'warn');
if (errorAndWarn.length > 0) {
// In doctor context we report stale skills, not deployment blocks.
// Re-running `aiwg use` will auto-clean aiwg-owned stale skills.
console.log('\n── Skill collision scan ──');
console.log('');
console.log('⚠ Stale skills detected (names collide with Claude built-ins):');
for (const r of errorAndWarn) {
console.log(` ✗ ${r.skillName}: ${r.reason}`);
}
console.log('');
console.log(' Fix: run `aiwg use <framework>` to redeploy and auto-clean stale skill directories.');
}
}
}
catch {
// Collision scan is non-fatal for doctor
}
// #1037 / #1049 — Project-local artifacts section: per-type counts,
// validation errors, shadows, denylist violations, drift detection,
// and provider deployment matrix. Replaces the older inline shadow
// scan with the richer section spec'd by design-doctor-log-promote.md.
try {
const projectDir = ctx.cwd || process.cwd();
const fr = await getFrameworkRoot();
const { readAiwgConfig } = await import('../../config/aiwg-config.js');
const config = await readAiwgConfig(projectDir);
const { buildProjectLocalDoctorSection } = await import('../../extensions/project-local-doctor.js');
const onlyProjectLocal = ctx.args.includes('--project-local');
const quiet = ctx.args.includes('--quiet');
const section = await buildProjectLocalDoctorSection({
projectDir, frameworkRoot: fr, config, quiet,
});
if (section.output) {
console.log(section.output);
}
// When --project-local is requested as the only output and the rest
// of doctor printed nothing project-local-specific, fold result exit
// into our own findings.
if (onlyProjectLocal && section.hasFailures) {
return { exitCode: 1, message: '' };
}
}
catch {
// Project-local section is non-fatal for doctor
}
return result;
},
};
/**
* Handler for update command
*
* Updates AIWG and re-deploys installed frameworks/addons.
* - Checks for npm/git updates first
* - Reads .aiwg/frameworks/registry.json to detect installed items
* - Re-deploys only those (preserving the user's current selection)
* - Use --all to deploy everything (equivalent to `aiwg use all`)
*
* Usage:
* aiwg update # Update + re-deploy installed frameworks
* aiwg update --all # Update + deploy everything
* aiwg update --dry-run # Show what would be updated
* aiwg update --provider <name> # Pass through provider to deployment
* aiwg update --skip-check # Skip npm/git update check, only re-deploy
*
* @issue #342
*/
export const updateHandler = {
id: 'update',
name: 'Update',
description: 'Update AIWG and re-deploy installed frameworks',
category: 'maintenance',
aliases: ['-update', '--update'],
async execute(ctx) {
const args = ctx.args;
const deployAll = args.includes('--all');
const dryRun = args.includes('--dry-run');
const skipCheck = args.includes('--skip-check');
// Extract --provider value if present
const providerIdx = args.findIndex(a => a === '--provider' || a === '--platform');
const providerArgs = providerIdx >= 0 && args[providerIdx + 1]
? ['--provider', args[providerIdx + 1]]
: [];
// Step 1: Check for package updates (unless --skip-check)
if (!skipCheck) {
try {
console.log('Checking for AIWG updates...\n');
await forceUpdateCheck();
}
catch (error) {
console.error(`Warning: Update check failed: ${error instanceof Error ? error.message : String(error)}`);
console.log('Continuing with re-deployment...\n');
}
}
// Step 2: Determine what to re-deploy
if (deployAll) {
// --all: deploy everything (equivalent to `aiwg use all`)
const frameworks = ['all'];
if (dryRun) {
console.log('Dry run: Would re-deploy all frameworks and addons');
return { exitCode: 0 };
}
console.log('Re-deploying all frameworks and addons...\n');
const result = await useFrameworkHandler.execute({
...ctx,
args: [...frameworks, ...providerArgs],
});
return result;
}
// Read registry to determine installed frameworks
const registry = readFrameworkRegistry(ctx.cwd);
if (!registry || registry.frameworks.length === 0) {
console.log('No frameworks found in .aiwg/frameworks/registry.json');
console.log('');
console.log('To deploy a framework first, run:');
console.log(' aiwg use sdlc');
console.log(' aiwg use marketing');
console.log(' aiwg use all');
return { exitCode: 0 };
}
// Map registry IDs to framework use-names
const installedFrameworks = [];
const unmapped = [];
for (const fw of registry.frameworks) {
const useName = REGISTRY_ID_TO_USE_NAME[fw.id];
if (useName) {
installedFrameworks.push(useName);
}
else {
unmapped.push(fw.id);
}
}
if (installedFrameworks.length === 0) {
console.log('No recognized frameworks in registry');
if (unmapped.length > 0) {
console.log(`Unrecognized entries: ${unmapped.join(', ')}`);
}
return { exitCode: 0 };
}
// Report what will be updated
console.log(`Installed frameworks: ${installedFrameworks.join(', ')}`);
if (unmapped.length > 0) {
console.log(`Skipping unrecognized: ${unmapped.join(', ')}`);
}
console.log('');
if (dryRun) {
console.log('Dry run: Would re-deploy the following frameworks:');
for (const fw of installedFrameworks) {
console.log(` - ${fw}`);
}
return { exitCode: 0 };
}
// Step 3: Re-deploy each installed framework
const results = [];
for (const fw of installedFrameworks) {
console.log(`Re-deploying ${fw}...`);
const result = await useFrameworkHandler.execute({
...ctx,
args: [fw, ...providerArgs],
});
results.push({ framework: fw, exitCode: result.exitCode });
if (result.exitCode !== 0) {
console.error(`Warning: Failed to re-deploy ${fw}`);
}
}
// Step 4: Report summary
console.log('');
console.log('Update Summary:');
const succeeded = results.filter(r => r.exitCode === 0);
const failed = results.filter(r => r.exitCode !== 0);
for (const r of results) {
const status = r.exitCode === 0 ? 'updated' : 'FAILED';
console.log(` ${r.framework}: ${status}`);
}
console.log('');
console.log(`Updated: ${succeeded.length}/${results.length}`);
return {
exitCode: failed.length > 0 ? 1 : 0,
message: failed.length > 0
? `Some frameworks failed to update: ${failed.map(f => f.framework).join(', ')}`
: `Successfully updated ${succeeded.length} framework(s)`,
};
},
};
/**
* All utility handlers
*/
export const utilityHandlers = [
prefillCardsHandler,
contributeStartHandler,
validateMetadataHandler,
doctorHandler,
updateHandler,
];
//# sourceMappingURL=utilities.js.map