copperhead 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/README.md +36 -4
  2. package/dist/agent/animate.js +76 -0
  3. package/dist/agent/animate.js.map +1 -0
  4. package/dist/agent/box.js +89 -0
  5. package/dist/agent/box.js.map +1 -0
  6. package/dist/agent/dock-renderer.js +173 -0
  7. package/dist/agent/dock-renderer.js.map +1 -0
  8. package/dist/agent/logo.js +21 -0
  9. package/dist/agent/logo.js.map +1 -0
  10. package/dist/agent/loop.js +130 -18
  11. package/dist/agent/loop.js.map +1 -1
  12. package/dist/agent/prompts.js +2 -1
  13. package/dist/agent/prompts.js.map +1 -1
  14. package/dist/agent/providers/claude-code.js +85 -116
  15. package/dist/agent/providers/claude-code.js.map +1 -1
  16. package/dist/agent/providers/cursor.js +317 -0
  17. package/dist/agent/providers/cursor.js.map +1 -0
  18. package/dist/agent/providers/tool-protocol.js +205 -0
  19. package/dist/agent/providers/tool-protocol.js.map +1 -0
  20. package/dist/agent/recovery.js +148 -0
  21. package/dist/agent/recovery.js.map +1 -0
  22. package/dist/agent/render.js +44 -12
  23. package/dist/agent/render.js.map +1 -1
  24. package/dist/agent/response-cache.js +81 -0
  25. package/dist/agent/response-cache.js.map +1 -0
  26. package/dist/agent/runmeta.js +4 -5
  27. package/dist/agent/runmeta.js.map +1 -1
  28. package/dist/agent/theme.js +84 -0
  29. package/dist/agent/theme.js.map +1 -0
  30. package/dist/agent/tools.js +61 -4
  31. package/dist/agent/tools.js.map +1 -1
  32. package/dist/agent/transcript.js.map +1 -1
  33. package/dist/cli.js +134 -13
  34. package/dist/cli.js.map +1 -1
  35. package/dist/commands/create.js +482 -38
  36. package/dist/commands/create.js.map +1 -1
  37. package/dist/commands/demo.js +146 -0
  38. package/dist/commands/demo.js.map +1 -0
  39. package/dist/commands/doctor.js +240 -0
  40. package/dist/commands/doctor.js.map +1 -0
  41. package/dist/commands/repl-inspect.js +342 -0
  42. package/dist/commands/repl-inspect.js.map +1 -0
  43. package/dist/commands/repl.js +618 -0
  44. package/dist/commands/repl.js.map +1 -0
  45. package/dist/config.js +24 -2
  46. package/dist/config.js.map +1 -1
  47. package/dist/kicad/bootstrap.js +166 -0
  48. package/dist/kicad/bootstrap.js.map +1 -0
  49. package/dist/kicad/cli.js +126 -6
  50. package/dist/kicad/cli.js.map +1 -1
  51. package/dist/kicad/spice.js +306 -0
  52. package/dist/kicad/spice.js.map +1 -0
  53. package/dist/kicad/symlib.js +228 -0
  54. package/dist/kicad/symlib.js.map +1 -0
  55. package/dist/memory/bom-table.js +193 -22
  56. package/dist/memory/bom-table.js.map +1 -1
  57. package/dist/memory/drift.js +33 -11
  58. package/dist/memory/drift.js.map +1 -1
  59. package/dist/util/cli-args.js +35 -0
  60. package/dist/util/cli-args.js.map +1 -0
  61. package/dist/util/dock.js +155 -0
  62. package/dist/util/dock.js.map +1 -0
  63. package/dist/util/git.js +165 -4
  64. package/dist/util/git.js.map +1 -1
  65. package/dist/util/live-prompt.js +542 -0
  66. package/dist/util/live-prompt.js.map +1 -0
  67. package/dist/util/paths.js +9 -0
  68. package/dist/util/paths.js.map +1 -1
  69. package/dist/util/preflight.js +37 -0
  70. package/dist/util/preflight.js.map +1 -1
  71. package/dist/util/retry.js +23 -0
  72. package/dist/util/retry.js.map +1 -1
  73. package/dist/util/select.js +172 -0
  74. package/dist/util/select.js.map +1 -0
  75. package/dist/util/tmp.js +119 -0
  76. package/dist/util/tmp.js.map +1 -0
  77. package/package.json +3 -2
  78. package/src/agent/animate.ts +90 -0
  79. package/src/agent/box.ts +99 -0
  80. package/src/agent/dock-renderer.ts +181 -0
  81. package/src/agent/logo.ts +23 -0
  82. package/src/agent/loop.ts +148 -18
  83. package/src/agent/prompts.ts +2 -1
  84. package/src/agent/providers/claude-code.ts +91 -122
  85. package/src/agent/providers/cursor.ts +364 -0
  86. package/src/agent/providers/tool-protocol.ts +212 -0
  87. package/src/agent/recovery.ts +162 -0
  88. package/src/agent/render.ts +56 -12
  89. package/src/agent/response-cache.ts +80 -0
  90. package/src/agent/runmeta.ts +6 -7
  91. package/src/agent/theme.ts +91 -0
  92. package/src/agent/tools.ts +62 -4
  93. package/src/agent/transcript.ts +1 -0
  94. package/src/agent/types.ts +17 -0
  95. package/src/cli.ts +139 -15
  96. package/src/commands/create.ts +581 -40
  97. package/src/commands/demo.ts +184 -0
  98. package/src/commands/doctor.ts +289 -0
  99. package/src/commands/repl-inspect.ts +353 -0
  100. package/src/commands/repl.ts +685 -0
  101. package/src/config.ts +40 -3
  102. package/src/kicad/bootstrap.ts +181 -0
  103. package/src/kicad/cli.ts +132 -7
  104. package/src/kicad/spice.ts +399 -0
  105. package/src/kicad/symlib.ts +248 -0
  106. package/src/layout/claude-ui-layout.md +72 -0
  107. package/src/layout/repl-ui-layout.md +139 -0
  108. package/src/memory/bom-table.ts +191 -20
  109. package/src/memory/drift.ts +42 -11
  110. package/src/util/cli-args.ts +42 -0
  111. package/src/util/dock.ts +161 -0
  112. package/src/util/git.ts +176 -4
  113. package/src/util/live-prompt.ts +595 -0
  114. package/src/util/paths.ts +10 -0
  115. package/src/util/preflight.ts +44 -0
  116. package/src/util/retry.ts +29 -0
  117. package/src/util/select.ts +192 -0
  118. package/src/util/tmp.ts +113 -0
@@ -0,0 +1,184 @@
1
+ /**
2
+ * End-to-end demo: scaffold a tiny git repo and run `create` against the
3
+ * USB-C power breakout brief (same path as `npm run demo:simple`).
4
+ */
5
+
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { existsSync } from 'node:fs';
9
+ import { mkdir, writeFile, readFile, readdir, appendFile } from 'node:fs/promises';
10
+ import { execa } from 'execa';
11
+ import type { ProgressRenderer } from '../agent/render.js';
12
+ import type { RunMetaInput } from '../agent/runmeta.js';
13
+ import type { BudgetExhaustedStats } from '../agent/loop.js';
14
+ import { copper, dim, ok } from '../agent/theme.js';
15
+ import { traceRule } from '../agent/animate.js';
16
+ import { shortPath } from '../util/paths.js';
17
+ import { runCreate } from './create.js';
18
+
19
+ const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
20
+ const DEFAULT_BRIEF = path.join(PKG_ROOT, 'examples/simple/usb-c-breakout.md');
21
+ const DEFAULT_DEMO_DIR = path.join(PKG_ROOT, 'demo-runs/usb-c-breakout');
22
+
23
+ export interface DemoOptions {
24
+ model: string;
25
+ modelSource: RunMetaInput['modelSource'];
26
+ version: string;
27
+ kicadCliVersion: string;
28
+ interactive?: boolean;
29
+ /** Override demo repo (defaults to demo-runs/usb-c-breakout or COPPERHEAD_DEMO_DIR). */
30
+ demoDir?: string;
31
+ briefPath?: string;
32
+ log?: (line: string) => void;
33
+ renderer: ProgressRenderer;
34
+ onBudgetExhausted?: (stats: BudgetExhaustedStats) => Promise<number>;
35
+ }
36
+
37
+ /** Resolve the packaged USB-C brief; throws a clear error if the install is incomplete. */
38
+ export function defaultBriefPath(): string {
39
+ if (!existsSync(DEFAULT_BRIEF)) {
40
+ throw new Error(
41
+ `demo brief missing at ${DEFAULT_BRIEF}; reinstall copperhead or run from a full checkout`,
42
+ );
43
+ }
44
+ return DEFAULT_BRIEF;
45
+ }
46
+
47
+ export function defaultDemoDir(): string {
48
+ return process.env.COPPERHEAD_DEMO_DIR ?? DEFAULT_DEMO_DIR;
49
+ }
50
+
51
+ /** Marker identifying a directory scaffolded by `copperhead demo`. */
52
+ const DEMO_MARKER = path.join('.copperhead', 'demo-repo');
53
+
54
+ /**
55
+ * Prepare a clean-enough git repo for the create pipeline (git init, ignore
56
+ * runs/, baseline config + commit). Mirrors scripts/demo-simple.sh.
57
+ */
58
+ export async function scaffoldDemoRepo(demoDir: string): Promise<void> {
59
+ await mkdir(demoDir, { recursive: true });
60
+
61
+ // Fail closed before touching git: only scaffold into an empty directory
62
+ // or one this function created earlier (identified by the marker file).
63
+ // Anything else risks git-initializing and committing into a directory
64
+ // the user cares about.
65
+ const entries = await readdir(demoDir);
66
+ if (entries.length > 0 && !existsSync(path.join(demoDir, DEMO_MARKER))) {
67
+ throw new Error(
68
+ `refusing to scaffold the demo repo in ${demoDir}: the directory is not empty and was not created by copperhead demo; use an empty directory (or point COPPERHEAD_DEMO_DIR elsewhere)`,
69
+ );
70
+ }
71
+
72
+ const git = async (...args: string[]) => execa('git', args, { cwd: demoDir });
73
+
74
+ if (!existsSync(path.join(demoDir, '.git'))) {
75
+ await git('init', '-q');
76
+ }
77
+
78
+ const hasName = await execa('git', ['config', 'user.name'], { cwd: demoDir, reject: false });
79
+ if (hasName.exitCode !== 0) await git('config', 'user.name', 'copperhead demo');
80
+ const hasEmail = await execa('git', ['config', 'user.email'], { cwd: demoDir, reject: false });
81
+ if (hasEmail.exitCode !== 0) await git('config', 'user.email', 'demo@copperhead.local');
82
+
83
+ const gi = path.join(demoDir, '.gitignore');
84
+ if (!existsSync(gi)) await writeFile(gi, '', 'utf8');
85
+ const giText = await readFile(gi, 'utf8');
86
+ if (!giText.split('\n').includes('.copperhead/runs/')) {
87
+ await appendFile(gi, (giText.endsWith('\n') || giText === '' ? '' : '\n') + '.copperhead/runs/\n');
88
+ }
89
+
90
+ const cfgDir = path.join(demoDir, '.copperhead');
91
+ await mkdir(cfgDir, { recursive: true });
92
+ const marker = path.join(demoDir, DEMO_MARKER);
93
+ if (!existsSync(marker)) await writeFile(marker, 'created by copperhead demo\n', 'utf8');
94
+ const cfg = path.join(cfgDir, 'config.json');
95
+ if (!existsSync(cfg)) {
96
+ // Create stages need a larger turn budget than a single `do` edit.
97
+ await writeFile(
98
+ cfg,
99
+ `${JSON.stringify({ docs: 'docs/', maxTurns: 100, maxRepairCycles: 5 }, null, 2)}\n`,
100
+ 'utf8',
101
+ );
102
+ }
103
+
104
+ await git('add', '.gitignore', '.copperhead/config.json');
105
+ const head = await execa('git', ['rev-parse', '--verify', 'HEAD'], { cwd: demoDir, reject: false });
106
+ if (head.exitCode !== 0) {
107
+ await git('commit', '-q', '-m', 'demo: initialize repository');
108
+ } else {
109
+ const staged = await execa('git', ['diff', '--cached', '--quiet'], { cwd: demoDir, reject: false });
110
+ if (staged.exitCode !== 0) {
111
+ await git('commit', '-q', '-m', 'demo: update demo scaffolding');
112
+ }
113
+ }
114
+ }
115
+
116
+ /** What copperhead is — printed by `/demo` and `copperhead demo --tour`. */
117
+ export function demoTourText(): string {
118
+ return [
119
+ '',
120
+ copper(' What copperhead does'),
121
+ traceRule(28),
122
+ '',
123
+ dim(' Cursor for circuit boards. You describe a change; the agent edits'),
124
+ dim(' real KiCad files, keeps design docs in sync, and verifies with'),
125
+ dim(' kicad-cli ERC/DRC before anything is committed.'),
126
+ '',
127
+ copper(' The loop'),
128
+ traceRule(16),
129
+ '',
130
+ ` ${copper('1.')} ${dim('Propose')} write an OpenSpec change (edit tools stay locked until it validates)`,
131
+ ` ${copper('2.')} ${dim('Edit')} anchored edits to .kicad_sch / .kicad_pcb + docs`,
132
+ ` ${copper('3.')} ${dim('Verify')} run ERC (and DRC if the board changed); repair or roll back`,
133
+ ` ${copper('4.')} ${dim('Remember')} DECISIONS.md + CHANGELOG + a run summary next to the transcript`,
134
+ '',
135
+ copper(' Try it'),
136
+ traceRule(14),
137
+ '',
138
+ ` ${copper('copperhead demo')} ${dim('full create pipeline (USB-C breakout)')}`,
139
+ ` ${copper('copperhead')} ${dim('interactive shell — type a change request')}`,
140
+ ` ${copper('copperhead do "add an LED"')} ${dim('one-shot edit on the current repo')}`,
141
+ '',
142
+ copper(' Example prompts'),
143
+ traceRule(22),
144
+ '',
145
+ dim(' • add reverse-polarity protection on VIN'),
146
+ dim(' • rename net KEY_DAH to KEY_DASH'),
147
+ dim(' • move the key input to a different RTC-capable pin'),
148
+ '',
149
+ ].join('\n');
150
+ }
151
+
152
+ export async function runDemo(opts: DemoOptions): Promise<{ ok: boolean; demoDir: string }> {
153
+ const log = opts.log ?? ((l: string) => console.log(l));
154
+ const demoDir = path.resolve(opts.demoDir ?? defaultDemoDir());
155
+ const briefPath = path.resolve(opts.briefPath ?? defaultBriefPath());
156
+
157
+ log('');
158
+ log(` ${copper('copperhead demo')} ${dim('USB-C power breakout')}`);
159
+ log(` ${dim('repo')} ${shortPath(demoDir)}`);
160
+ log(` ${dim('brief')} ${shortPath(briefPath)}`);
161
+ log('');
162
+
163
+ await scaffoldDemoRepo(demoDir);
164
+ log(ok(' scaffold ready'));
165
+ log('');
166
+
167
+ const res = await runCreate({
168
+ repoRoot: demoDir,
169
+ briefPath,
170
+ model: opts.model,
171
+ interactive: opts.interactive ?? false,
172
+ ...(opts.onBudgetExhausted ? { onBudgetExhausted: opts.onBudgetExhausted } : {}),
173
+ log,
174
+ renderer: opts.renderer,
175
+ meta: {
176
+ command: 'create',
177
+ modelSource: opts.modelSource,
178
+ version: opts.version,
179
+ kicadCliVersion: opts.kicadCliVersion,
180
+ },
181
+ });
182
+
183
+ return { ok: res.ok, demoDir };
184
+ }
@@ -0,0 +1,289 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { existsSync } from 'node:fs';
4
+ import path from 'node:path';
5
+ import { DEFAULTS, loadConfig, resolveModel, type CopperheadConfig } from '../config.js';
6
+ import { kicadCliVersion } from '../kicad/cli.js';
7
+ import { redactSecrets } from '../util/redact.js';
8
+
9
+ const execFileP = promisify(execFile);
10
+
11
+ /**
12
+ * `copperhead doctor` (env preflight): a fast, LLM-free, network-free check of
13
+ * whether this machine can actually run a copperhead command — the gap `check`
14
+ * leaves (it verifies kicad-cli but is contractually LLM-free, so it never looks
15
+ * at the model provider). Each probe fails soft: a missing tool is a reported
16
+ * `fail`, never a thrown error, so `doctor` still prints the rest of the report.
17
+ */
18
+ export type DoctorStatus = 'ok' | 'fail' | 'info';
19
+
20
+ export interface DoctorCheck {
21
+ name: string;
22
+ status: DoctorStatus;
23
+ detail: string;
24
+ hint?: string;
25
+ }
26
+
27
+ export interface DoctorReport {
28
+ /** true when no *critical* check failed (info-only checks never block). */
29
+ ok: boolean;
30
+ checks: DoctorCheck[];
31
+ }
32
+
33
+ /** Probes are injectable so tests never depend on the host's tools. */
34
+ export interface DoctorDeps {
35
+ nodeVersion: string;
36
+ kicadVersion: () => Promise<string>;
37
+ gitVersion: () => Promise<string>;
38
+ env: NodeJS.ProcessEnv;
39
+ }
40
+
41
+ function defaultDeps(): DoctorDeps {
42
+ return {
43
+ nodeVersion: process.version,
44
+ kicadVersion: kicadCliVersion,
45
+ // `git --version` prints "git version 2.34.1"; keep only the number, the
46
+ // report already labels the row "git".
47
+ gitVersion: async () => (await execFileP('git', ['--version'])).stdout.trim().replace(/^git version\s+/, ''),
48
+ env: process.env,
49
+ };
50
+ }
51
+
52
+ const MIN_NODE_MAJOR = 20; // package.json engines: ">=20"
53
+
54
+ function nodeCheck(version: string): DoctorCheck {
55
+ const major = Number(version.replace(/^v/, '').split('.')[0]);
56
+ if (Number.isFinite(major) && major >= MIN_NODE_MAJOR) {
57
+ return { name: 'node', status: 'ok', detail: `${version} (>= ${MIN_NODE_MAJOR})` };
58
+ }
59
+ return {
60
+ name: 'node',
61
+ status: 'fail',
62
+ detail: `${version} (< ${MIN_NODE_MAJOR})`,
63
+ hint: `copperhead needs Node >= ${MIN_NODE_MAJOR}; upgrade Node.`,
64
+ };
65
+ }
66
+
67
+ async function kicadCheck(probe: () => Promise<string>): Promise<DoctorCheck> {
68
+ try {
69
+ return { name: 'kicad-cli', status: 'ok', detail: await probe() };
70
+ } catch {
71
+ return {
72
+ name: 'kicad-cli',
73
+ status: 'fail',
74
+ detail: 'not found on PATH',
75
+ hint: 'install KiCad >= 9 (bundles kicad-cli); ERC/DRC gates need it.',
76
+ };
77
+ }
78
+ }
79
+
80
+ async function gitCheck(probe: () => Promise<string>): Promise<DoctorCheck> {
81
+ try {
82
+ return { name: 'git', status: 'ok', detail: await probe() };
83
+ } catch {
84
+ return {
85
+ name: 'git',
86
+ status: 'fail',
87
+ detail: 'not found on PATH',
88
+ hint: 'install git; copperhead snapshots and commits its work.',
89
+ };
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Map a resolved model to the credential its provider needs, mirroring
95
+ * makeProvider's prefix routing (agent/loop.ts). Presence-only: it checks that a
96
+ * required API key is set, never that it authenticates (that would need network).
97
+ * Saved-login providers (codex, claude-code) need no key and can't be verified
98
+ * offline, so they report `info` (which does not block `ok`).
99
+ */
100
+ export function checkCredential(model: string, env: NodeJS.ProcessEnv): DoctorCheck {
101
+ // A pasted API key can end up as the model value (--model sk-..., a stray
102
+ // COPPERHEAD_MODEL); redact it before it reaches the report, same policy as
103
+ // transcripts (AC-4.1). Routing below still uses the raw value.
104
+ const shown = redactSecrets(model);
105
+ const savedLogin: Record<string, string> = {
106
+ codex: 'uses local Codex login',
107
+ 'claude-code': 'uses Claude Code login',
108
+ cursor: 'uses Cursor Agent CLI login',
109
+ };
110
+ for (const [prefix, how] of Object.entries(savedLogin)) {
111
+ if (model === prefix || model.startsWith(`${prefix}:`)) {
112
+ // makeProvider rejects an empty override; a real run would fail here.
113
+ if (model === `${prefix}:`) {
114
+ return {
115
+ name: 'provider',
116
+ status: 'fail',
117
+ detail: `${shown} -> ${prefix}: empty model override`,
118
+ hint: `use "${prefix}" or "${prefix}:<model-id>".`,
119
+ };
120
+ }
121
+ return {
122
+ name: 'provider',
123
+ status: 'info',
124
+ detail: `${shown} -> ${prefix}: ${how} (not verified offline)`,
125
+ };
126
+ }
127
+ }
128
+ if (model === 'claude' || model.startsWith('claude')) {
129
+ return env.ANTHROPIC_API_KEY
130
+ ? { name: 'provider', status: 'ok', detail: `${shown} -> anthropic: ANTHROPIC_API_KEY set` }
131
+ : {
132
+ name: 'provider',
133
+ status: 'fail',
134
+ detail: `${shown} -> anthropic: ANTHROPIC_API_KEY not set`,
135
+ hint: 'export ANTHROPIC_API_KEY=... (or use --model claude-code for saved login).',
136
+ };
137
+ }
138
+ return env.OPENAI_API_KEY
139
+ ? { name: 'provider', status: 'ok', detail: `${shown} -> openai: OPENAI_API_KEY set` }
140
+ : {
141
+ name: 'provider',
142
+ status: 'fail',
143
+ detail: `${shown} -> openai: OPENAI_API_KEY not set`,
144
+ hint: 'export OPENAI_API_KEY=... (or use --model codex for saved login).',
145
+ };
146
+ }
147
+
148
+ function providerCheck(
149
+ flag: string | undefined,
150
+ config: Awaited<ReturnType<typeof loadConfig>>,
151
+ env: NodeJS.ProcessEnv,
152
+ ): DoctorCheck {
153
+ try {
154
+ const { model } = resolveModel(flag, config, env);
155
+ return checkCredential(model, env);
156
+ } catch (err) {
157
+ // resolveModel throws only when nothing selects a model at all. Its message
158
+ // starts with "no model configured: " — already this check's detail line —
159
+ // so keep only the remedy part for the hint.
160
+ return {
161
+ name: 'provider',
162
+ status: 'fail',
163
+ detail: 'no model configured',
164
+ hint: (err as Error).message.replace(/^no model configured:\s*/, ''),
165
+ };
166
+ }
167
+ }
168
+
169
+ function projectCheck(config: Awaited<ReturnType<typeof loadConfig>>, repoRoot: string): DoctorCheck {
170
+ const hasConfig = existsSync(path.join(repoRoot, '.copperhead', 'config.json'));
171
+ if (!hasConfig) {
172
+ return {
173
+ name: 'project',
174
+ status: 'info',
175
+ detail: 'no .copperhead/config.json (run `copperhead init` to scaffold)',
176
+ };
177
+ }
178
+ return {
179
+ name: 'project',
180
+ status: 'info',
181
+ detail: `schematic ${config.schematic ?? 'not wired'} · board ${config.board ?? 'not wired'}`,
182
+ };
183
+ }
184
+
185
+ export interface RunDoctorOptions {
186
+ repoRoot: string;
187
+ model?: string | undefined;
188
+ deps?: Partial<DoctorDeps>;
189
+ }
190
+
191
+ // Same shape loadConfig returns when no config file exists at all: a safe
192
+ // fallback so a corrupted config.json degrades the project check, not the
193
+ // whole command (resolveModel's config.model precedence level is simply
194
+ // unavailable; --model/COPPERHEAD_MODEL/an available key still resolve).
195
+ const FALLBACK_CONFIG: CopperheadConfig = { schematic: null, board: null, ...DEFAULTS };
196
+
197
+ export async function runDoctor(opts: RunDoctorOptions): Promise<DoctorReport> {
198
+ const deps = { ...defaultDeps(), ...opts.deps };
199
+ let config: CopperheadConfig;
200
+ let configError: DoctorCheck | undefined;
201
+ try {
202
+ config = await loadConfig(opts.repoRoot);
203
+ } catch (err) {
204
+ config = FALLBACK_CONFIG;
205
+ // JSON.parse throws a bare SyntaxError for bad content; readFile throws a
206
+ // coded Error (EACCES, EISDIR, ...) for a file that couldn't be read at
207
+ // all. The two need different advice: content is fixed by regenerating
208
+ // the file, unreadable is a permissions/filesystem problem regenerating
209
+ // it will not solve.
210
+ configError =
211
+ err instanceof SyntaxError
212
+ ? {
213
+ name: 'project',
214
+ status: 'fail',
215
+ detail: `.copperhead/config.json is malformed: ${err.message}`,
216
+ hint: 'fix or delete .copperhead/config.json (rerun `copperhead init`/`copperhead create` to regenerate it).',
217
+ }
218
+ : {
219
+ name: 'project',
220
+ status: 'fail',
221
+ detail: `.copperhead/config.json could not be read: ${(err as Error).message}`,
222
+ hint: 'check that it is a regular file (not a directory) and that you have permission to read it.',
223
+ };
224
+ }
225
+ const checks: DoctorCheck[] = [
226
+ nodeCheck(deps.nodeVersion),
227
+ await kicadCheck(deps.kicadVersion),
228
+ await gitCheck(deps.gitVersion),
229
+ providerCheck(opts.model, config, deps.env),
230
+ configError ?? projectCheck(config, opts.repoRoot),
231
+ ];
232
+ return { ok: checks.every((c) => c.status !== 'fail'), checks };
233
+ }
234
+
235
+ const TAG: Record<DoctorStatus, string> = { ok: '[ok]', fail: '[FAIL]', info: '[info]' };
236
+ const TAG_COL = 2; // leading indent
237
+ const NAME_COL = TAG_COL + 7; // widest tag "[FAIL]" + one space
238
+ const DETAIL_COL = NAME_COL + 10; // widest name "kicad-cli" + one space
239
+
240
+ // Plain ANSI, no color dependency: green/red/cyan tags, dim hints. Color is
241
+ // off by default; the CLI opts in only for a real TTY, so piped output and
242
+ // tests see plain text. Colored text is padded before painting — escape codes
243
+ // have zero display width but nonzero string length, so painting first would
244
+ // break the column math.
245
+ const ANSI: Record<DoctorStatus, string> = { ok: '32', fail: '31', info: '36' };
246
+ const DIM = '2';
247
+ function paint(text: string, code: string, on: boolean): string {
248
+ return on ? `\u001b[${code}m${text}\u001b[0m` : text;
249
+ }
250
+
251
+ function wrapWords(text: string, width: number): string[] {
252
+ const lines: string[] = [];
253
+ let line = '';
254
+ for (const word of text.split(' ')) {
255
+ if (line && line.length + 1 + word.length > width) {
256
+ lines.push(line);
257
+ line = word;
258
+ } else {
259
+ line = line ? `${line} ${word}` : word;
260
+ }
261
+ }
262
+ if (line) lines.push(line);
263
+ return lines;
264
+ }
265
+
266
+ /** Continuation lines land in the same column as the first, so wrapped text reads as one block. */
267
+ function pushWrapped(lines: string[], first: string, text: string, col: number, width: number): void {
268
+ const wrapped = wrapWords(text, Math.max(20, width - col));
269
+ lines.push(first + (wrapped[0] ?? ''));
270
+ for (const rest of wrapped.slice(1)) lines.push(' '.repeat(col) + rest);
271
+ }
272
+
273
+ export function formatDoctor(report: DoctorReport, width = 80, color = false): string[] {
274
+ const lines: string[] = [];
275
+ for (const c of report.checks) {
276
+ const tag = paint(TAG[c.status], ANSI[c.status], color) + ' '.repeat(NAME_COL - TAG_COL - TAG[c.status].length);
277
+ const head = ' '.repeat(TAG_COL) + tag + c.name.padEnd(DETAIL_COL - NAME_COL);
278
+ pushWrapped(lines, head, c.detail, DETAIL_COL, width);
279
+ if (c.hint) {
280
+ const start = lines.length;
281
+ pushWrapped(lines, `${' '.repeat(NAME_COL)}hint: `, c.hint, NAME_COL + 6, width);
282
+ for (let i = start; i < lines.length; i++) lines[i] = paint(lines[i]!, DIM, color);
283
+ }
284
+ }
285
+ lines.push(
286
+ report.ok ? paint('ready', ANSI.ok, color) : paint('not ready: fix the [FAIL] items above', ANSI.fail, color),
287
+ );
288
+ return lines;
289
+ }