UNPKG

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
/** * 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