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
558 lines (554 loc) • 21.6 kB
JavaScript
/**
* Config CLI — Subcommand router for `aiwg config`
*
* Subcommands:
* get <key> — Read a config value
* set <key> <value> — Write a config value
* list — Show all user config (merged view)
* validate — Validate all config files
* reset [<key>] — Reset key or all config to defaults
* path — Print the active config directory path
* edit — Open config in $EDITOR
* gitignore — Show/check/fix .gitignore for AIWG runtime dirs
*
* Global flags:
* --config-dir <path> — Override config directory
*
* @implements #545
* @implements #553
*/
import { fileURLToPath } from 'url';
import path from 'path';
import { UserConfig } from './user-config.js';
import { AiwgError, EXIT_CODES } from '../cli/errors.js';
const _scriptDir = path.dirname(fileURLToPath(import.meta.url));
/**
* Main CLI entry point for `aiwg config <subcommand> [args]`
*/
export async function main(args) {
// Extract --config-dir flag before routing
let configDir;
const filteredArgs = [];
for (let i = 0; i < args.length; i++) {
if (args[i] === '--config-dir' && i + 1 < args.length) {
configDir = args[i + 1];
i++; // skip next arg
}
else {
filteredArgs.push(args[i]);
}
}
const subcommand = filteredArgs[0];
const subArgs = filteredArgs.slice(1);
const config = new UserConfig(configDir);
switch (subcommand) {
case 'get':
await handleGet(config, subArgs);
break;
case 'set':
await handleSet(config, subArgs);
break;
case 'list':
case 'ls':
await handleList(config);
break;
case 'validate':
await handleValidate(config);
break;
case 'reset':
await handleReset(config, subArgs);
break;
case 'path':
handlePath(config);
break;
case 'edit':
await handleEdit(config);
break;
case 'gitignore':
await handleGitignore(subArgs);
break;
case 'show':
await handleShow(subArgs);
break;
default:
printUsage();
if (subcommand) {
throw new AiwgError({
code: 'ERR_USAGE_UNKNOWN_SUBCOMMAND',
message: `Unknown config subcommand: ${subcommand}`,
hint: "Run 'aiwg config' without arguments to see usage",
exitCode: EXIT_CODES.USAGE,
});
}
break;
}
}
async function handleGet(config, args) {
const isProject = args.includes('--project');
const positional = args.filter(a => a !== '--project');
const key = positional[0];
if (!key) {
throw new AiwgError({
code: 'ERR_USAGE_MISSING_ARG',
message: 'aiwg config get requires a key',
hint: 'Example: aiwg config get defaults.provider (or --project delivery.mode)',
exitCode: EXIT_CODES.USAGE,
});
}
if (isProject) {
await projectConfigGet(key, args);
return;
}
const value = await config.get(key);
if (value === undefined) {
console.log(`(not set)`);
}
else if (typeof value === 'object') {
console.log(JSON.stringify(value, null, 2));
}
else {
console.log(String(value));
}
}
async function handleSet(config, args) {
const isProject = args.includes('--project');
const positional = args.filter(a => a !== '--project');
const key = positional[0];
const value = positional[1];
if (!key || value === undefined) {
throw new AiwgError({
code: 'ERR_USAGE_MISSING_ARG',
message: 'aiwg config set requires both a key and a value',
hint: 'Example: aiwg config set defaults.verbosity quiet (or --project delivery.mode pr-required)',
exitCode: EXIT_CODES.USAGE,
});
}
if (isProject) {
await projectConfigSet(key, value, args);
return;
}
await config.set(key, value);
console.log(`Set ${key} = ${value}`);
}
// ── Project-config get/set (#1006) ────────────────────────────────────────────
//
// Extends `aiwg config get|set` with `--project` to read/write the project-
// level .aiwg/aiwg.config. Dotted paths address nested fields:
// aiwg config get --project delivery.mode
// aiwg config set --project delivery.mode pr-required
// aiwg config get --project remotes.primary
//
// Set validates enum membership for known fields (delivery.mode,
// delivery.merge_style, delivery.force_push_policy) before writing.
const ENUM_RULES = {
'delivery.mode': ['direct', 'feature-branch', 'pr-required'],
'delivery.merge_style': ['rebase-merge', 'squash', 'merge', 'fast-forward-only'],
'delivery.force_push_policy': ['never', 'own-branch-only', 'allowed'],
};
const BOOLEAN_FIELDS = new Set([
'delivery.delete_branch_on_merge',
'delivery.require_ci_green',
'delivery.require_signed_commits',
'delivery.auto_close_issues',
'delivery.issue_comment_on_cycle',
]);
// Integer fields with valid range constraints. Validated at `aiwg config set`.
// Bounds mirror the JSON schema (vscode-extension/schemas/aiwg.config.v1.json).
const INTEGER_FIELDS = {
'parallelism.max_parallel_subagents': { min: 1, max: 50 },
'parallelism.max_parallel_ralph_loops': { min: 1, max: 20 },
'parallelism.max_parallel_mc_missions': { min: 1, max: 20 },
};
async function projectConfigGet(key, args) {
const { readAiwgConfig, getProjectDir } = await import('./aiwg-config.js');
const projectDir = getProjectDir(undefined, args);
const cfg = await readAiwgConfig(projectDir);
if (!cfg) {
throw new AiwgError({
code: 'ERR_NO_PROJECT_CONFIG',
message: 'No .aiwg/aiwg.config in this project.',
hint: 'Run `aiwg init` to scaffold one, or use `aiwg use <framework>` to deploy.',
exitCode: EXIT_CODES.CONFIG,
});
}
const value = getDottedPath(cfg, key);
if (value === undefined) {
console.log('(not set)');
}
else if (typeof value === 'object') {
console.log(JSON.stringify(value, null, 2));
}
else {
console.log(String(value));
}
}
async function projectConfigSet(key, raw, args) {
const { readAiwgConfig, writeAiwgConfig, getProjectDir, emptyConfig } = await import('./aiwg-config.js');
const projectDir = getProjectDir(undefined, args);
// Validate enum fields before writing
const allowed = ENUM_RULES[key];
if (allowed && !allowed.includes(raw)) {
throw new AiwgError({
code: 'ERR_INVALID_VALUE',
message: `Invalid value for ${key}: '${raw}'. Allowed: ${allowed.join(', ')}`,
hint: `Try: aiwg config set --project ${key} ${allowed[0]}`,
exitCode: EXIT_CODES.USAGE,
});
}
// Coerce booleans for known boolean fields
let value = raw;
if (BOOLEAN_FIELDS.has(key)) {
if (raw === 'true')
value = true;
else if (raw === 'false')
value = false;
else {
throw new AiwgError({
code: 'ERR_INVALID_VALUE',
message: `${key} must be 'true' or 'false', got '${raw}'`,
hint: `Try: aiwg config set --project ${key} true`,
exitCode: EXIT_CODES.USAGE,
});
}
}
// Coerce + range-check integers for known integer fields (#1359).
const intRule = INTEGER_FIELDS[key];
if (intRule) {
const n = Number(raw);
if (!Number.isInteger(n) || n < intRule.min || n > intRule.max) {
throw new AiwgError({
code: 'ERR_INVALID_VALUE',
message: `${key} must be an integer between ${intRule.min} and ${intRule.max}, got '${raw}'`,
hint: `Try: aiwg config set --project ${key} ${intRule.min}`,
exitCode: EXIT_CODES.USAGE,
});
}
value = n;
}
// Read-modify-write — preserve unrelated fields. emptyConfig() seeds the
// base shape when no config file exists yet (e.g. brand-new project).
const cfg = (await readAiwgConfig(projectDir)) ?? emptyConfig();
setDottedPath(cfg, key, value);
await writeAiwgConfig(projectDir, cfg);
console.log(`Set --project ${key} = ${raw}`);
}
/** Read a dotted path from a nested record. Returns undefined when any segment is missing. */
function getDottedPath(obj, key) {
const parts = key.split('.');
let cur = obj;
for (const p of parts) {
if (cur === null || typeof cur !== 'object')
return undefined;
cur = cur[p];
}
return cur;
}
/** Write a dotted path into a nested record, creating intermediate objects. */
function setDottedPath(obj, key, value) {
const parts = key.split('.');
const last = parts.pop();
let cur = obj;
for (const p of parts) {
const next = cur[p];
if (next === undefined || next === null || typeof next !== 'object' || Array.isArray(next)) {
cur[p] = {};
}
cur = cur[p];
}
cur[last] = value;
}
async function handleList(config) {
const allConfig = await config.list();
console.log(`Config directory: ${config.getPath()}\n`);
for (const [filename, data] of Object.entries(allConfig)) {
console.log(`── ${filename} ──`);
if (typeof data === 'string') {
console.log(data);
}
else {
console.log(JSON.stringify(data, null, 2));
}
console.log('');
}
}
async function handleValidate(config) {
const issues = await config.validate();
console.log(`Config directory: ${config.getPath()}\n`);
if (issues.length === 0) {
console.log('✓ All config files valid');
return;
}
const errors = issues.filter(i => i.severity === 'error');
const warnings = issues.filter(i => i.severity === 'warning');
const infos = issues.filter(i => i.severity === 'info');
for (const issue of issues) {
const icon = issue.severity === 'error' ? '✗' : issue.severity === 'warning' ? '!' : 'i';
console.log(` ${icon} [${issue.file}] ${issue.message}`);
}
console.log('');
console.log(`${errors.length} error(s), ${warnings.length} warning(s), ${infos.length} info`);
if (errors.length > 0) {
throw new AiwgError({
code: 'ERR_CONFIG_VALIDATION',
message: `Config validation failed with ${errors.length} error(s)`,
hint: 'Fix the errors listed above, or run: aiwg config edit',
exitCode: EXIT_CODES.CONFIG,
});
}
}
async function handleReset(config, args) {
const isProject = args.includes('--project');
const positional = args.filter(a => a !== '--project');
const key = positional[0];
if (isProject) {
await projectConfigReset(key, args);
return;
}
if (key) {
await config.reset(key);
console.log(`Reset ${key} to default`);
}
else {
await config.reset();
console.log('Reset all config to defaults');
}
}
/**
* Reset a project-config key to its provider-aware default.
*
* Currently scoped to the `parallelism` block (#1359). Resetting
* `parallelism` (no field suffix) restores the block to the primary
* provider's defaults. Resetting a specific field like
* `parallelism.max_parallel_subagents` restores just that field.
*/
async function projectConfigReset(key, args) {
const { readAiwgConfig, writeAiwgConfig, getProjectDir, getProviderParallelismDefaults, } = await import('./aiwg-config.js');
if (!key) {
throw new AiwgError({
code: 'ERR_USAGE_MISSING_ARG',
message: 'aiwg config reset --project requires a key',
hint: 'Example: aiwg config reset --project parallelism',
exitCode: EXIT_CODES.USAGE,
});
}
const projectDir = getProjectDir(undefined, args);
const cfg = await readAiwgConfig(projectDir);
if (!cfg) {
throw new AiwgError({
code: 'ERR_NO_PROJECT_CONFIG',
message: 'No .aiwg/aiwg.config in this project.',
hint: 'Run `aiwg init` to scaffold one.',
exitCode: EXIT_CODES.CONFIG,
});
}
const primary = cfg.providers[0];
const defaults = getProviderParallelismDefaults(primary);
if (key === 'parallelism') {
cfg.parallelism = {
max_parallel_subagents: defaults.max_parallel_subagents,
max_parallel_ralph_loops: defaults.max_parallel_ralph_loops,
max_parallel_mc_missions: defaults.max_parallel_mc_missions,
rationale: `Reset to provider default for ${primary ?? 'unknown'}`,
};
await writeAiwgConfig(projectDir, cfg);
console.log(`Reset --project parallelism to provider default (${primary ?? 'unknown'})`);
return;
}
if (key.startsWith('parallelism.')) {
const field = key.slice('parallelism.'.length);
if (field in defaults) {
cfg.parallelism = cfg.parallelism ?? {};
cfg.parallelism[field] = defaults[field];
await writeAiwgConfig(projectDir, cfg);
console.log(`Reset --project ${key} to provider default (${defaults[field]})`);
return;
}
}
throw new AiwgError({
code: 'ERR_INVALID_KEY',
message: `Reset for project key '${key}' is not supported.`,
hint: 'Supported: parallelism, parallelism.max_parallel_subagents, parallelism.max_parallel_ralph_loops, parallelism.max_parallel_mc_missions',
exitCode: EXIT_CODES.USAGE,
});
}
function handlePath(config) {
console.log(config.getPath());
}
async function handleEdit(config) {
const editor = process.env.EDITOR || process.env.VISUAL || 'vi';
const configPath = `${config.getPath()}/config.yaml`;
// Ensure the config file exists before opening
await config.ensureDir();
const { execSync } = await import('child_process');
try {
execSync(`${editor} "${configPath}"`, { stdio: 'inherit' });
}
catch (err) {
throw new AiwgError({
code: 'ERR_EDITOR_FAILED',
message: `Failed to open editor: ${editor}`,
hint: 'Set EDITOR or VISUAL to a valid editor binary, or edit the file directly',
exitCode: EXIT_CODES.GENERAL,
cause: err,
});
}
}
/**
* `aiwg config show --project [--json]` — print the resolved project-level
* .aiwg/aiwg.config (#999).
*
* Without `--project`, prints a hint pointing at `list` (user config) or
* `show --project` (project config). Project mode resolves the remotes block
* via the same defaults the rest of AIWG uses (origin → primary → issue/ci).
*/
async function handleShow(args) {
const isProject = args.includes('--project');
const isJson = args.includes('--json');
if (!isProject) {
console.log(`aiwg config show requires a scope flag.
For user-level config: aiwg config list
For project-level config: aiwg config show --project [--json]
`);
throw new AiwgError({
code: 'ERR_USAGE_MISSING_FLAG',
message: 'aiwg config show requires --project (or use `aiwg config list` for user config)',
hint: 'Try: aiwg config show --project',
exitCode: EXIT_CODES.USAGE,
});
}
// Lazy-import to keep startup cost low for unrelated subcommands.
const { readAiwgConfig, resolveRemotes, getProjectDir } = await import('./aiwg-config.js');
const { spawnSync } = await import('child_process');
const projectDir = getProjectDir(undefined, args);
const cfg = await readAiwgConfig(projectDir);
if (!cfg) {
throw new AiwgError({
code: 'ERR_NO_PROJECT_CONFIG',
message: 'No .aiwg/aiwg.config in this project.',
hint: 'Run `aiwg init` to scaffold one, or use `aiwg use <framework>` to deploy.',
exitCode: EXIT_CODES.CONFIG,
});
}
const resolvedRemotes = resolveRemotes(cfg.remotes);
// Resolve each declared remote name to its URL via git. Best-effort.
function getUrl(name) {
const r = spawnSync('git', ['-C', projectDir, 'remote', 'get-url', name], {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
});
if (r.status !== 0)
return null;
const out = r.stdout?.toString().trim();
return out || null;
}
const remotesView = {
primary: { name: resolvedRemotes.primary, url: getUrl(resolvedRemotes.primary) },
issue_tracker: { name: resolvedRemotes.issue_tracker, url: getUrl(resolvedRemotes.issue_tracker) },
ci: { name: resolvedRemotes.ci, url: getUrl(resolvedRemotes.ci) },
secondary: resolvedRemotes.secondary.map((s) => ({
...s,
url: getUrl(s.name),
})),
has_remotes_block: !!cfg.remotes,
};
if (isJson) {
console.log(JSON.stringify({
project_dir: projectDir,
version: cfg.version,
providers: cfg.providers,
installed: cfg.installed,
scripts: cfg.scripts,
remotes: remotesView,
}, null, 2));
return;
}
// Human-readable view
console.log(`Project config: ${projectDir}/.aiwg/aiwg.config\n`);
console.log(`Schema version: ${cfg.version}`);
console.log(`Providers: ${cfg.providers.join(', ') || '(none)'}`);
console.log('');
console.log('Installed frameworks:');
const installed = Object.entries(cfg.installed);
if (installed.length === 0) {
console.log(' (none)');
}
else {
for (const [name, entry] of installed) {
const providers = Object.keys(entry.deployedTo).join(', ') || '(no targets)';
console.log(` - ${name} v${entry.version} → ${providers}`);
}
}
console.log('');
console.log('Remote topology:');
if (!remotesView.has_remotes_block) {
console.log(' (no `remotes` block — defaults: origin is primary, issue tracker, and CI)');
}
const fmt = (label, ref) => {
const u = ref.url ? ` (${ref.url})` : ' (no such remote in `git remote`)';
return ` ${label}: ${ref.name}${u}`;
};
console.log(fmt('Primary ', remotesView.primary));
if (remotesView.issue_tracker.name !== remotesView.primary.name) {
console.log(fmt('Issue tracker ', remotesView.issue_tracker));
}
if (remotesView.ci.name !== remotesView.primary.name) {
console.log(fmt('CI ', remotesView.ci));
}
for (const sec of remotesView.secondary) {
const purpose = sec.purpose ? ` — ${sec.purpose}` : '';
const release = sec.push_on_release ? ' [push tags on release]' : '';
const u = sec.url ? ` (${sec.url})` : ' (no such remote in `git remote`)';
console.log(` Secondary : ${sec.name}${u}${purpose}${release}`);
}
console.log('');
}
async function handleGitignore(args) {
const { spawnSync } = await import('child_process');
// Locate the gitignore CLI script relative to this compiled module
// (#1228). At runtime this module lives at `dist/src/config/cli.js`,
// so we need three `..` segments to reach the package root where
// `tools/cli/` is shipped (see package.json `files`).
const scriptPath = path.resolve(_scriptDir, '../../../tools/cli/config-gitignore.mjs');
const result = spawnSync(process.execPath, [scriptPath, ...args], { stdio: 'inherit' });
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
function printUsage() {
console.log(`Usage: aiwg config <subcommand> [options]
Subcommands:
get <key> Read a user config value
get --project <key> Read a project config value (.aiwg/aiwg.config)
set <key> <value> Write a user config value
set --project <key> <value> Write a project config value (validates enums)
list Show all user config
show --project Show resolved project config (.aiwg/aiwg.config)
validate Validate all config files
reset [<key>] Reset key or all config to defaults
path Print config directory path
edit Open config in $EDITOR
gitignore Show/check/fix .gitignore AIWG entries
Global flags:
--config-dir <path> Override config directory
Examples:
aiwg config get defaults.provider
aiwg config set defaults.verbosity quiet
aiwg config set updates.channel next
aiwg config list
aiwg config show --project
aiwg config show --project --json
aiwg config get --project delivery.mode
aiwg config set --project delivery.mode pr-required
aiwg config set --project delivery.merge_style squash
aiwg config get --project parallelism
aiwg config set --project parallelism.max_parallel_subagents 6
aiwg config reset --project parallelism
aiwg config validate
aiwg config path
aiwg config reset defaults.provider
aiwg config --config-dir /custom/path list
aiwg config gitignore
aiwg config gitignore --fix
aiwg config gitignore --check`);
}
//# sourceMappingURL=cli.js.map