Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
b97977475This file is pinned to the selected release. Download the original to open it in your design tool.
#!/usr/bin/env node
import { Command } from 'commander';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
import { createInterface } from 'node:readline/promises';
import { loadConfig, resolveModel } from './config.js';
import { runInit, InitError } from './memory/scaffold.js';
import { runCheck } from './commands/check.js';
import { syncVerify, syncResolve, formatSyncReport } from './commands/sync.js';
import { runCreate } from './commands/create.js';
import { runAgentLoop, type BudgetExhaustedStats } from './agent/loop.js';
import { makeRenderer } from './agent/render.js';
import { kicadCliVersion } from './kicad/cli.js';
import { loadEnvFile } from './util/env.js';
// Read .env from the working directory before any command resolves a model or a
// provider. Loaded here rather than per-command so `check` behaves identically,
// though check never reads a key: it stays LLM-free and network-free either way.
// A real environment variable always beats the file.
loadEnvFile(process.cwd());
// Single source of truth for the version. Both src/cli.ts (via tsx) and
// dist/cli.js sit one level below the package root, so the path holds either
// way, and a release can never ship a version string that disagrees with the
// package it was published as.
const { version } = createRequire(import.meta.url)('../package.json') as { version: string };
export const program = new Command();
const repoOf = (opts: { repo?: string }): string => path.resolve(opts.repo ?? process.cwd());
async function confirmTty(question: string): Promise<boolean> {
const rl = createInterface({ input: process.stdin, output: process.stdout });
const answer = await rl.question(`${question} [y/N] `);
rl.close();
return /^y(es)?$/i.test(answer.trim());
}
/**
* Attended runs get a decision point instead of a rollback when the turn
* budget runs out (issue #15). Non-TTY (CI, pipes) keeps fail-and-restore.
*/
function budgetContinuePrompt(): ((stats: BudgetExhaustedStats) => Promise<number>) | undefined {
if (!process.stdin.isTTY || !process.stdout.isTTY) return undefined;
return async (stats) => {
// ceil of the ORIGINAL budget (design D1), so repeat extensions offer the
// same increment instead of escalating with the extended turn count.
const extra = Math.ceil(stats.maxTurns / 2);
const k = (n: number) => `${(n / 1000).toFixed(1)}k`;
const q = `Turn budget exhausted (${stats.turnsUsed} turns, ${k(stats.tokensIn)} in / ${k(stats.tokensOut)} out, ${stats.filesTouched.length} file(s) touched, ${stats.openObligations} open obligation(s)). Continue with ${extra} more turns?`;
return (await confirmTty(q)) ? extra : 0;
};
}
program
.name('copperhead')
.description('Cursor for circuit boards: an AI agent for real KiCad repositories')
.version(version)
.option('--repo <path>', 'target repository (default: cwd)')
.option('--json', 'machine-readable output')
.option('--plain', 'plain log-style output (no interactive status line)');
const rendererOf = () =>
makeRenderer({ json: Boolean(program.opts().json), plain: Boolean(program.opts().plain) });
program
.command('init')
.description('scaffold docs/ from an existing schematic; idempotent')
.option('--path <dir>', 'where to look for KiCad files', '.')
.option('--force', 'overwrite hand-edited generated docs')
.option('--no-hooks', 'skip git pre-commit hook installation')
.action(async (opts: { path: string; force?: boolean; hooks: boolean }) => {
const repo = repoOf(program.opts());
try {
await kicadCliVersion();
const res = await runInit({
repoRoot: repo,
searchPath: opts.path,
force: opts.force ?? false,
installHooks: opts.hooks,
});
if (program.opts().json) {
console.log(JSON.stringify(res, null, 2));
} else {
for (const f of res.created) console.log(`created ${f}`);
for (const f of res.skipped) console.log(`unchanged ${f}`);
for (const f of res.refused) console.log(`REFUSED (hand-edited; use --force): ${f}`);
}
process.exit(res.refused.length ? 1 : 0);
} catch (err) {
console.error(err instanceof InitError ? err.message : (err as Error).message);
process.exit(1);
}
});
const checkAction = async (): Promise<void> => {
const repo = repoOf(program.opts());
const json = Boolean(program.opts().json);
try {
await kicadCliVersion();
const res = await runCheck(repo, json ? () => {} : (s) => console.log(s));
if (json) console.log(JSON.stringify(res, null, 2));
process.exit(res.ok ? 0 : 1);
} catch (err) {
console.error((err as Error).message);
process.exit(1);
}
};
program
.command('check')
.alias('verify')
.description('ERC + DRC + doc-drift + spec validation; no LLM calls; CI-safe')
.action(checkAction);
program
.command('do')
.description('the core loop: propose, edit, verify, propagate, commit')
.argument('<request>', 'the change request in natural language')
.option('--model <model>', 'codex | gpt-5 | claude (or a provider-specific model id)')
.option('--max-turns <n>', 'turn budget for this run')
.option('--allow-dirty', 'allow a dirty tree (snapshot via git stash create)')
.option(
'--keep-on-fail',
'leave unrecoverable-failure edits in place (constraint refusals still roll back)',
)
.option('--dry-run', 'propose the diff, write nothing')
.option('--interactive', 'pause for approval after the proposal validates')
.action(
async (
request: string,
opts: {
model?: string;
maxTurns?: string;
allowDirty?: boolean;
keepOnFail?: boolean;
dryRun?: boolean;
interactive?: boolean;
},
) => {
const repo = repoOf(program.opts());
try {
const kicadVer = await kicadCliVersion();
const config = await loadConfig(repo);
const { model, source } = resolveModel(opts.model, config);
const continuePrompt = budgetContinuePrompt();
const res = await runAgentLoop({
repoRoot: repo,
request,
model,
...(opts.maxTurns ? { maxTurns: parseInt(opts.maxTurns, 10) } : {}),
allowDirty: opts.allowDirty ?? false,
keepOnFail: opts.keepOnFail ?? false,
dryRun: opts.dryRun ?? false,
interactive: opts.interactive ?? false,
confirm: confirmTty,
...(continuePrompt ? { onBudgetExhausted: continuePrompt } : {}),
renderer: rendererOf(),
meta: { command: 'do', modelSource: source, version, kicadCliVersion: kicadVer },
});
if (program.opts().json) console.log(JSON.stringify(res, null, 2));
process.exit(res.outcome === 'failure' ? 1 : 0);
} catch (err) {
console.error((err as Error).message);
process.exit(1);
}
},
);
program
.command('sync')
.description('verify the whole design state for inconsistencies and resolve drift')
.option('--model <model>', 'model for the resolve phase')
.option('--dry-run', 'print the inconsistency report, write nothing')
.action(async (opts: { model?: string; dryRun?: boolean }) => {
const repo = repoOf(program.opts());
try {
const kicadVer = await kicadCliVersion();
const report = await syncVerify(repo);
const json = Boolean(program.opts().json);
if (json) console.log(JSON.stringify(report, null, 2));
else console.log(formatSyncReport(report));
if (opts.dryRun) {
process.exit(report.violations.length ? 2 : 0);
}
if (report.violations.length) {
// requirement violations are never auto-resolved (AC-7.3)
process.exit(2);
}
if (!report.resolvable.length) {
process.exit(0);
}
const config = await loadConfig(repo);
const { model, source } = resolveModel(opts.model, config);
const res = await syncResolve(repo, report, model, json ? () => {} : (s) => console.log(s), {
renderer: rendererOf(),
meta: { command: 'sync', modelSource: source, version, kicadCliVersion: kicadVer },
});
process.exit(res.ok ? 0 : 1);
} catch (err) {
console.error((err as Error).message);
process.exit(1);
}
});
program
.command('create')
.description('Mode A: full pipeline from a product brief to the output package')
.requiredOption('--brief <file>', 'product brief (markdown)')
.option('--model <model>', 'codex | gpt-5 | claude')
.option('--interactive', 're-enable the human gates (spec approval, pre-export)')
.option(
'--keep-on-fail',
'leave unrecoverable failed-stage edits in place; recover before rerunning create',
)
.action(async (opts: { brief: string; model?: string; interactive?: boolean; keepOnFail?: boolean }) => {
const repo = repoOf(program.opts());
try {
const kicadVer = await kicadCliVersion();
const config = await loadConfig(repo);
const { model, source } = resolveModel(opts.model, config);
const continuePrompt = budgetContinuePrompt();
const res = await runCreate({
repoRoot: repo,
briefPath: opts.brief,
model,
interactive: opts.interactive ?? false,
keepOnFail: opts.keepOnFail ?? false,
...(continuePrompt ? { onBudgetExhausted: continuePrompt } : {}),
log: (s) => console.log(s),
renderer: rendererOf(),
meta: { command: 'create', modelSource: source, version, kicadCliVersion: kicadVer },
});
process.exit(res.ok ? 0 : 1);
} catch (err) {
console.error((err as Error).message);
process.exit(1);
}
});
export async function main(argv = process.argv): Promise<void> {
await program.parseAsync(argv);
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch((err: Error) => {
console.error(err.message);
process.exit(1);
});
}