Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
54e85dd7fThis 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 { createRequire } from 'node:module';
import { createInterface } from 'node:readline/promises';
import { loadConfig, resolveModel, type ModelSource } from './config.js';
import { pickModel } from './util/select.js';
import { runInit, InitError } from './memory/scaffold.js';
import { runCheck } from './commands/check.js';
import { runDoctor, formatDoctor } from './commands/doctor.js';
import { syncVerify, syncResolve, formatSyncReport } from './commands/sync.js';
import { runCreate } from './commands/create.js';
import { runDemo, demoTourText } from './commands/demo.js';
import { runRepl } from './commands/repl.js';
import {
runExportBom,
parseSupplier,
parseBoards,
parseSpares,
ExportError,
} from './commands/export.js';
import { DEFAULT_BOARDS, DEFAULT_SPARES } from './kicad/bom-export.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';
import { budgetExtraTurns, budgetPromptText, parseMaxTurns, repoOf } from './util/cli-args.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 };
const program = new Command();
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) =>
(await confirmTty(budgetPromptText(stats))) ? budgetExtraTurns(stats) : 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('repl', { isDefault: true })
.description('interactive agent shell (default when no command is given)')
.argument('[request...]', 'optional first change request before the prompt loop')
.option('--model <model>', 'codex | cursor | gpt-5 | claude | claude-code (or a provider-specific model id)')
.option('--max-turns <n>', 'turn budget per request')
.option('--allow-dirty', 'let turns run on a dirty working tree')
.option('--interactive', 'pause for approval after each proposal validates')
.action(
async (
requestParts: string[],
opts: { model?: string; maxTurns?: string; allowDirty?: boolean; interactive?: boolean },
) => {
const repo = repoOf(program.opts());
if (program.opts().json) {
console.error(
'copperhead: --json is not supported with the interactive shell. Use `copperhead do "<request>" --json`.',
);
process.exit(1);
}
try {
const kicadVer = await kicadCliVersion();
const config = await loadConfig(repo);
const renderer = rendererOf();
let model: string;
let source: ModelSource;
try {
({ model, source } = resolveModel(opts.model, config));
} catch (err) {
// No model anywhere (flag, COPPERHEAD_MODEL, config, .env keys):
// on a TTY, offer an interactive pick instead of refusing to start.
if (!process.stdin.isTTY || !process.stdout.isTTY) throw err;
console.log('No model configured for this session, pick one:');
const chosen = await pickModel();
if (!chosen) throw err;
model = chosen;
source = 'picker';
}
const continuePrompt = budgetContinuePrompt();
const seed = requestParts.length ? requestParts.join(' ') : undefined;
const res = await runRepl({
repoRoot: repo,
model,
modelSource: source,
version,
kicadCliVersion: kicadVer,
...(opts.maxTurns ? { maxTurns: parseMaxTurns(opts.maxTurns) } : {}),
allowDirty: opts.allowDirty ?? false,
interactive: opts.interactive ?? false,
...(seed ? { seed } : {}),
confirm: confirmTty,
...(continuePrompt ? { onBudgetExhausted: continuePrompt } : {}),
renderer,
});
process.exit(res.ok ? 0 : 1);
} catch (err) {
console.error((err as Error).message);
process.exit(1);
}
},
);
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);
// `draft` and `score` are command groups taking the artifact as a noun
// (`draft schematic` today, `draft pcb` when layout drafting exists), so the
// verb alone never has to guess what it applies to.
const draftGroup = program
.command('draft')
.description('deterministically draft an artifact from its declared intent; no LLM, no network');
draftGroup
.command('schematic')
.description('draft the schematic from schematic.intent.json')
.option('--intent <path>', 'repo-relative intent file (default: schematic.intent.json beside the schematic)')
.action(async (opts: { intent?: string }) => {
const repo = repoOf(program.opts());
const json = Boolean(program.opts().json);
try {
const { loadConfig } = await import('./config.js');
const { draftSchematic, defaultIntentPath, formatSchematicDraftReport } = await import('./kicad/draft/draft.js');
const config = await loadConfig(repo);
if (!config.schematic) {
console.error('no schematic configured in .copperhead/config.json');
process.exit(1);
}
const res = await draftSchematic({
repoRoot: repo,
schematic: config.schematic,
intentPath: opts.intent ?? defaultIntentPath(config.schematic),
docsDir: config.docs,
});
if (!res.ok) {
if (json) console.log(JSON.stringify({ ok: false, findings: res.findings }, null, 2));
else console.error(res.message);
process.exit(1);
}
if (json) console.log(JSON.stringify({ ok: true, report: res.report }, null, 2));
else console.log(formatSchematicDraftReport(res.report));
process.exit(0);
} catch (err) {
console.error((err as Error).message);
process.exit(1);
}
});
const scoreGroup = program
.command('score')
.description('quantitative quality score for an artifact; advisory exit code; no LLM, no network');
scoreGroup
.command('schematic')
.description('legibility and layout score for the schematic')
.action(async () => {
const repo = repoOf(program.opts());
const json = Boolean(program.opts().json);
try {
const { loadConfig } = await import('./config.js');
const { scoreSchematic, formatScore } = await import('./kicad/score.js');
const path = await import('node:path');
const config = await loadConfig(repo);
if (!config.schematic) {
console.error('no schematic configured in .copperhead/config.json');
process.exit(1);
}
const report = await scoreSchematic(path.join(repo, config.schematic), {
docsDir: path.join(repo, config.docs),
...(config.legibility ? { config: config.legibility } : {}),
});
console.log(json ? JSON.stringify(report, null, 2) : formatScore(report));
process.exit(0); // the exit code never depends on the composite (AC-16.26 family)
} catch (err) {
console.error((err as Error).message);
process.exit(1);
}
});
program
.command('doctor')
.description('env preflight: kicad-cli, git, node, and the model provider credential; no LLM, no network')
.option('--model <model>', 'model to check the provider credential for (default: resolved like a run)')
.action(async (opts: { model?: string }) => {
const repo = repoOf(program.opts());
// Unlike other commands, doctor never gates on kicad-cli being present:
// runDoctor probes it and reports a failure instead of throwing, so a user
// with a missing tool still gets the full report.
const report = await runDoctor({ repoRoot: repo, model: opts.model });
if (program.opts().json) console.log(JSON.stringify(report, null, 2));
else {
const color = process.stdout.isTTY === true && !process.env.NO_COLOR;
// || not ??: some non-interactive ptys report columns as 0.
for (const line of formatDoctor(report, process.stdout.columns || 80, color)) console.log(line);
}
process.exit(report.ok ? 0 : 1);
});
program
.command('do')
.description('the core loop: propose, edit, verify, propagate, commit')
.argument('<request>', 'the change request in natural language')
.option('--model <model>', 'codex | cursor | gpt-5 | claude | claude-code | compat:<id> (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('--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; 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: parseMaxTurns(opts.maxTurns) } : {}),
allowDirty: opts.allowDirty ?? 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('demo')
.description('tour of what copperhead does, or run the USB-C breakout create pipeline')
.option('--model <model>', 'codex | cursor | gpt-5 | claude | claude-code (or a provider-specific model id)')
.option('--interactive', 're-enable the human gates (spec approval, pre-export)')
.option('--dir <path>', 'demo repo directory (default: demo-runs/usb-c-breakout)')
.option('--tour', 'print the overview only; do not run the pipeline')
.action(async (opts: { model?: string; interactive?: boolean; dir?: string; tour?: boolean }) => {
if (opts.tour) {
const { setColorEnabled } = await import('./agent/theme.js');
if (program.opts().json) {
// --json is a contract, not a suggestion: a script that passes it
// unconditionally must never get prose back. Plain lines, no SGR.
setColorEnabled(false);
console.log(JSON.stringify({ tour: demoTourText().split('\n') }, null, 2));
process.exit(0);
}
// Color on for attended TTY tours even without a renderer.
setColorEnabled(Boolean(process.stdout.isTTY) && !program.opts().plain && !process.env.NO_COLOR);
console.log(demoTourText());
process.exit(0);
}
try {
const kicadVer = await kicadCliVersion();
// Resolve model from the caller's cwd config / env / flag; the demo repo
// is scaffolded next and typically has no model of its own yet.
const config = await loadConfig(repoOf(program.opts()));
const { model, source } = resolveModel(opts.model, config);
const continuePrompt = budgetContinuePrompt();
const res = await runDemo({
model,
modelSource: source,
version,
kicadCliVersion: kicadVer,
interactive: opts.interactive ?? false,
...(opts.dir ? { demoDir: opts.dir } : {}),
...(continuePrompt ? { onBudgetExhausted: continuePrompt } : {}),
log: (s) => console.log(s),
renderer: rendererOf(),
});
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 | cursor | gpt-5 | claude | claude-code | compat:<id> (or a provider-specific model id)')
.option('--interactive', 're-enable the human gates (spec approval, pre-export)')
.action(async (opts: { brief: string; model?: string; 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 runCreate({
repoRoot: repo,
briefPath: opts.brief,
model,
interactive: opts.interactive ?? 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);
}
});
const exportCmd = program
.command('export')
.description('emit supplier-ready files from repo state (deterministic; no LLM, no network)');
exportCmd
.command('bom')
.description('write a supplier-format BOM (jlcpcb | digikey | mouser) from docs/BOM.md')
.requiredOption('--supplier <name>', 'jlcpcb | digikey | mouser')
.option('--boards <n>', 'number of boards to order', String(DEFAULT_BOARDS))
.option('--spares <percent>', 'spare parts percentage', String(DEFAULT_SPARES))
.option('--include-unverified', 'include UNVERIFIED rows that carry an MPN (never MPN-less rows)')
.action(async (opts: { supplier: string; boards: string; spares: string; includeUnverified?: boolean }) => {
const repo = repoOf(program.opts());
const json = Boolean(program.opts().json);
try {
const supplier = parseSupplier(opts.supplier);
const boards = parseBoards(opts.boards);
const spares = parseSpares(opts.spares);
const res = await runExportBom({
repoRoot: repo,
supplier,
boards,
spares,
includeUnverified: opts.includeUnverified ?? false,
});
// Warnings go to stderr so a `> file` redirect of stdout stays clean and
// the excluded-rows report is still seen.
for (const w of res.warnings) console.error(w);
if (json) {
console.log(JSON.stringify(res, null, 2));
} else {
console.log(`wrote ${res.outPath} (${res.included.length} part(s), ${res.excluded.length} excluded)`);
}
process.exit(0);
} catch (err) {
// ExportError carries an actionable message (bad flag, missing BOM, drift);
// anything else is unexpected. Both exit non-zero with no stack trace.
console.error(err instanceof ExportError ? err.message : (err as Error).message);
process.exit(1);
}
});
program.parseAsync().catch((err: Error) => {
console.error(err.message);
process.exit(1);
});