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
package/src/config.ts CHANGED
@@ -12,6 +12,20 @@ export interface CopperheadConfig {
12
12
  stageMaxTurns?: Record<string, number>;
13
13
  maxRepairCycles: number;
14
14
  budgets: Record<string, number>;
15
+ /** Per-turn watchdog (ms). A provider turn exceeding this is aborted and
16
+ * retried, so a hung call can't stall the run forever. <=0 disables it. */
17
+ turnTimeoutMs: number;
18
+ /** How often (ms) to emit a liveness heartbeat while a provider turn is in
19
+ * flight, so a slow large-output turn is distinguishable from a hung one
20
+ * (5.1). Fires only after the first interval, so quick turns stay silent.
21
+ * <=0 disables it. */
22
+ heartbeatMs: number;
23
+ /** How many times the create pipeline may auto-retry a stage that failed or
24
+ * ended without meeting its contract, gated by an LLM diagnosis each time. */
25
+ maxStageRetries: number;
26
+ /** Cache each turn's LLM response to disk and replay it on identical inputs,
27
+ * so retries/restarts reuse work already paid for. Default on. */
28
+ llmCache: boolean;
15
29
  /** Content hashes of generated docs, for init idempotency (AC-1.4). */
16
30
  generatedHashes?: Record<string, string>;
17
31
  /**
@@ -30,6 +44,19 @@ export const DEFAULTS: Omit<CopperheadConfig, 'schematic' | 'board'> = {
30
44
  maxTurns: 40,
31
45
  maxRepairCycles: 5,
32
46
  budgets: {},
47
+ // 10 min. A single large capture turn (a full lib_symbols + instances edit,
48
+ // ~40k output tokens) on the claude-code provider legitimately runs several
49
+ // minutes; the old 5-min deadline killed those mid-flight and, because the
50
+ // watchdog budget is spent per stage, could fail a stage that was only slow,
51
+ // not hung. 10 min clears the largest observed turns while still catching a
52
+ // genuinely stuck subprocess.
53
+ turnTimeoutMs: 600000,
54
+ // 30s: within one interval an operator knows a turn is alive, and a full
55
+ // 10-min turn emits ~20 lines — enough to distinguish slow from hung without
56
+ // flooding the log. Quick turns (< 30s) emit nothing.
57
+ heartbeatMs: 30000,
58
+ maxStageRetries: 2,
59
+ llmCache: true,
33
60
  };
34
61
 
35
62
  export function configPath(repoRoot: string): string {
@@ -56,13 +83,20 @@ export async function loadConfig(repoRoot: string): Promise<CopperheadConfig> {
56
83
  ...(Object.keys(stageMaxTurns).length ? { stageMaxTurns } : {}),
57
84
  maxRepairCycles: raw.maxRepairCycles ?? DEFAULTS.maxRepairCycles,
58
85
  budgets: raw.budgets ?? {},
86
+ turnTimeoutMs: typeof raw.turnTimeoutMs === 'number' ? raw.turnTimeoutMs : DEFAULTS.turnTimeoutMs,
87
+ heartbeatMs: typeof raw.heartbeatMs === 'number' ? raw.heartbeatMs : DEFAULTS.heartbeatMs,
88
+ maxStageRetries:
89
+ Number.isInteger(raw.maxStageRetries) && (raw.maxStageRetries as number) >= 0
90
+ ? (raw.maxStageRetries as number)
91
+ : DEFAULTS.maxStageRetries,
92
+ llmCache: raw.llmCache !== false,
59
93
  ...(raw.generatedHashes ? { generatedHashes: raw.generatedHashes } : {}),
60
94
  ...(raw.origin === 'create' || raw.origin === 'init' ? { origin: raw.origin } : {}),
61
95
  };
62
96
  }
63
97
 
64
98
  /** Which level of the model-selection precedence chain won. */
65
- export type ModelSource = 'flag' | 'env' | 'config' | 'openai-key' | 'anthropic-key';
99
+ export type ModelSource = 'flag' | 'env' | 'config' | 'openai-key' | 'anthropic-key' | 'picker';
66
100
 
67
101
  export interface ResolvedModel {
68
102
  model: string;
@@ -77,6 +111,8 @@ export interface ResolvedModel {
77
111
  * Accepted values (same set for `--model`, COPPERHEAD_MODEL, and `model` in
78
112
  * .copperhead/config.json):
79
113
  *
114
+ * - `cursor` : the Cursor Agent CLI using saved login (`agent login`).
115
+ * - `cursor:<id>` : the same provider on a specific model id.
80
116
  * - `claude-code` : the Claude Code saved-login provider on its default
81
117
  * model. Needs NO API key — it reuses the logged-in Claude
82
118
  * Code CLI / CLAUDE_CODE_OAUTH_TOKEN via the Agent SDK.
@@ -97,7 +133,8 @@ export interface ResolvedModel {
97
133
  * a typo like `claud-sonnet-5` silently routes to OpenAI and fails there.
98
134
  * Anthropic and direct OpenAI providers require their API keys; `codex` requires
99
135
  * a locally installed and authenticated Codex CLI, and `claude-code` requires a
100
- * Claude Code login (CLAUDE_CODE_OAUTH_TOKEN); neither needs a model API key.
136
+ * Claude Code login (CLAUDE_CODE_OAUTH_TOKEN); `cursor` requires `agent login`.
137
+ * None of the saved-login providers need a model API key.
101
138
  */
102
139
  export function resolveModel(flag: string | undefined, config: CopperheadConfig, env = process.env): ResolvedModel {
103
140
  if (flag) return { model: flag, source: 'flag' };
@@ -106,6 +143,6 @@ export function resolveModel(flag: string | undefined, config: CopperheadConfig,
106
143
  if (env.OPENAI_API_KEY) return { model: 'gpt-5', source: 'openai-key' };
107
144
  if (env.ANTHROPIC_API_KEY) return { model: 'claude', source: 'anthropic-key' };
108
145
  throw new Error(
109
- 'no model configured: pass --model codex (uses your local Codex login), set COPPERHEAD_MODEL, set model in .copperhead/config.json, or provide OPENAI_API_KEY/ANTHROPIC_API_KEY',
146
+ 'no model configured: pass --model, set COPPERHEAD_MODEL, or export an API key; see https://docs.copperhead.sh/reference/configuration/',
110
147
  );
111
148
  }
@@ -0,0 +1,181 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { writeFile } from 'node:fs/promises';
3
+ import { createHash } from 'node:crypto';
4
+ import path from 'node:path';
5
+ import { configPath, loadConfig, type CopperheadConfig } from '../config.js';
6
+
7
+ /**
8
+ * The create pipeline starts from a brief with no KiCad files, but the agent
9
+ * cannot create them: `write_file` refuses KiCad files and `edit_file` only
10
+ * edits existing ones. Without a project on disk the schematic stage can never
11
+ * satisfy its contract (config.schematic stays null), so the pipeline stalls
12
+ * indefinitely. This module scaffolds a minimal, kicad-cli-loadable empty
13
+ * project (schematic ERC-clean, board DRC-clean with a default outline) and
14
+ * wires it into .copperhead/config.json, giving the agent a file to populate.
15
+ */
16
+
17
+ /** Slug for the project filename, taken from the brief's first H1 (a leading
18
+ * "Brief:" label is dropped). Falls back to "board". */
19
+ export function projectSlug(brief: string): string {
20
+ const m = brief.match(/^#\s+(.+?)\s*$/m);
21
+ const title = (m?.[1] ?? 'board').replace(/^brief\s*:\s*/i, '');
22
+ const slug = title
23
+ .toLowerCase()
24
+ .replace(/[^a-z0-9]+/g, '-')
25
+ .replace(/^-+|-+$/g, '');
26
+ return slug || 'board';
27
+ }
28
+
29
+ /** A stable, valid-shaped v4 UUID derived from a seed, so a given project
30
+ * bootstraps to the same UUIDs on every run (no Date/random — runs stay
31
+ * reproducible). */
32
+ function uuidFrom(seed: string): string {
33
+ const h = createHash('sha256').update(seed).digest('hex');
34
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-8${h.slice(17, 20)}-${h.slice(20, 32)}`;
35
+ }
36
+
37
+ function emptySchematic(rootUuid: string): string {
38
+ return `(kicad_sch
39
+ (version 20231120)
40
+ (generator "eeschema")
41
+ (generator_version "8.0")
42
+ (uuid "${rootUuid}")
43
+ (paper "A4")
44
+ (lib_symbols)
45
+ (sheet_instances
46
+ (path "/" (page "1"))
47
+ )
48
+ )
49
+ `;
50
+ }
51
+
52
+ function emptyBoard(outlineUuid: string): string {
53
+ // A default 30x20mm outline on Edge.Cuts so the blank board is DRC-clean out
54
+ // of the gate; the layout stage resizes/replaces it with the real outline.
55
+ return `(kicad_pcb
56
+ (version 20240108)
57
+ (generator "pcbnew")
58
+ (generator_version "8.0")
59
+ (general
60
+ (thickness 1.6)
61
+ (legacy_teardrops no)
62
+ )
63
+ (paper "A4")
64
+ (layers
65
+ (0 "F.Cu" signal)
66
+ (31 "B.Cu" signal)
67
+ (32 "B.Adhes" user "B.Adhesive")
68
+ (33 "F.Adhes" user "F.Adhesive")
69
+ (34 "B.Paste" user)
70
+ (35 "F.Paste" user)
71
+ (36 "B.SilkS" user "B.Silkscreen")
72
+ (37 "F.SilkS" user "F.Silkscreen")
73
+ (38 "B.Mask" user)
74
+ (39 "F.Mask" user)
75
+ (40 "Dwgs.User" user "User.Drawings")
76
+ (41 "Cmts.User" user "User.Comments")
77
+ (42 "Eco1.User" user "User.Eco1")
78
+ (43 "Eco2.User" user "User.Eco2")
79
+ (44 "Edge.Cuts" user)
80
+ (45 "Margin" user)
81
+ (46 "B.CrtYd" user "B.Courtyard")
82
+ (47 "F.CrtYd" user "F.Courtyard")
83
+ (48 "B.Fab" user)
84
+ (49 "F.Fab" user)
85
+ )
86
+ (setup
87
+ (pad_to_mask_clearance 0)
88
+ (allow_soldermask_bridges_in_footprints no)
89
+ )
90
+ (net 0 "")
91
+ (gr_rect (start 100 100) (end 130 120)
92
+ (stroke (width 0.1) (type default))
93
+ (layer "Edge.Cuts")
94
+ (uuid "${outlineUuid}")
95
+ )
96
+ )
97
+ `;
98
+ }
99
+
100
+ function projectFile(slug: string, rootUuid: string): string {
101
+ return (
102
+ JSON.stringify(
103
+ {
104
+ board: { design_settings: { defaults: {}, rules: {} } },
105
+ erc: {
106
+ erc_exclusions: [],
107
+ meta: { version: 0 },
108
+ rule_severities: { footprint_link_issues: 'ignore', lib_symbol_issues: 'ignore' },
109
+ },
110
+ meta: { filename: `${slug}.kicad_pro`, version: 1 },
111
+ net_settings: {
112
+ classes: [
113
+ {
114
+ bus_width: 12,
115
+ clearance: 0.2,
116
+ diff_pair_gap: 0.25,
117
+ diff_pair_via_gap: 0.25,
118
+ diff_pair_width: 0.2,
119
+ line_style: 0,
120
+ microvia_diameter: 0.3,
121
+ microvia_drill: 0.1,
122
+ name: 'Default',
123
+ pcb_color: 'rgba(0, 0, 0, 0.000)',
124
+ schematic_color: 'rgba(0, 0, 0, 0.000)',
125
+ track_width: 0.25,
126
+ via_diameter: 0.6,
127
+ via_drill: 0.3,
128
+ wire_width: 6,
129
+ },
130
+ ],
131
+ meta: { version: 3 },
132
+ },
133
+ schematic: {
134
+ annotate_start_num: 0,
135
+ drawing: { default_line_thickness: 6.0, default_text_size: 50.0 },
136
+ legacy_lib_dir: '',
137
+ legacy_lib_list: [],
138
+ meta: { version: 1 },
139
+ },
140
+ sheets: [[rootUuid, 'Root']],
141
+ text_variables: {},
142
+ },
143
+ null,
144
+ 2,
145
+ ) + '\n'
146
+ );
147
+ }
148
+
149
+ async function persist(repoRoot: string, config: CopperheadConfig): Promise<void> {
150
+ await writeFile(configPath(repoRoot), JSON.stringify(config, null, 2) + '\n', 'utf8');
151
+ }
152
+
153
+ /**
154
+ * Ensure a KiCad project exists and is wired into config. No-op (returns null)
155
+ * when config already points at a schematic on disk. If project files exist but
156
+ * config doesn't reference them, just wires config. Otherwise scaffolds an empty
157
+ * project. Returns the schematic's repo-relative path when it created or wired
158
+ * one, else null.
159
+ */
160
+ export async function bootstrapKicadProject(repoRoot: string, brief: string): Promise<string | null> {
161
+ const config = await loadConfig(repoRoot);
162
+ if (config.schematic && existsSync(path.join(repoRoot, config.schematic))) return null;
163
+
164
+ const slug = projectSlug(brief);
165
+ const schRel = `${slug}.kicad_sch`;
166
+ const pcbRel = `${slug}.kicad_pcb`;
167
+ const proRel = `${slug}.kicad_pro`;
168
+ const schAbs = path.join(repoRoot, schRel);
169
+
170
+ if (!existsSync(schAbs)) {
171
+ const rootUuid = uuidFrom(slug);
172
+ await writeFile(schAbs, emptySchematic(rootUuid), 'utf8');
173
+ await writeFile(path.join(repoRoot, pcbRel), emptyBoard(uuidFrom(`${slug}:edge`)), 'utf8');
174
+ await writeFile(path.join(repoRoot, proRel), projectFile(slug, rootUuid), 'utf8');
175
+ }
176
+
177
+ config.schematic = schRel;
178
+ config.board = existsSync(path.join(repoRoot, pcbRel)) ? pcbRel : null;
179
+ await persist(repoRoot, config);
180
+ return schRel;
181
+ }
package/src/kicad/cli.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { execa, ExecaError } from 'execa';
2
+ import { existsSync } from 'node:fs';
2
3
  import { mkdtemp, readFile, rm } from 'node:fs/promises';
3
4
  import { tmpdir } from 'node:os';
4
5
  import path from 'node:path';
@@ -13,6 +14,7 @@ export class KicadCliMissingError extends PreflightError {
13
14
  [
14
15
  'install KiCad ≥ 8: https://www.kicad.org/download/',
15
16
  'ensure the kicad-cli binary is on PATH (on macOS it ships inside KiCad.app/Contents/MacOS)',
17
+ 'or set COPPERHEAD_KICAD_CLI=/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli',
16
18
  'confirm with "kicad-cli version", then rerun',
17
19
  ],
18
20
  );
@@ -20,12 +22,134 @@ export class KicadCliMissingError extends PreflightError {
20
22
  }
21
23
  }
22
24
 
25
+ /**
26
+ * `COPPERHEAD_KICAD_CLI` points somewhere that does not exist. Distinct from
27
+ * KicadCliMissingError because the advice is the opposite: the override was
28
+ * seen and rejected, so telling the user to set it would be nonsense.
29
+ */
30
+ export class KicadCliBadOverrideError extends PreflightError {
31
+ constructor(configured: string) {
32
+ super(
33
+ `COPPERHEAD_KICAD_CLI points to a path that does not exist: ${configured}`,
34
+ 'the override wins over PATH, so falling back silently would run a different binary than the one you named and make the failure impossible to diagnose',
35
+ [
36
+ `check the path: ls -l "${configured}"`,
37
+ 'on macOS the binary lives at /Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli',
38
+ 'or unset COPPERHEAD_KICAD_CLI to fall back to PATH',
39
+ 'confirm with "$COPPERHEAD_KICAD_CLI version", then rerun',
40
+ ],
41
+ );
42
+ this.name = 'KicadCliBadOverrideError';
43
+ }
44
+ }
45
+
46
+ /** Well-known install locations when `kicad-cli` is not on PATH (macOS app bundle). */
47
+ const FALLBACK_BINARIES = [
48
+ '/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli',
49
+ '/Applications/KiCad-10.0/KiCad.app/Contents/MacOS/kicad-cli',
50
+ '/Applications/KiCad-9.0/KiCad.app/Contents/MacOS/kicad-cli',
51
+ '/Applications/KiCad-8.0/KiCad.app/Contents/MacOS/kicad-cli',
52
+ ];
53
+
54
+ let fallbackBinaries: readonly string[] = FALLBACK_BINARIES;
55
+
56
+ let cachedBinary: string | null | undefined;
57
+
58
+ /**
59
+ * Resolve the kicad-cli executable: `COPPERHEAD_KICAD_CLI` > PATH name
60
+ * (`kicad-cli`). On PATH miss, `runKicad` falls back to macOS KiCad.app paths.
61
+ */
62
+ export function resolveKicadCli(): string {
63
+ if (cachedBinary !== undefined) {
64
+ if (cachedBinary === null) throw new KicadCliMissingError();
65
+ return cachedBinary;
66
+ }
67
+ const fromEnv = envOverride();
68
+ if (fromEnv) {
69
+ cachedBinary = fromEnv;
70
+ return fromEnv;
71
+ }
72
+ cachedBinary = 'kicad-cli';
73
+ return cachedBinary;
74
+ }
75
+
76
+ /**
77
+ * The `COPPERHEAD_KICAD_CLI` override, or null when unset. Set-but-missing is
78
+ * a hard error rather than a silent fallthrough to PATH: the user told us
79
+ * which binary to run, and quietly running a different one (or reporting
80
+ * "not found on PATH" with advice to set the variable they already set) is
81
+ * the worst possible answer.
82
+ */
83
+ function envOverride(): string | null {
84
+ const fromEnv = process.env.COPPERHEAD_KICAD_CLI?.trim();
85
+ if (!fromEnv) return null;
86
+ if (!existsSync(fromEnv)) throw new KicadCliBadOverrideError(fromEnv);
87
+ return fromEnv;
88
+ }
89
+
90
+ /**
91
+ * After ENOENT on PATH, retry with a known macOS install path.
92
+ *
93
+ * Deliberately does not re-read `COPPERHEAD_KICAD_CLI`: this is reached only
94
+ * when resolveKicadCli() cached the bare PATH name, which in turn happens only
95
+ * when the override was unset (set-but-missing throws there instead), so an
96
+ * override re-check here could never fire.
97
+ */
98
+ function fallbackAfterMissing(): string {
99
+ for (const candidate of fallbackBinaries) {
100
+ if (existsSync(candidate)) {
101
+ cachedBinary = candidate;
102
+ return candidate;
103
+ }
104
+ }
105
+ cachedBinary = null;
106
+ throw new KicadCliMissingError();
107
+ }
108
+
109
+ async function runKicad(args: string[], opts?: { reject?: boolean }): Promise<Awaited<ReturnType<typeof execa>>> {
110
+ let bin = resolveKicadCli();
111
+ let res = await execa(bin, args, { reject: false });
112
+ if (res.failed && (res as unknown as ExecaError).code === 'ENOENT') {
113
+ if (bin === 'kicad-cli') {
114
+ bin = fallbackAfterMissing();
115
+ res = await execa(bin, args, { reject: false });
116
+ } else {
117
+ throw new KicadCliMissingError();
118
+ }
119
+ }
120
+ if (opts?.reject === false) return res;
121
+ if (res.failed && (res as unknown as ExecaError).code === 'ENOENT') {
122
+ throw new KicadCliMissingError();
123
+ }
124
+ if (res.failed) {
125
+ throw Object.assign(new Error(res.stderr || res.stdout || `kicad-cli exited ${res.exitCode}`), res);
126
+ }
127
+ return res;
128
+ }
129
+
130
+ /** Test helper: clear the resolved-binary cache. */
131
+ export function resetKicadCliCache(): void {
132
+ cachedBinary = undefined;
133
+ }
134
+
135
+ /**
136
+ * Test helper: point the app-bundle probe at fixture paths, or call with no
137
+ * argument to restore the real list. Without this the fallback branch is only
138
+ * exercisable on a macOS host that happens to have KiCad installed, which
139
+ * makes the outcome depend on the developer's machine.
140
+ */
141
+ export function setKicadFallbackBinaries(paths?: readonly string[]): void {
142
+ fallbackBinaries = paths ?? FALLBACK_BINARIES;
143
+ }
144
+
23
145
  export async function kicadCliVersion(): Promise<string> {
24
146
  try {
25
- const { stdout } = await execa('kicad-cli', ['version']);
26
- return stdout.trim();
147
+ const res = await runKicad(['version']);
148
+ return String(res.stdout ?? '').trim();
27
149
  } catch (err) {
28
150
  if ((err as ExecaError).code === 'ENOENT') throw new KicadCliMissingError();
151
+ // runKicad already maps PATH ENOENT → fallback → KicadCliMissingError
152
+ if (err instanceof KicadCliMissingError) throw err;
29
153
  throw err;
30
154
  }
31
155
  }
@@ -39,8 +163,7 @@ async function runCheck(
39
163
  const out = path.join(dir, `${kind}.json`);
40
164
  const sub = kind === 'erc' ? ['sch', 'erc'] : ['pcb', 'drc'];
41
165
  try {
42
- const res = await execa(
43
- 'kicad-cli',
166
+ const res = await runKicad(
44
167
  [...sub, '--format', 'json', '--exit-code-violations', '--output', out, ...extraArgs, filePath],
45
168
  { reject: false },
46
169
  );
@@ -94,7 +217,7 @@ export async function kicadLoadError(filePath: string): Promise<string | null> {
94
217
  ? ['sch', 'export', 'netlist', '--output', path.join(dir, 'probe.net'), filePath]
95
218
  : ['pcb', 'export', 'pos', '--output', path.join(dir, 'probe.pos'), filePath];
96
219
  try {
97
- const res = await execa('kicad-cli', args, { reject: false });
220
+ const res = await runKicad(args, { reject: false });
98
221
  if (res.failed && (res as unknown as ExecaError).code === 'ENOENT') throw new KicadCliMissingError();
99
222
  if (res.exitCode === 0) return null;
100
223
  return [res.stderr, res.stdout].filter(Boolean).join('\n').trim() || `kicad-cli exited ${res.exitCode}`;
@@ -131,9 +254,10 @@ export async function exportFab(pcbPath: string, schPath: string | null, outDir:
131
254
  }
132
255
  for (const job of jobs) {
133
256
  try {
134
- await execa('kicad-cli', job.args);
257
+ await runKicad(job.args);
135
258
  result.produced.push(job.artifact);
136
259
  } catch (err) {
260
+ if (err instanceof KicadCliMissingError) throw err;
137
261
  if ((err as ExecaError).code === 'ENOENT') throw new KicadCliMissingError();
138
262
  result.failed.push({ artifact: job.artifact, reason: String((err as ExecaError).stderr ?? (err as Error).message).slice(0, 200) });
139
263
  }
@@ -148,8 +272,9 @@ export async function exportSvg(kind: 'sch' | 'pcb', filePath: string, outDir: s
148
272
  ? ['sch', 'export', 'svg', '--output', outDir, filePath]
149
273
  : ['pcb', 'export', 'svg', '--output', path.join(outDir, 'board.svg'), '--layers', 'F.Cu,B.Cu,Edge.Cuts', filePath];
150
274
  try {
151
- await execa('kicad-cli', args);
275
+ await runKicad(args);
152
276
  } catch (err) {
277
+ if (err instanceof KicadCliMissingError) throw err;
153
278
  if ((err as ExecaError).code === 'ENOENT') throw new KicadCliMissingError();
154
279
  throw err;
155
280
  }