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
430 lines • 18.3 kB
JavaScript
/**
* Project-Level AIWG Config
*
* Manages `.aiwg/aiwg.config` — the project-level record of:
* - Which AI provider toolchains this project targets
* - Which frameworks/addons are deployed (with uninstall metadata)
* - User-defined scripts callable via `aiwg run`
*
* @implements #621
*/
import { readFile, writeFile, mkdir, access, readdir, rename, unlink } from 'fs/promises';
import { createHash, randomBytes } from 'crypto';
import { resolve, join, isAbsolute } from 'path';
import { homedir } from 'os';
const CONFIG_FILENAME = 'aiwg.config';
const AIWG_DIR = '.aiwg';
const DEFAULT_BRANCH_NAMING = {
prefix_by_type: {
feat: 'feat/{issue}-{slug}',
fix: 'fix/{issue}-{slug}',
docs: 'docs/{slug}',
chore: 'chore/{slug}',
refactor: 'refactor/{slug}',
test: 'test/{slug}',
},
};
/**
* Resolve the delivery policy with defaults applied.
*
* Defaults are intentionally conservative — they match what AIWG agents
* naturally do today (PR-required, rebase-merge, no force pushes, post issue
* comments) so that adding the schema doesn't shift behavior for existing
* projects.
*/
export function resolveDelivery(delivery) {
return {
mode: delivery?.mode ?? 'pr-required',
default_branch: delivery?.default_branch ?? 'main',
branch_naming: {
prefix_by_type: {
...DEFAULT_BRANCH_NAMING.prefix_by_type,
...(delivery?.branch_naming?.prefix_by_type ?? {}),
},
},
merge_style: delivery?.merge_style ?? 'rebase-merge',
delete_branch_on_merge: delivery?.delete_branch_on_merge ?? true,
require_ci_green: delivery?.require_ci_green ?? true,
require_signed_commits: delivery?.require_signed_commits ?? false,
force_push_policy: delivery?.force_push_policy ?? 'never',
auto_close_issues: delivery?.auto_close_issues ?? true,
issue_comment_on_cycle: delivery?.issue_comment_on_cycle ?? true,
};
}
/**
* Per-provider parallelism defaults. Conservative numbers for Anthropic-backed
* providers reflect Pro/Team-plan rate limits — operators on Enterprise tiers
* should bump via `aiwg config set --project parallelism.max_parallel_subagents N`.
*
* Sources for the numbers:
* - claude / claude-code: Anthropic per-key throttling at higher concurrency
* - codex / copilot / etc.: OpenAI / GitHub quotas are generally per-org and
* less aggressive at small fan-outs (10 is a safe middle ground)
* - hermes: MCP sidecar; rate-limit depends on upstream provider, operator
* should tune. Conservative 10 default.
* - unknown: conservative 4 default.
*/
export const PROVIDER_PARALLELISM_DEFAULTS = {
claude: { max_parallel_subagents: 4, max_parallel_ralph_loops: 2, max_parallel_mc_missions: 4 },
codex: { max_parallel_subagents: 10, max_parallel_ralph_loops: 3, max_parallel_mc_missions: 6 },
copilot: { max_parallel_subagents: 10, max_parallel_ralph_loops: 3, max_parallel_mc_missions: 6 },
cursor: { max_parallel_subagents: 10, max_parallel_ralph_loops: 3, max_parallel_mc_missions: 6 },
factory: { max_parallel_subagents: 10, max_parallel_ralph_loops: 3, max_parallel_mc_missions: 6 },
opencode: { max_parallel_subagents: 10, max_parallel_ralph_loops: 3, max_parallel_mc_missions: 6 },
warp: { max_parallel_subagents: 10, max_parallel_ralph_loops: 3, max_parallel_mc_missions: 6 },
windsurf: { max_parallel_subagents: 10, max_parallel_ralph_loops: 3, max_parallel_mc_missions: 6 },
openclaw: { max_parallel_subagents: 10, max_parallel_ralph_loops: 3, max_parallel_mc_missions: 6 },
hermes: { max_parallel_subagents: 10, max_parallel_ralph_loops: 3, max_parallel_mc_missions: 6 },
};
const UNKNOWN_PROVIDER_PARALLELISM = {
max_parallel_subagents: 4,
max_parallel_ralph_loops: 2,
max_parallel_mc_missions: 4,
};
/**
* Return the provider's parallelism defaults, or the conservative fallback
* when the provider is unknown.
*/
export function getProviderParallelismDefaults(provider) {
if (!provider)
return UNKNOWN_PROVIDER_PARALLELISM;
return PROVIDER_PARALLELISM_DEFAULTS[provider] ?? UNKNOWN_PROVIDER_PARALLELISM;
}
/**
* Resolve the parallelism caps with provider-aware defaults applied. The
* primary provider drives the default — typically the first entry in the
* project's `providers` array.
*
* When `parallelism` has explicit values, they override the provider default
* field-by-field. When no provider is supplied (or it's not in the defaults
* map), the conservative 4-subagent fallback applies.
*
* @implements #1359
*/
export function resolveParallelism(parallelism, primaryProvider) {
const defaults = getProviderParallelismDefaults(primaryProvider);
const resolved = {
max_parallel_subagents: parallelism?.max_parallel_subagents ?? defaults.max_parallel_subagents,
max_parallel_ralph_loops: parallelism?.max_parallel_ralph_loops ?? defaults.max_parallel_ralph_loops,
max_parallel_mc_missions: parallelism?.max_parallel_mc_missions ?? defaults.max_parallel_mc_missions,
};
if (parallelism?.rationale !== undefined)
resolved.rationale = parallelism.rationale;
return resolved;
}
/**
* Provider tag for a given remote URL. Used by skills (issue-create,
* pr-review, commit-and-push) to pick the right CLI / MCP client when
* the operator didn't pass `--provider` explicitly.
*
* Recognized hosts:
* - github.com → 'github'
* - gitlab.com / gitlab.* → 'gitlab'
* - any host containing 'gitea' (or matching the typical Gitea path shape) → 'gitea'
*
* Returns 'unknown' for self-hosted instances we can't classify by host alone —
* callers should then prompt the operator or fall back to the configured
* AIWG provider list.
*
* @implements #997
*/
export function resolveRemoteProvider(remoteUrl) {
if (!remoteUrl)
return 'unknown';
const lower = remoteUrl.toLowerCase();
// github.com (or git@github.com:owner/repo.git form)
if (/(^|[/@])github\.com[:/]/.test(lower))
return 'github';
// gitlab.com or self-hosted gitlab
if (/(^|[/@])gitlab\./.test(lower) || lower.includes('/gitlab/'))
return 'gitlab';
// gitea — identified by hostname token. Self-hosted Gitea instances often
// don't include 'gitea' in their hostname (e.g. corporate git servers), so
// 'unknown' is the honest answer there — callers should consult the
// configured AIWG provider list rather than guess.
if (lower.includes('gitea'))
return 'gitea';
return 'unknown';
}
/**
* Resolve the repo remote topology with defaults applied.
*
* Defaults:
* - `primary` defaults to "origin"
* - `issue_tracker` defaults to `primary`
* - `ci` defaults to `primary`
* - `secondary` defaults to `[]`
*
* Pass an absent or partial `remotes` block — every field comes back populated.
*/
export function resolveRemotes(remotes) {
const primary = remotes?.primary ?? 'origin';
return {
primary,
issue_tracker: remotes?.issue_tracker ?? primary,
ci: remotes?.ci ?? primary,
secondary: remotes?.secondary ?? [],
};
}
/**
* Valid provider names (mirrors PROVIDER_PATHS in use.ts)
*/
export const VALID_PROVIDERS = [
'claude', 'factory', 'codex', 'opencode', 'copilot',
'cursor', 'warp', 'windsurf', 'hermes', 'openclaw',
];
/**
* Empty config template.
*
* Includes an explicit `delivery` block defaulting to `pr-required`. The
* runtime default in {@link resolveDelivery} is the same, so this is purely
* for visibility — new projects ship with the policy written down so users
* can see what their agents will do, and switch via `aiwg config set` or
* the AIWG Steward agent without first having to discover the field exists.
*/
export function emptyConfig(providers = ['claude']) {
const primaryProvider = providers[0];
const parDefaults = getProviderParallelismDefaults(primaryProvider);
const knownProvider = primaryProvider && primaryProvider in PROVIDER_PARALLELISM_DEFAULTS;
return {
$schema: 'https://aiwg.io/schemas/aiwg.config.v1.json',
version: '1',
providers,
installed: {},
scripts: {},
delivery: {
mode: 'pr-required',
default_branch: 'main',
require_ci_green: true,
auto_close_issues: true,
issue_comment_on_cycle: true,
force_push_policy: 'never',
},
parallelism: {
max_parallel_subagents: parDefaults.max_parallel_subagents,
max_parallel_ralph_loops: parDefaults.max_parallel_ralph_loops,
max_parallel_mc_missions: parDefaults.max_parallel_mc_missions,
rationale: knownProvider
? `Provider default for ${primaryProvider} — adjust via 'aiwg config set --project parallelism.max_parallel_subagents N'`
: `Conservative default (unknown provider) — adjust via 'aiwg config set --project parallelism.max_parallel_subagents N'`,
},
};
}
/**
* Resolve path to .aiwg/aiwg.config for a project directory
*/
export function getConfigPath(projectDir) {
return resolve(projectDir, AIWG_DIR, CONFIG_FILENAME);
}
/**
* Resolve the project directory for a handler invocation.
*
* Precedence:
* 1. `--target <path>` or `--prefix <path>` flag in args
* 2. The HandlerContext `cwd`, if provided
* 3. `process.cwd()`
*
* All three variants existed scattered across handlers (#919 cleanup).
* Use this helper so we have one authoritative resolution.
*/
export function getProjectDir(ctx, args = []) {
const targetIdx = args.findIndex(a => a === '--target' || a === '--prefix');
const targetValue = targetIdx >= 0 ? args[targetIdx + 1] : undefined;
return targetValue || ctx?.cwd || process.cwd();
}
/**
* Read .aiwg/aiwg.config.
* Returns null if the file does not exist.
*/
export async function readAiwgConfig(projectDir) {
const filePath = getConfigPath(projectDir);
try {
await access(filePath);
}
catch {
return null;
}
const content = await readFile(filePath, 'utf-8');
const parsed = JSON.parse(content);
// Ensure required fields exist (forward-compat)
if (!parsed.providers)
parsed.providers = ['claude'];
if (!parsed.installed)
parsed.installed = {};
if (!parsed.scripts)
parsed.scripts = {};
return parsed;
}
/**
* Write .aiwg/aiwg.config, creating .aiwg/ if needed.
*/
export async function writeAiwgConfig(projectDir, config) {
const dir = resolve(projectDir, AIWG_DIR);
await mkdir(dir, { recursive: true });
const filePath = join(dir, CONFIG_FILENAME);
// Atomic write: emit to a temp sibling, fsync-ish via rename. Prevents a
// crash or kill mid-write from corrupting the config file. Rename is
// atomic on POSIX and on NTFS when both paths are on the same volume.
// The random suffix avoids collisions if two concurrent writers run.
const tmpPath = `${filePath}.${randomBytes(6).toString('hex')}.tmp`;
try {
await writeFile(tmpPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
await rename(tmpPath, filePath);
}
catch (err) {
// Best-effort cleanup of the temp file on failure, ignoring ENOENT.
try {
await unlink(tmpPath);
}
catch {
/* ignore */
}
throw err;
}
}
/**
* Update the `installed` record for a framework after a successful deployment.
* Returns the updated config (does not write to disk — caller must call writeAiwgConfig).
*/
export function updateInstalled(config, name, provider, counts, opts) {
// Project-local invariant: `source: 'project-local'` requires localPath + localType
// (per ADR adr-unified-registry-shape §6 risk mitigation).
if (opts.source === 'project-local') {
if (!opts.localPath || !opts.localType) {
throw new Error(`updateInstalled: source 'project-local' requires localPath and localType (got ${JSON.stringify({ localPath: opts.localPath, localType: opts.localType })})`);
}
}
const existing = config.installed[name] ?? {
version: opts.version,
source: opts.source,
installedAt: new Date().toISOString(),
deployedTo: {},
manifestHash: opts.manifestHash,
};
existing.version = opts.version;
existing.source = opts.source;
existing.installedAt = new Date().toISOString();
existing.deployedTo[provider] = counts;
if (opts.manifestHash)
existing.manifestHash = opts.manifestHash;
if (opts.source === 'project-local') {
existing.localPath = opts.localPath;
existing.localType = opts.localType;
if (opts.manifestVersion)
existing.manifestVersion = opts.manifestVersion;
if (opts.artifactHashes)
existing.artifactHashes = opts.artifactHashes;
}
else {
// Clear stale project-local fields if a previously project-local entry is
// being overwritten by a non-project-local source.
delete existing.localPath;
delete existing.localType;
delete existing.manifestVersion;
delete existing.artifactHashes;
}
config.installed[name] = existing;
return config;
}
/**
* Aggregate deployment counts across all installed frameworks for a given provider.
* Returns the totals for agents, commands, skills, and rules.
* If no provider is specified, uses the first configured provider.
*/
export function getDeploymentSummary(config, provider) {
const targetProvider = provider ?? config.providers[0] ?? 'claude';
const totals = { agents: 0, commands: 0, skills: 0, rules: 0 };
for (const entry of Object.values(config.installed)) {
const counts = entry.deployedTo[targetProvider];
if (!counts)
continue;
totals.agents += counts.agents;
totals.commands += counts.commands;
totals.skills += counts.skills;
totals.rules += counts.rules;
}
return totals;
}
/**
* Compute SHA-256 hash of a manifest.json file.
* Returns undefined if the file cannot be read.
*/
export async function hashManifest(manifestPath) {
try {
const content = await readFile(manifestPath, 'utf-8');
return 'sha256:' + createHash('sha256').update(content).digest('hex');
}
catch {
return undefined;
}
}
/**
* Provider → relative deployment directories (project-relative unless absolute).
* Mirrors PROVIDER_PATHS in use.ts; kept here to avoid circular imports.
*/
const PROVIDER_DEPLOY_DIRS = {
claude: { agents: '.claude/agents', skills: '.claude/.aiwg/skills', commands: '.claude/commands', rules: '.claude/rules' },
copilot: { agents: '.github/agents', skills: '.github/.aiwg/skills', commands: '.github/commands', rules: '.github/copilot-rules' },
cursor: { agents: '.cursor/agents', skills: '.cursor/.aiwg/skills', commands: '.cursor/commands', rules: '.cursor/rules' },
opencode: { agents: '.opencode/agent', skills: '.opencode/.aiwg/skill', commands: '.opencode/command', rules: '.opencode/rule' },
warp: { agents: '.warp/agents', skills: '.warp/.aiwg/skills', commands: '.warp/commands', rules: '.warp/rules' },
windsurf: { agents: '.windsurf/agents', skills: '.windsurf/.aiwg/skills', commands: '.windsurf/workflows', rules: '.windsurf/rules' },
factory: { agents: '.factory/droids', skills: '.factory/.aiwg/skills', commands: '.factory/commands', rules: '.factory/rules' },
codex: { agents: '.codex/agents', skills: '.codex/.aiwg/skills', commands: '.codex/commands', rules: '.codex/rules' },
hermes: { agents: '', skills: join(homedir(), '.hermes', '.aiwg', 'skills'), commands: '', rules: '' },
openclaw: { agents: join(homedir(), '.openclaw', 'agents'), skills: join(homedir(), '.openclaw', '.aiwg', 'skills'), commands: join(homedir(), '.openclaw', 'commands'), rules: join(homedir(), '.openclaw', 'rules') },
};
/**
* Count .md files or subdirectories in a deployment directory.
* Returns 0 if the directory does not exist.
*/
async function countDeployedInDir(projectDir, relOrAbsDir, mode) {
if (!relOrAbsDir)
return 0;
const dir = isAbsolute(relOrAbsDir) ? relOrAbsDir : resolve(projectDir, relOrAbsDir);
try {
const entries = await readdir(dir, { withFileTypes: true });
if (mode === 'md')
return entries.filter(e => e.isFile() && e.name.endsWith('.md')).length;
return entries.filter(e => e.isDirectory()).length;
}
catch {
return 0;
}
}
/**
* Scan actual deployment directories and populate `deployedTo` for any
* `installed` entries that have an empty `deployedTo` map.
*
* Called by `aiwg init` when migrating a project that already has frameworks
* deployed but whose config was created before deployment-tracking was added.
*
* @implements #721
*/
export async function populateDeployedTo(config, projectDir) {
const entriesNeedingPopulation = Object.entries(config.installed).filter(([, entry]) => Object.keys(entry.deployedTo).length === 0);
if (entriesNeedingPopulation.length === 0)
return config;
for (const provider of config.providers) {
const dirs = PROVIDER_DEPLOY_DIRS[provider];
if (!dirs)
continue;
const counts = {
agents: await countDeployedInDir(projectDir, dirs.agents, 'md'),
commands: await countDeployedInDir(projectDir, dirs.commands, 'md'),
skills: await countDeployedInDir(projectDir, dirs.skills, 'dirs'),
rules: await countDeployedInDir(projectDir, dirs.rules, 'md'),
};
// Only populate if at least one artifact type is present
if (counts.agents + counts.commands + counts.skills + counts.rules === 0)
continue;
for (const [name, entry] of entriesNeedingPopulation) {
if (Object.keys(entry.deployedTo).length === 0) {
entry.deployedTo[provider] = counts;
config.installed[name] = entry;
}
}
}
return config;
}
//# sourceMappingURL=aiwg-config.js.map