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.
- package/README.md +36 -4
- package/dist/agent/animate.js +76 -0
- package/dist/agent/animate.js.map +1 -0
- package/dist/agent/box.js +89 -0
- package/dist/agent/box.js.map +1 -0
- package/dist/agent/dock-renderer.js +173 -0
- package/dist/agent/dock-renderer.js.map +1 -0
- package/dist/agent/logo.js +21 -0
- package/dist/agent/logo.js.map +1 -0
- package/dist/agent/loop.js +130 -18
- package/dist/agent/loop.js.map +1 -1
- package/dist/agent/prompts.js +2 -1
- package/dist/agent/prompts.js.map +1 -1
- package/dist/agent/providers/claude-code.js +85 -116
- package/dist/agent/providers/claude-code.js.map +1 -1
- package/dist/agent/providers/cursor.js +317 -0
- package/dist/agent/providers/cursor.js.map +1 -0
- package/dist/agent/providers/tool-protocol.js +205 -0
- package/dist/agent/providers/tool-protocol.js.map +1 -0
- package/dist/agent/recovery.js +148 -0
- package/dist/agent/recovery.js.map +1 -0
- package/dist/agent/render.js +44 -12
- package/dist/agent/render.js.map +1 -1
- package/dist/agent/response-cache.js +81 -0
- package/dist/agent/response-cache.js.map +1 -0
- package/dist/agent/runmeta.js +4 -5
- package/dist/agent/runmeta.js.map +1 -1
- package/dist/agent/theme.js +84 -0
- package/dist/agent/theme.js.map +1 -0
- package/dist/agent/tools.js +61 -4
- package/dist/agent/tools.js.map +1 -1
- package/dist/agent/transcript.js.map +1 -1
- package/dist/cli.js +134 -13
- package/dist/cli.js.map +1 -1
- package/dist/commands/create.js +482 -38
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/demo.js +146 -0
- package/dist/commands/demo.js.map +1 -0
- package/dist/commands/doctor.js +240 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/repl-inspect.js +342 -0
- package/dist/commands/repl-inspect.js.map +1 -0
- package/dist/commands/repl.js +618 -0
- package/dist/commands/repl.js.map +1 -0
- package/dist/config.js +24 -2
- package/dist/config.js.map +1 -1
- package/dist/kicad/bootstrap.js +166 -0
- package/dist/kicad/bootstrap.js.map +1 -0
- package/dist/kicad/cli.js +126 -6
- package/dist/kicad/cli.js.map +1 -1
- package/dist/kicad/spice.js +306 -0
- package/dist/kicad/spice.js.map +1 -0
- package/dist/kicad/symlib.js +228 -0
- package/dist/kicad/symlib.js.map +1 -0
- package/dist/memory/bom-table.js +193 -22
- package/dist/memory/bom-table.js.map +1 -1
- package/dist/memory/drift.js +33 -11
- package/dist/memory/drift.js.map +1 -1
- package/dist/util/cli-args.js +35 -0
- package/dist/util/cli-args.js.map +1 -0
- package/dist/util/dock.js +155 -0
- package/dist/util/dock.js.map +1 -0
- package/dist/util/git.js +165 -4
- package/dist/util/git.js.map +1 -1
- package/dist/util/live-prompt.js +542 -0
- package/dist/util/live-prompt.js.map +1 -0
- package/dist/util/paths.js +9 -0
- package/dist/util/paths.js.map +1 -1
- package/dist/util/preflight.js +37 -0
- package/dist/util/preflight.js.map +1 -1
- package/dist/util/retry.js +23 -0
- package/dist/util/retry.js.map +1 -1
- package/dist/util/select.js +172 -0
- package/dist/util/select.js.map +1 -0
- package/dist/util/tmp.js +119 -0
- package/dist/util/tmp.js.map +1 -0
- package/package.json +3 -2
- package/src/agent/animate.ts +90 -0
- package/src/agent/box.ts +99 -0
- package/src/agent/dock-renderer.ts +181 -0
- package/src/agent/logo.ts +23 -0
- package/src/agent/loop.ts +148 -18
- package/src/agent/prompts.ts +2 -1
- package/src/agent/providers/claude-code.ts +91 -122
- package/src/agent/providers/cursor.ts +364 -0
- package/src/agent/providers/tool-protocol.ts +212 -0
- package/src/agent/recovery.ts +162 -0
- package/src/agent/render.ts +56 -12
- package/src/agent/response-cache.ts +80 -0
- package/src/agent/runmeta.ts +6 -7
- package/src/agent/theme.ts +91 -0
- package/src/agent/tools.ts +62 -4
- package/src/agent/transcript.ts +1 -0
- package/src/agent/types.ts +17 -0
- package/src/cli.ts +139 -15
- package/src/commands/create.ts +581 -40
- package/src/commands/demo.ts +184 -0
- package/src/commands/doctor.ts +289 -0
- package/src/commands/repl-inspect.ts +353 -0
- package/src/commands/repl.ts +685 -0
- package/src/config.ts +40 -3
- package/src/kicad/bootstrap.ts +181 -0
- package/src/kicad/cli.ts +132 -7
- package/src/kicad/spice.ts +399 -0
- package/src/kicad/symlib.ts +248 -0
- package/src/layout/claude-ui-layout.md +72 -0
- package/src/layout/repl-ui-layout.md +139 -0
- package/src/memory/bom-table.ts +191 -20
- package/src/memory/drift.ts +42 -11
- package/src/util/cli-args.ts +42 -0
- package/src/util/dock.ts +161 -0
- package/src/util/git.ts +176 -4
- package/src/util/live-prompt.ts +595 -0
- package/src/util/paths.ts +10 -0
- package/src/util/preflight.ts +44 -0
- package/src/util/retry.ts +29 -0
- package/src/util/select.ts +192 -0
- 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);
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
}
|