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
311 lines (288 loc) • 12.5 kB
JavaScript
/**
* AIWG CLI Entry Point
*
* Single entry point for the `aiwg` command. Dispatches directly to the
* compiled router at `dist/src/cli/router.js` with no intermediate tsx fork
* or facade layer — one Node process per invocation.
*
* Responsibilities:
* 1. Handle channel-switching commands (--use-dev, --use-edge, --use-stable)
* 2. Fire a non-blocking background update check
* 3. Resolve the compiled router (installed path or dev-repo override)
* 4. Dispatch to router.run(args), then process.exit() deterministically
*
* This file is intentionally minimal. All command logic lives in the router
* and its handlers. If you find yourself adding business logic here, it
* probably belongs in a handler instead.
*
* @module bin/aiwg
* @implements #919
*/
import { fileURLToPath } from 'url';
import path from 'path';
import { existsSync, readFileSync } from 'fs';
import os from 'os';
import { randomUUID } from 'crypto';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const packageRoot = path.resolve(__dirname, '..');
function readPackageVersion() {
try {
const pkg = JSON.parse(readFileSync(path.join(packageRoot, 'package.json'), 'utf8'));
return typeof pkg.version === 'string' ? pkg.version : '(unknown)';
} catch {
return '(unknown)';
}
}
function detectVersionChannel(version) {
if (version.includes('-rc.')) return 'rc';
if (version.includes('-beta.')) return 'beta';
if (version.includes('-alpha.')) return 'alpha';
if (version.includes('-nightly.')) return 'nightly';
try {
const raw = readFileSync(path.join(os.homedir(), '.aiwg', 'channel.json'), 'utf8');
const cfg = JSON.parse(raw);
if (cfg?.devMode) return 'dev';
if (typeof cfg?.channel === 'string') return cfg.channel;
} catch {
// Default below.
}
return 'stable';
}
function maybeHandleFastVersion(args) {
if (args.length !== 1) return false;
if (args[0] !== '--version' && args[0] !== '-version') return false;
const version = readPackageVersion();
const channel = detectVersionChannel(version);
console.log(` aiwg ${version} [${channel}]`);
console.log(` path: ${packageRoot}`);
return true;
}
if (maybeHandleFastVersion(process.argv.slice(2))) {
process.exit(0);
}
// Mint or inherit an invocation ID before anything else loads. Child processes
// spawned by handlers (detached update-notifier, aiwg exec, etc.) inherit the
// parent's ID via `AIWG_INVOCATION_ID` so their JSONL log records correlate
// with the parent's — search the log by invocation_id to get the full trace
// across process boundaries.
//
// randomUUID() is UUIDv4 today. Time-ordered v7 is not in Node stdlib yet;
// the correlation property is what matters, not the ordering.
function ensureInvocationId() {
const existing = process.env['AIWG_INVOCATION_ID'];
if (existing) return existing;
const fresh = randomUUID();
process.env['AIWG_INVOCATION_ID'] = fresh;
return fresh;
}
const invocationId = ensureInvocationId();
// Startup tracing — set AIWG_TRACE_STARTUP=1 to print per-phase timings to
// stderr. Useful for diagnosing cold-start regressions. No-op by default so
// it doesn't cost anything on the hot path.
const traceStartup = process.env['AIWG_TRACE_STARTUP'] === '1' ||
process.env['AIWG_TRACE_STARTUP']?.toLowerCase() === 'true';
const startHr = process.hrtime.bigint();
function trace(phase) {
if (!traceStartup) return;
const ms = Number(process.hrtime.bigint() - startHr) / 1_000_000;
process.stderr.write(`[trace] +${ms.toFixed(1)}ms ${phase}\n`);
}
trace('bin:entry');
/**
* Resolve the path to the compiled router.
*
* In dev mode (AIWG --use-dev set), point at the dev repo's `dist/`. In
* stable/next/edge/nightly mode, use this installed package's `dist/`.
*
* If the compiled router is missing (fresh clone without `npm run build`),
* emit a clear error and exit rather than falling back to a tsx fork.
*/
async function resolveRouterPath() {
const { loadConfig } = await import('../dist/src/channel/manager.mjs');
const config = await loadConfig();
if (config.devMode && config.edgePath && config.edgePath !== packageRoot) {
const devRouter = path.join(config.edgePath, 'dist', 'src', 'cli', 'router.js');
if (!existsSync(devRouter)) {
console.error(`Dev mode: compiled router not found at ${devRouter}`);
console.error(` Run: (cd ${config.edgePath} && npm run build)`);
console.error(` Or switch back: aiwg --use-stable`);
process.exit(1);
}
return devRouter;
}
const installedRouter = path.join(packageRoot, 'dist', 'src', 'cli', 'router.js');
if (!existsSync(installedRouter)) {
console.error(`Compiled router not found at ${installedRouter}`);
console.error(` This is a packaging bug. Please report it at:`);
console.error(` https://git.integrolabs.net/roctinam/aiwg/issues`);
process.exit(1);
}
return installedRouter;
}
/**
* Parse verbosity flags from argv and set the logger level. Stackable:
* (none) → warn and above
* -v → info
* -vv → debug
* -vvv → debug + scope filter wide-open
* --quiet → error only
*
* `AIWG_LOG_LEVEL` env var overrides argv. `--verbose` is a synonym for -v.
*
* Returns the resolved level so the main entry can stash it. Does NOT mutate
* argv — handlers still see the flags. Call before the router loads so the
* logger picks up the right level when it initializes.
*/
async function applyVerbosityFromArgs(args) {
let level = 'warn'; // default
if (args.includes('--quiet') || args.includes('-q')) level = 'error';
else if (args.includes('-vvv')) { level = 'debug'; process.env['AIWG_DEBUG'] ??= '1'; }
else if (args.includes('-vv')) level = 'debug';
else if (args.includes('-v') || args.includes('--verbose')) level = 'info';
// Env var takes precedence (for CI or scripting).
const envLevel = process.env['AIWG_LOG_LEVEL']?.toLowerCase();
if (envLevel && ['debug', 'info', 'warn', 'error', 'silent'].includes(envLevel)) {
level = envLevel;
}
// Reach into the compiled logger (same dist/ we're about to dispatch to)
// and set the level. Failing to import the logger here is non-fatal — the
// logger's own fallbacks will pick up AIWG_LOG_LEVEL from env.
try {
const routerPath = await resolveRouterPath();
const logPath = path.join(path.dirname(routerPath), 'log.js');
if (existsSync(logPath)) {
const { setLogLevel, setInvocationId, pruneOldLogs } = await import('file://' + logPath);
setLogLevel(level);
setInvocationId(invocationId);
// One-shot prune of old JSONL files on startup. Bounded work; safe to
// call on every invocation because the work is a single directory list.
pruneOldLogs();
}
} catch {
// If the logger isn't importable yet (fresh clone without dist/), the
// regular router-resolution error below surfaces it.
}
return level;
}
async function main() {
const args = process.argv.slice(2);
// Channel-switching commands — handled before anything else so they work
// even when the router can't load (e.g. fixing a broken dev-mode pointer).
if (args[0] === '--use-main' || args[0] === '--use-edge') {
const { switchToEdge } = await import('../dist/src/channel/manager.mjs');
await switchToEdge();
return;
}
if (args[0] === '--use-dev') {
const { switchToDev } = await import('../dist/src/channel/manager.mjs');
const devPath = args[1] || process.cwd();
await switchToDev(devPath);
return;
}
if (args[0] === '--use-stable' || args[0] === '--use-npm') {
const { switchToStable } = await import('../dist/src/channel/manager.mjs');
await switchToStable();
return;
}
// Wire up the logger level from -v/-vv/--quiet/AIWG_LOG_LEVEL before any
// handler runs, and stamp the top-level invocation ID so the logger can
// tag every record with it.
await applyVerbosityFromArgs(args);
// Update notifier: print any pending notice from the previous run's
// background check, then schedule the next background check. Both are
// non-blocking — the current command never waits on the network.
// Honors NO_UPDATE_NOTIFIER, CI=*, and non-TTY stderr.
const { scheduleBackgroundCheck, maybePrintNotice } = await import('../dist/src/update/notifier.mjs');
maybePrintNotice();
scheduleBackgroundCheck(packageRoot);
// Top-level cancellation controller. SIGINT / SIGTERM flip it, long-running
// handlers plumb ctx.signal through fetches and loops so Ctrl-C cancels
// in-flight work cleanly instead of leaving orphaned sockets and children.
// Exit codes 130 (SIGINT = 128+2) and 143 (SIGTERM = 128+15) follow shell
// convention so scripts can branch on the signal kind.
const abortController = new AbortController();
const onSigint = () => {
abortController.abort('sigint');
// Safety deadline: if a handler does not honor the signal, force exit
// after 3s. .unref() so a well-behaved handler can still finish first.
const deadline = setTimeout(() => process.exit(130), 3_000);
deadline.unref?.();
};
const onSigterm = () => {
abortController.abort('sigterm');
const deadline = setTimeout(() => process.exit(143), 3_000);
deadline.unref?.();
};
process.once('SIGINT', onSigint);
process.once('SIGTERM', onSigterm);
// Direct in-process dispatch — no tsx fork, no facade, no router-loader.
trace('resolve:router');
const routerPath = await resolveRouterPath();
trace('import:router');
const { run } = await import('file://' + routerPath);
trace('dispatch:begin');
try {
await run(args, { cwd: process.cwd(), signal: abortController.signal });
} finally {
trace('dispatch:end');
process.removeListener('SIGINT', onSigint);
process.removeListener('SIGTERM', onSigterm);
}
}
// Give the background update check a brief grace window before forcing exit.
// Without an explicit process.exit(), unawaited promises (HTTPS keepalive
// sockets, libuv worker handles, buffered readline from the update prompt)
// can keep the event loop alive for minutes on slow networks or detached
// terminals — the "30s command that hangs for 5 minutes" symptom we debugged
// in #924. 1500ms is plenty for a normal npm registry check; if the check
// is slower the user still gets their shell back promptly and the check
// runs again on the next invocation.
// Lazy-loaded structured error formatter. Imported on demand so a failing
// top-level catch doesn't itself throw by trying to load a missing dist/.
async function formatAndExit(error, fallbackCode = 1) {
// Show stack trace when the user has opted in to verbose diagnostics.
const verbose =
process.env.AIWG_DEBUG === '1' ||
process.env.AIWG_DEBUG?.toLowerCase() === 'true' ||
process.env.DEBUG === '1' ||
process.argv.includes('--verbose') ||
process.argv.includes('-vv') ||
process.argv.includes('-vvv');
let exitCode = fallbackCode;
try {
const errorsMod = await import(
'file://' + path.join(packageRoot, 'dist', 'src', 'cli', 'errors.js')
);
const { formatError, exitCodeFor } = errorsMod;
const formatted = formatError(error, { verbose });
// Strip ANSI colors when stderr isn't a TTY so piped output stays clean.
process.stderr.write(formatted + '\n');
exitCode = exitCodeFor(error);
} catch {
// Fallback path: dist/ missing or errors.js failed to load. Print a
// minimal message so we never silently exit.
const msg = error instanceof Error ? error.message : String(error);
process.stderr.write(`aiwg: error: ${msg}\n`);
if (verbose && error instanceof Error && error.stack) {
process.stderr.write(error.stack + '\n');
}
}
process.exit(exitCode);
}
// Install process-level handlers for unhandled failures so the same
// structured formatter renders them instead of Node's default crash dump.
process.on('uncaughtException', (err) => {
formatAndExit(err, 1).catch(() => process.exit(1));
});
process.on('unhandledRejection', (reason) => {
formatAndExit(reason, 1).catch(() => process.exit(1));
});
// With the update notifier now running as a detached unref()'d child (#920),
// main() has no background promise to grace-wait on — the router finishes,
// we exit. The background child writes its cache file and exits on its own
// schedule.
main()
.then(() => process.exit(0))
.catch((error) => formatAndExit(error, 1));