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
561 lines (498 loc) • 20.9 kB
JavaScript
/**
* Guided onboarding command.
*
* Dry-run mode prints the provider/project/framework/deploy/verify plan
* without writing files. Normal execution delegates deployment to the existing
* `aiwg use` path and then runs the deterministic status probe.
*/
import fs from 'fs';
import path from 'path';
import os from 'os';
import { fileURLToPath } from 'url';
import { spawn } from 'child_process';
import readline from 'readline/promises';
import { buildVerificationProbe } from './workspace-status.mjs';
const PROJECT_SIGNALS = [
'.git',
'package.json',
'pyproject.toml',
'Cargo.toml',
'go.mod',
'pom.xml',
'Gemfile',
'build.gradle',
];
const MAX_PARENT_DEPTH = 3;
const VALID_PROVIDERS = [
'claude',
'codex',
'copilot',
'cursor',
'factory',
'opencode',
'openclaw',
'warp',
'windsurf',
'generic',
];
const VALID_FRAMEWORKS = [
'sdlc',
'research',
'marketing',
'forensics',
'ops',
'security-engineering',
'knowledge-base',
'media-curator',
'writing',
'general',
];
const PROFILE_PRESETS = {
beginner: { framework: 'sdlc', goal: 'help me start a project' },
default: { framework: 'sdlc', goal: 'help me start a project' },
sdlc: { framework: 'sdlc', goal: 'help me start a project' },
research: { framework: 'research', goal: 'organize research and citations' },
marketing: { framework: 'marketing', goal: 'plan a marketing campaign' },
forensics: { framework: 'forensics', goal: 'investigate an incident' },
ops: { framework: 'ops', goal: 'manage infrastructure operations' },
security: { framework: 'security-engineering', goal: 'assess security' },
'knowledge-base': { framework: 'knowledge-base', goal: 'build a knowledge base' },
writing: { framework: 'writing', goal: 'improve writing with voice guidance' },
};
const INTENT_CLUSTERS = [
{ match: /start|idea|build|project|requirements|intake/i, framework: 'sdlc', discover: 'intake wizard' },
{ match: /research|paper|citation|grade|literature/i, framework: 'research', discover: 'research workflow' },
{ match: /market|campaign|brand|content|audience/i, framework: 'marketing', discover: 'marketing intake' },
{ match: /incident|forensic|breach|ioc|timeline/i, framework: 'forensics', discover: 'incident timeline' },
{ match: /infra|server|ops|runbook|fleet/i, framework: 'ops', discover: 'ops runbook' },
{ match: /security|threat|crypto|secret|supply chain/i, framework: 'security-engineering', discover: 'security assessment' },
{ match: /remember|knowledge|wiki|memory|corpus/i, framework: 'knowledge-base', discover: 'memory ingest' },
{ match: /write|voice|draft|prose|copy/i, framework: 'writing', discover: 'apply voice profile' },
];
const PROVIDER_PROMPT_ORDER = ['codex', 'claude', 'opencode', 'cursor', 'copilot', 'warp', 'windsurf', 'factory', 'openclaw', 'generic'];
function parseArgs(args) {
const options = {
dryRun: false,
json: false,
help: false,
provider: null,
framework: null,
profile: null,
nonInteractive: false,
executionGuard: null,
goal: '',
projectRoot: process.cwd(),
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--dry-run' || arg === '-n') {
options.dryRun = true;
} else if (arg === '--json') {
options.json = true;
} else if (arg === '--help' || arg === '-h') {
options.help = true;
} else if ((arg === '--provider' || arg === '-p') && args[i + 1]) {
options.provider = args[++i];
} else if ((arg === '--framework' || arg === '-f') && args[i + 1]) {
options.framework = args[++i];
} else if (arg === '--profile' && args[i + 1]) {
options.profile = args[++i];
} else if (arg === '--non-interactive' || arg === '--yes' || arg === '-y') {
options.nonInteractive = true;
} else if ((arg === '--goal' || arg === '-g') && args[i + 1]) {
options.goal = args[++i];
} else if (!arg.startsWith('-')) {
options.projectRoot = path.resolve(arg);
}
}
return options;
}
function enforceExecutionGuards(options, input = process.stdin, output = process.stdout) {
const guarded = { ...options };
if (guarded.json) {
guarded.dryRun = true;
guarded.executionGuard = 'JSON output is plan-only and never deploys.';
} else if (!guarded.nonInteractive && !guarded.dryRun && !(input.isTTY && output.isTTY)) {
guarded.dryRun = true;
guarded.executionGuard = 'Unattended execution requires --non-interactive; showing a dry-run plan instead.';
}
return guarded;
}
function displayHelp() {
console.log(`
AIWG - Onboarding Wizard
USAGE
aiwg wizard [options] [project-root]
OPTIONS
--goal <text> Plain-language goal used to recommend a framework
--profile <preset> Non-interactive preset (${Object.keys(PROFILE_PRESETS).join(', ')})
--provider <name> Provider to target (${VALID_PROVIDERS.join(', ')})
--framework <name> Framework to deploy (${VALID_FRAMEWORKS.join(', ')})
--non-interactive Execute with selected or inferred defaults; never prompt
--dry-run, -n Print the guided plan without writing files
--json Output the guided plan as JSON
--help, -h Show this help message
EXAMPLES
aiwg wizard --dry-run --goal "help me start a project"
aiwg wizard
aiwg wizard --non-interactive --profile beginner --provider codex
aiwg wizard --provider codex --framework sdlc --dry-run
`);
}
function hasCsprojFile(dir) {
try {
return fs.readdirSync(dir).some((entry) => /\.csproj$/i.test(entry));
} catch {
return false;
}
}
function detectProjectSignal(start) {
let dir = path.resolve(start);
for (let depth = 0; depth <= MAX_PARENT_DEPTH; depth++) {
for (const signal of PROJECT_SIGNALS) {
if (fs.existsSync(path.join(dir, signal))) {
return { found: true, signal, foundAt: dir };
}
}
if (hasCsprojFile(dir)) {
return { found: true, signal: '*.csproj', foundAt: dir };
}
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
return { found: false, signal: null, foundAt: null };
}
function isUnsuitableCwd(cwd, home = os.homedir()) {
const normalized = stripTrailingSep(path.resolve(cwd));
return normalized === stripTrailingSep(home) || normalized === '/' || normalized === '/tmp';
}
function stripTrailingSep(value) {
return value.length > 1 && /[\\/]$/.test(value) ? value.slice(0, -1) : value;
}
function detectProviders(projectRoot, env = process.env) {
const found = [];
const add = (name, reason) => {
if (VALID_PROVIDERS.includes(name) && !found.some((provider) => provider.name === name)) {
found.push({ name, reason });
}
};
const envProvider = env.AIWG_PROVIDER || env.CLAUDECODE_PROVIDER;
if (envProvider) add(envProvider, 'environment');
if (env.CODEX_SANDBOX || env.CODEX_HOME) add('codex', 'environment');
const projectSignals = [
['claude', '.claude'],
['codex', '.codex'],
['copilot', '.github'],
['cursor', '.cursor'],
['opencode', '.opencode'],
['warp', '.warp'],
['windsurf', '.windsurf'],
['generic', '.agents'],
];
for (const [provider, relativePath] of projectSignals) {
if (fs.existsSync(path.join(projectRoot, relativePath))) add(provider, `project path ${relativePath}`);
}
const primary = found.length === 1 ? found[0].name : found.length > 1 ? null : null;
return {
count: found.length,
primary,
providers: found,
status: found.length === 0 ? 'none-detected' : found.length === 1 ? 'single-detected' : 'multiple-detected',
};
}
function resolveProfile(profile) {
if (!profile) return null;
return PROFILE_PRESETS[profile] || null;
}
function resolveProvider(options, projectRoot) {
const detected = detectProviders(projectRoot);
if (options.provider) {
return { provider: options.provider, detected, reason: 'selected by user' };
}
if (detected.primary) {
return { provider: detected.primary, detected, reason: detected.providers[0].reason };
}
if (detected.providers.length > 1 && options.nonInteractive) {
const selected = PROVIDER_PROMPT_ORDER.find((candidate) => detected.providers.some((provider) => provider.name === candidate));
return { provider: selected || detected.providers[0].name, detected, reason: 'non-interactive default from detected providers' };
}
if (envPreferredProvider()) {
return { provider: envPreferredProvider(), detected, reason: 'environment' };
}
return { provider: 'generic', detected, reason: 'fallback default' };
}
function envPreferredProvider() {
const envProvider = process.env.AIWG_PROVIDER || process.env.CLAUDECODE_PROVIDER;
if (envProvider && VALID_PROVIDERS.includes(envProvider)) return envProvider;
if (process.env.CODEX_SANDBOX || process.env.CODEX_HOME) return 'codex';
return null;
}
function inferFramework(goal) {
if (!goal) return { framework: 'sdlc', discover: 'intake wizard', reason: 'default beginner path' };
for (const cluster of INTENT_CLUSTERS) {
if (cluster.match.test(goal)) {
return { framework: cluster.framework, discover: cluster.discover, reason: `matched goal phrase: ${cluster.discover}` };
}
}
return { framework: 'sdlc', discover: 'aiwg steward', reason: 'no close match; start with steward/discovery' };
}
function validateSelection(name, value, allowed) {
if (!value || allowed.includes(value)) return null;
return {
severity: 'error',
message: `Unknown ${name}: ${value}`,
action: `Choose one of: ${allowed.join(', ')}`,
};
}
function buildWizardPlan(options) {
const projectRoot = path.resolve(options.projectRoot);
const projectSignal = detectProjectSignal(projectRoot);
const profile = resolveProfile(options.profile);
const goal = options.goal || profile?.goal || '';
const inferred = inferFramework(goal);
const providerResolution = resolveProvider(options, projectRoot);
const provider = providerResolution.provider;
const providerDetection = providerResolution.detected;
const framework = options.framework || profile?.framework || inferred.framework;
const warnings = [];
const providerError = validateSelection('provider', provider, VALID_PROVIDERS);
const frameworkError = validateSelection('framework', framework, VALID_FRAMEWORKS);
const profileError = options.profile && !profile
? validateSelection('profile', options.profile, Object.keys(PROFILE_PRESETS))
: null;
if (providerError) warnings.push(providerError);
if (frameworkError) warnings.push(frameworkError);
if (profileError) warnings.push(profileError);
if (!options.provider && providerDetection.count > 1 && !options.nonInteractive) {
warnings.push({
severity: 'error',
message: `Multiple providers detected: ${providerDetection.providers.map((item) => item.name).join(', ')}`,
action: 'Re-run with --provider <name>, or use --non-interactive to accept the default.',
});
}
if (!options.provider && providerDetection.count === 0) {
warnings.push({
severity: 'warning',
message: 'No provider-specific project files were detected.',
action: 'The wizard will use the generic provider unless you pass --provider.',
});
}
if (options.executionGuard) {
warnings.push({
severity: 'warning',
message: options.executionGuard,
action: options.json ? 'Re-run without --json to execute.' : 'Re-run with --non-interactive to execute without prompts.',
});
}
if (!projectSignal.found && isUnsuitableCwd(projectRoot)) {
warnings.push({
severity: 'warning',
message: 'No project detected here. AIWG will deploy to the current directory.',
action: 'Run this from your project root before deploying.',
});
}
const deployArgs = ['use', framework, '--provider', provider, '--target', projectRoot, '--non-interactive'];
const deployCommand = `aiwg ${deployArgs.join(' ')}`;
const verifyCommand = 'aiwg status --probe --json';
const dryRun = Boolean(options.dryRun);
const hasErrors = warnings.some((warning) => warning.severity === 'error');
return {
schema: 'aiwg.wizard.plan.v1',
dry_run: dryRun,
non_interactive: Boolean(options.nonInteractive),
writes_files: !dryRun && !hasErrors,
project_root: projectRoot,
goal: goal || null,
profile: options.profile || null,
recommendation: {
provider,
framework,
reason: options.framework ? 'selected by user' : profile ? `profile preset: ${options.profile}` : inferred.reason,
discover_phrase: inferred.discover,
},
provider_detection: providerDetection,
project_detection: projectSignal,
warnings,
deployment: {
command: deployCommand,
args: deployArgs,
},
steps: [
{ id: 'provider', status: providerError ? 'error' : 'ready', detail: provider },
{ id: 'project', status: projectSignal.found ? 'ready' : 'needs-review', detail: projectSignal.foundAt || projectRoot },
{ id: 'framework', status: frameworkError ? 'error' : 'ready', detail: framework },
{ id: 'deploy', status: dryRun ? 'planned' : hasErrors ? 'blocked' : 'ready', command: deployCommand },
{ id: 'verify', status: dryRun ? 'planned' : hasErrors ? 'blocked' : 'required', command: verifyCommand },
],
next_actions: [
dryRun ? `Run: ${deployCommand}` : 'Open your target agent session in this project root.',
`Then verify: ${verifyCommand}`,
],
};
}
function printPlan(plan) {
console.log('\nAIWG Onboarding Wizard');
console.log('='.repeat(60));
console.log('');
console.log('Provider: ' + plan.recommendation.provider + ` (${plan.provider_detection.status})`);
console.log('Project: ' + (plan.project_detection.found ? `${plan.project_detection.foundAt} (${plan.project_detection.signal})` : `${plan.project_root} (no signal found)`));
console.log('Framework: ' + plan.recommendation.framework);
console.log('Reason: ' + plan.recommendation.reason);
console.log('');
if (plan.warnings.length > 0) {
console.log('Warnings:');
for (const warning of plan.warnings) {
console.log(` ${warning.severity}: ${warning.message}`);
console.log(` ${warning.action}`);
}
console.log('');
}
console.log('Guided Steps:');
for (const step of plan.steps) {
console.log(` ${step.id.padEnd(10)} ${step.status}${step.command ? ` ${step.command}` : ` ${step.detail}`}`);
}
console.log('');
if (plan.dry_run) {
console.log('No files were written by this dry run.');
} else {
console.log('This run will deploy through the existing aiwg use path.');
}
console.log('Verification is required before treating setup as complete:');
console.log(' ' + plan.steps.find((step) => step.id === 'verify').command);
console.log('');
}
function canPrompt(options, input = process.stdin, output = process.stdout) {
return !options.nonInteractive && !options.json && input.isTTY && output.isTTY;
}
function formatChoices(choices) {
return choices.map((choice, index) => `${index + 1}) ${choice}`).join(' ');
}
async function promptChoice(rl, label, choices, defaultChoice) {
const defaultIndex = choices.indexOf(defaultChoice);
const suffix = defaultIndex >= 0 ? ` [${defaultIndex + 1}]` : '';
const answer = (await rl.question(`${label}\n${formatChoices(choices)}\nChoose${suffix}: `)).trim();
if (!answer) return defaultChoice;
const selectedIndex = Number(answer);
if (Number.isInteger(selectedIndex) && selectedIndex >= 1 && selectedIndex <= choices.length) {
return choices[selectedIndex - 1];
}
const normalized = choices.find((choice) => choice.toLowerCase() === answer.toLowerCase());
return normalized || defaultChoice;
}
async function promptText(rl, label, defaultValue) {
const suffix = defaultValue ? ` [${defaultValue}]` : '';
const answer = (await rl.question(`${label}${suffix}: `)).trim();
return answer || defaultValue;
}
async function promptYesNo(rl, label, defaultValue = false) {
const suffix = defaultValue ? ' [Y/n]' : ' [y/N]';
const answer = (await rl.question(`${label}${suffix}: `)).trim().toLowerCase();
if (!answer) return defaultValue;
return answer === 'y' || answer === 'yes';
}
function providerPromptChoices(detected) {
const detectedNames = detected.providers.map((provider) => provider.name);
const orderedDetected = PROVIDER_PROMPT_ORDER.filter((provider) => detectedNames.includes(provider));
const remainder = PROVIDER_PROMPT_ORDER.filter((provider) => !orderedDetected.includes(provider));
return [...orderedDetected, ...remainder];
}
async function promptWizardOptions(options, io = {}) {
const input = io.input || process.stdin;
const output = io.output || process.stdout;
if (!canPrompt(options, input, output)) return options;
const prompted = { ...options };
const rl = io.readlineInterface || readline.createInterface({ input, output });
try {
const projectRoot = path.resolve(prompted.projectRoot);
const detected = detectProviders(projectRoot);
if (!prompted.goal && !prompted.framework && !prompted.profile) {
prompted.goal = await promptText(rl, 'What are you working on?', 'help me start a project');
}
if (!prompted.provider && detected.count !== 1) {
const choices = providerPromptChoices(detected);
const defaultProvider = detected.providers.length > 0
? PROVIDER_PROMPT_ORDER.find((provider) => detected.providers.some((item) => item.name === provider)) || detected.providers[0].name
: envPreferredProvider() || 'generic';
prompted.provider = await promptChoice(rl, 'Which AI provider should AIWG target?', choices, defaultProvider);
}
if (!prompted.framework && !prompted.profile) {
const inferred = inferFramework(prompted.goal);
prompted.framework = await promptChoice(rl, 'Which AIWG path should be deployed first?', VALID_FRAMEWORKS, inferred.framework);
}
if (!prompted.dryRun) {
const deployNow = await promptYesNo(rl, 'Deploy this plan now?', false);
prompted.dryRun = !deployNow;
}
} finally {
rl.close();
}
return prompted;
}
function resolveAiwgBin() {
if (process.env.AIWG_BIN) return process.env.AIWG_BIN;
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'bin', 'aiwg.mjs');
}
function spawnAiwg(args, options = {}) {
const aiwgBin = resolveAiwgBin();
return new Promise((resolve) => {
const child = spawn(process.execPath, [aiwgBin, ...args], {
cwd: options.cwd || process.cwd(),
stdio: options.capture ? 'pipe' : 'inherit',
env: { ...process.env, AIWG_WIZARD_CHILD: '1' },
});
let stdout = '';
let stderr = '';
if (options.capture) {
child.stdout?.on('data', (data) => { stdout += data.toString(); });
child.stderr?.on('data', (data) => { stderr += data.toString(); });
}
child.on('close', (code) => resolve({ exitCode: code ?? 0, stdout, stderr }));
child.on('error', (error) => resolve({ exitCode: 1, stdout, stderr: error.message }));
});
}
async function executeWizardPlan(plan, options = {}) {
if (plan.dry_run || plan.warnings.some((warning) => warning.severity === 'error')) {
return { exitCode: plan.warnings.some((warning) => warning.severity === 'error') ? 1 : 0, plan, deploy: null, probe: null };
}
const runCommand = options.runCommand || spawnAiwg;
const deploy = await runCommand(plan.deployment.args, { cwd: plan.project_root, capture: options.capture });
if (deploy.exitCode !== 0) {
return { exitCode: deploy.exitCode, plan, deploy, probe: null };
}
const probe = await buildVerificationProbe(plan.project_root);
return { exitCode: probe.engaged ? 0 : 1, plan, deploy, probe };
}
async function wizard(args) {
let options = parseArgs(args);
if (options.help) {
displayHelp();
return;
}
options = await promptWizardOptions(options);
options = enforceExecutionGuards(options);
const plan = buildWizardPlan(options);
if (options.json) {
console.log(JSON.stringify(plan, null, 2));
} else {
printPlan(plan);
}
const result = await executeWizardPlan(plan);
if (!options.json && result.probe) {
console.log('Verification Result:');
console.log(` Status: ${result.probe.status}`);
console.log(` Engaged: ${result.probe.engaged ? 'yes' : 'no'}`);
if (result.probe.verification.next_command) {
console.log(` Next: ${result.probe.verification.next_command}`);
}
console.log('');
}
if (result.exitCode !== 0) {
process.exitCode = result.exitCode;
}
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
await wizard(process.argv.slice(2));
}
export { buildWizardPlan, detectProjectSignal, detectProviders, enforceExecutionGuards, executeWizardPlan, promptWizardOptions, wizard };