copperhead 0.8.0 → 0.9.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 (41) hide show
  1. package/README.md +9 -1
  2. package/dist/agent/loop.js +38 -4
  3. package/dist/agent/loop.js.map +1 -1
  4. package/dist/agent/providers/openai.js +28 -6
  5. package/dist/agent/providers/openai.js.map +1 -1
  6. package/dist/agent/response-cache.js +18 -2
  7. package/dist/agent/response-cache.js.map +1 -1
  8. package/dist/cli.js +2 -2
  9. package/dist/cli.js.map +1 -1
  10. package/dist/commands/create.js +113 -9
  11. package/dist/commands/create.js.map +1 -1
  12. package/dist/commands/doctor.js +164 -11
  13. package/dist/commands/doctor.js.map +1 -1
  14. package/dist/config.js +60 -4
  15. package/dist/config.js.map +1 -1
  16. package/dist/kicad/cli.js +7 -26
  17. package/dist/kicad/cli.js.map +1 -1
  18. package/dist/kicad/sexp.js +23 -4
  19. package/dist/kicad/sexp.js.map +1 -1
  20. package/dist/memory/bom-table.js +48 -10
  21. package/dist/memory/bom-table.js.map +1 -1
  22. package/dist/openspec/cli.js +2 -1
  23. package/dist/openspec/cli.js.map +1 -1
  24. package/dist/util/preflight.js +17 -0
  25. package/dist/util/preflight.js.map +1 -1
  26. package/dist/util/redact.js +6 -2
  27. package/dist/util/redact.js.map +1 -1
  28. package/package.json +2 -5
  29. package/src/agent/loop.ts +60 -4
  30. package/src/agent/providers/openai.ts +38 -4
  31. package/src/agent/response-cache.ts +17 -1
  32. package/src/cli.ts +2 -2
  33. package/src/commands/create.ts +107 -10
  34. package/src/commands/doctor.ts +171 -12
  35. package/src/config.ts +83 -2
  36. package/src/kicad/cli.ts +6 -19
  37. package/src/kicad/sexp.ts +24 -4
  38. package/src/memory/bom-table.ts +51 -10
  39. package/src/openspec/cli.ts +3 -2
  40. package/src/util/preflight.ts +18 -0
  41. package/src/util/redact.ts +6 -2
@@ -1,13 +1,13 @@
1
1
  import path from 'node:path';
2
2
  import { existsSync } from 'node:fs';
3
- import { readFile, mkdir, writeFile } from 'node:fs/promises';
3
+ import { readFile, mkdir, writeFile, readdir } from 'node:fs/promises';
4
4
  import { createHash } from 'node:crypto';
5
- import { loadConfig } from '../config.js';
5
+ import { loadConfig, resolveCompatSettings } from '../config.js';
6
6
  import { bootstrapKicadProject } from '../kicad/bootstrap.js';
7
7
  import { exportSvg, runErc } from '../kicad/cli.js';
8
8
  import { listSymbols } from '../kicad/sexp.js';
9
9
  import { isDirty, commitAll, changedFiles } from '../util/git.js';
10
- import type { CopperheadConfig } from '../config.js';
10
+ import type { CompatSettings, CopperheadConfig } from '../config.js';
11
11
  import { checkDrift } from '../memory/drift.js';
12
12
  import { runAgentLoop, makeProvider, type BudgetExhaustedStats } from '../agent/loop.js';
13
13
  import { diagnoseStageFailure, transcriptExcerpt, withTimeout, type StageDiagnosis } from '../agent/recovery.js';
@@ -55,22 +55,98 @@ async function docHasHeading(repoRoot: string, rel: string, word: string): Promi
55
55
  return re.test(await readFile(p, 'utf8'));
56
56
  }
57
57
 
58
+ /**
59
+ * Returns true when a directory exists and contains at least one file
60
+ * matching the optional glob-style extension list (case-insensitive).
61
+ * No extension list = any file.
62
+ */
63
+ async function dirHasFiles(dirPath: string, exts?: string[]): Promise<boolean> {
64
+ if (!existsSync(dirPath)) return false;
65
+ async function walk(dir: string): Promise<boolean> {
66
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
67
+ if (entry.isDirectory()) {
68
+ if (await walk(path.join(dir, entry.name))) return true;
69
+ } else if (!exts || exts.some((e) => entry.name.toLowerCase().endsWith(e))) {
70
+ return true;
71
+ }
72
+ }
73
+ return false;
74
+ }
75
+ return walk(dirPath);
76
+ }
77
+
58
78
  export const STAGES: Stage[] = [
59
79
  {
60
80
  name: 'spec-seed',
61
- isComplete: (root, docs) => docHasHeading(root, path.join(docs, 'SPEC.md'), 'Budgets?'),
81
+ isComplete: async (root, docs) => {
82
+ // The init scaffold writes SPEC.md with a "## Budgets" heading and an
83
+ // HTML comment placeholder — that alone must not count as complete.
84
+ // Require the heading AND at least one non-comment, non-blank line
85
+ // of real budget content beneath it (a filled section vs. an empty placeholder).
86
+ const p = path.join(root, docs, 'SPEC.md');
87
+ if (!existsSync(p)) return false;
88
+ const text = await readFile(p, 'utf8');
89
+ const budgetsMatch = /^#{1,6}\s.*\bBudgets?\b/im.test(text);
90
+ if (!budgetsMatch) return false;
91
+ // Find the Budgets section and strip HTML comments (single or multi-line)
92
+ const afterBudgets = text.split(/^#{1,6}\s.*\bBudgets?\b/im)[1] ?? '';
93
+ const firstNewline = afterBudgets.indexOf('\n');
94
+ const afterHeadingLine = firstNewline >= 0 ? afterBudgets.slice(firstNewline + 1) : '';
95
+ const nextSection = afterHeadingLine.search(/^#{1,6}\s/m);
96
+ const section = nextSection >= 0 ? afterHeadingLine.slice(0, nextSection) : afterHeadingLine;
97
+ const cleanSection = section.replace(/<!--[\s\S]*?-->/g, '');
98
+ const realLines = cleanSection.split('\n').filter((l) => l.trim().length > 0);
99
+ return realLines.length > 0;
100
+ },
62
101
  prompt: (brief) =>
63
102
  `Stage 1 of the create pipeline: seed the requirements. From the product brief below, write docs/SPEC.md (what the device is, top-level constraints and budgets). Every budget you state must also be recorded with record_constraint. Anything the brief does not state: propose a sensible default and flag it ASSUMED. If an openspec/ workspace exists, also seed openspec/specs/ with per-capability requirements using Given/When/Then scenarios.\n\nBrief:\n${brief}`,
64
103
  },
65
104
  {
66
105
  name: 'architecture',
67
- isComplete: (root, docs) => docExists(root, path.join(docs, 'SUBSYSTEMS.md')),
106
+ isComplete: async (root, docs) => {
107
+ // init scaffolds SUBSYSTEMS.md with boilerplate description text and auto-generated
108
+ // "## Sheet X" headings containing "- Ref: Value" symbol bullets.
109
+ // Require at least one level-2+ heading (## section) AND at least one real prose
110
+ // line beneath it (excluding boilerplate and auto-generated symbol bullets).
111
+ const p = path.join(root, docs, 'SUBSYSTEMS.md');
112
+ if (!existsSync(p)) return false;
113
+ const text = await readFile(p, 'utf8');
114
+ // Must have at least one level-2+ (##) section heading
115
+ if (!/^#{2,6}\s/m.test(text)) return false;
116
+ // Filter out headings, scaffold description, and auto-generated symbol bullets (- Ref: Value or - Ref?: Value)
117
+ const contentLines = text.split('\n').filter((l) => {
118
+ const trimmed = l.trim();
119
+ if (!trimmed || trimmed.startsWith('#')) return false;
120
+ if (trimmed.includes('Per-sheet values and reasoning')) return false;
121
+ if (/^-\s+(?:[A-Za-z]+\d+[A-Za-z]*|[A-Za-z]*\?):/.test(trimmed)) return false; // auto-generated refdes symbol bullet (e.g. - R1: 10k, - U?: ESP32, - ?: 10k)
122
+ return true;
123
+ });
124
+ return contentLines.length > 0;
125
+ },
68
126
  prompt: () =>
69
127
  'Stage 2: architecture. Write docs/SUBSYSTEMS.md: the block diagram in prose, one section per subsystem (power, MCU, connectivity, UI, ...), with the reasoning and key values for each. Respect every budget in SPEC.md.',
70
128
  },
71
129
  {
72
130
  name: 'part-selection',
73
- isComplete: (root, docs) => docExists(root, path.join(docs, 'BOM.md')),
131
+ isComplete: async (root, docs) => {
132
+ // init scaffolds BOM.md with a table pre-filled with UNVERIFIED MPNs
133
+ // extracted from the schematic. Require at least one row whose MPN
134
+ // column is NOT the UNVERIFIED placeholder — i.e. a real part was chosen.
135
+ const p = path.join(root, docs, 'BOM.md');
136
+ if (!existsSync(p)) return false;
137
+ const text = await readFile(p, 'utf8');
138
+ // Find table rows (lines starting with |) that are not the header or separator
139
+ const rows = text.split('\n').filter(
140
+ (l) => l.startsWith('|') && !l.includes('---') && !l.toLowerCase().includes('refdes'),
141
+ );
142
+ if (!rows.length) return false;
143
+ // At least one row must have a non-UNVERIFIED MPN (4th column)
144
+ return rows.some((row) => {
145
+ const cols = row.split('|').map((c) => c.trim());
146
+ const mpn = cols[4] ?? ''; // 0=empty, 1=Refdes, 2=Value, 3=Footprint, 4=MPN
147
+ return mpn && !mpn.toUpperCase().startsWith('UNVERIFIED');
148
+ });
149
+ },
74
150
  prompt: () =>
75
151
  'Stage 3: part selection. Write docs/BOM.md with the fixed table format (| Refdes | Value | Footprint | MPN | Rationale |). Every MPN you introduce is flagged UNVERIFIED with a datasheet-verifiable justification. Check leakage/quiescent current of every part against the power budget. Run check_drift before finishing.',
76
152
  },
@@ -122,19 +198,37 @@ export const STAGES: Stage[] = [
122
198
  },
123
199
  {
124
200
  name: 'outputs',
125
- isComplete: (root) => existsSync(path.join(root, 'outputs')),
201
+ isComplete: async (root) => {
202
+ // An empty outputs/ dir (e.g. from a failed export run) must not count
203
+ // as complete. Require at least one Gerber file (any .gbr variant).
204
+ return dirHasFiles(path.join(root, 'outputs'), ['.gbr', '.gtl', '.gbl', '.gbs', '.gbo', '.gbp', '.gbd', '.gto', '.gts', '.gml']);
205
+ },
126
206
  prompt: () =>
127
207
  'Stage 6: outputs package. Export into outputs/: gerbers+drill (JLC profile), DXF and STEP outline, SVG renders (export_svg), and an ordering BOM.csv generated from BOM.md (refdes, MPN, qty). Every export must succeed.',
128
208
  },
129
209
  {
130
210
  name: 'firmware',
131
- isComplete: (root) => existsSync(path.join(root, 'firmware')),
211
+ isComplete: async (root) => {
212
+ // An empty firmware/ dir must not count. Require at least one source file.
213
+ return dirHasFiles(path.join(root, 'firmware'), ['.c', '.h', '.cpp', '.hpp', '.py', '.rs', '.ino', '.s']);
214
+ },
132
215
  prompt: () =>
133
216
  'Stage 7: firmware scaffold. Generate firmware/ for the chosen MCU HAL: pins.h generated from PINOUT.md (single source of truth), driver stubs, and one working happy path. If the vendor toolchain is available, the build must pass; if not, note "not compiled here" explicitly in DEVPLAN.md.',
134
217
  },
135
218
  {
136
219
  name: 'devplan',
137
- isComplete: (root, docs) => docExists(root, path.join(docs, 'DEVPLAN.md')),
220
+ isComplete: async (root, docs) => {
221
+ // init does NOT scaffold DEVPLAN.md, but a blank file must not count.
222
+ // Require at least one ## section heading AND at least one content line.
223
+ const p = path.join(root, docs, 'DEVPLAN.md');
224
+ if (!existsSync(p)) return false;
225
+ const text = await readFile(p, 'utf8');
226
+ if (!/^#{1,6}\s/m.test(text)) return false;
227
+ const contentLines = text.split('\n').filter(
228
+ (l) => l.trim() && !l.trim().startsWith('#'),
229
+ );
230
+ return contentLines.length > 0;
231
+ },
138
232
  prompt: () =>
139
233
  'Stage 8: DEVPLAN.md. Write docs/DEVPLAN.md: bring-up steps in order, test points and what to meter first, risk list, and the prototype order plan.',
140
234
  },
@@ -284,10 +378,12 @@ async function diagnose(input: {
284
378
  transcriptDir: string;
285
379
  attempt: number;
286
380
  maxAttempts: number;
381
+ /** Compatible-endpoint settings, so a `compat` run can diagnose itself. */
382
+ compat?: CompatSettings | undefined;
287
383
  }): Promise<StageDiagnosis> {
288
384
  let provider: Provider | undefined;
289
385
  try {
290
- provider = await makeProvider(input.model);
386
+ provider = await makeProvider(input.model, false, input.compat);
291
387
  const p = provider;
292
388
  const excerpt = await transcriptExcerpt(input.transcriptDir);
293
389
  return await withTimeout(
@@ -685,6 +781,7 @@ export async function runCreate(opts: CreateOptions): Promise<{ ok: boolean; com
685
781
  const diagnosis = await diagnose({
686
782
  model: opts.model,
687
783
  timeoutMs: config.turnTimeoutMs,
784
+ compat: resolveCompatSettings(config),
688
785
  stageName: stage.name,
689
786
  stageGoal: basePrompt,
690
787
  failure,
@@ -2,7 +2,16 @@ import { execFile } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
3
  import { existsSync } from 'node:fs';
4
4
  import path from 'node:path';
5
- import { DEFAULTS, loadConfig, resolveModel, type CopperheadConfig } from '../config.js';
5
+ import {
6
+ DEFAULTS,
7
+ DEFAULT_API_KEY_ENV,
8
+ isLocalEndpoint,
9
+ loadConfig,
10
+ resolveCompatSettings,
11
+ resolveModel,
12
+ type CompatSettings,
13
+ type CopperheadConfig,
14
+ } from '../config.js';
6
15
  import { kicadCliVersion } from '../kicad/cli.js';
7
16
  import { redactSecrets } from '../util/redact.js';
8
17
 
@@ -15,7 +24,7 @@ const execFileP = promisify(execFile);
15
24
  * at the model provider). Each probe fails soft: a missing tool is a reported
16
25
  * `fail`, never a thrown error, so `doctor` still prints the rest of the report.
17
26
  */
18
- export type DoctorStatus = 'ok' | 'fail' | 'info';
27
+ export type DoctorStatus = 'ok' | 'fail' | 'warn' | 'info';
19
28
 
20
29
  export interface DoctorCheck {
21
30
  name: string;
@@ -97,7 +106,139 @@ async function gitCheck(probe: () => Promise<string>): Promise<DoctorCheck> {
97
106
  * Saved-login providers (codex, claude-code) need no key and can't be verified
98
107
  * offline, so they report `info` (which does not block `ok`).
99
108
  */
100
- export function checkCredential(model: string, env: NodeJS.ProcessEnv): DoctorCheck {
109
+ export function checkCredential(
110
+ model: string,
111
+ env: NodeJS.ProcessEnv,
112
+ compat?: CompatSettings | undefined,
113
+ ): DoctorCheck {
114
+ // OpenAI-compatible endpoint: the credential lives in a variable the user
115
+ // names, and the endpoint is worth showing because it is the whole point of
116
+ // the route. A loopback endpoint (Ollama) needs no key at all (design D4).
117
+ if (model === 'compat' || model.startsWith('compat:')) {
118
+ const shownModel = redactSecrets(model);
119
+ const compatModel = model.startsWith('compat:') ? model.slice('compat:'.length) : undefined;
120
+ // Mirrors makeProvider (agent/loop.ts): bare `compat` has no valid default
121
+ // model, so it must fail here too, or doctor reports "ready" for a run
122
+ // that would fail on its very first turn.
123
+ if (!compatModel) {
124
+ return {
125
+ name: 'provider',
126
+ status: 'fail',
127
+ detail: `${shownModel} -> compat: ${model === 'compat:' ? 'empty' : 'missing'} model id`,
128
+ hint: 'use "compat:<model-id>"; a compatible endpoint has no default model to assume.',
129
+ };
130
+ }
131
+ const settings = compat ?? { apiKeyEnv: DEFAULT_API_KEY_ENV };
132
+ // Display only: some endpoints embed a credential in the URL itself (a
133
+ // query param, userinfo) — Gemini's compat endpoint does this with
134
+ // ?key=..., in a format redactSecrets' key-shape patterns don't cover.
135
+ // Drop the query and userinfo entirely rather than pattern-matching, so
136
+ // this holds regardless of what a given provider's key looks like.
137
+ // isLocalEndpoint() below still runs against the raw settings.baseURL,
138
+ // never this.
139
+ const where = (() => {
140
+ if (!settings.baseURL) return '(no baseURL configured)';
141
+ try {
142
+ const u = new URL(settings.baseURL);
143
+ return `${u.origin}${u.pathname}`;
144
+ } catch {
145
+ return redactSecrets(settings.baseURL);
146
+ }
147
+ })();
148
+ if (!settings.baseURL) {
149
+ return {
150
+ name: 'provider',
151
+ status: 'fail',
152
+ detail: `${shownModel} -> compat: no endpoint configured`,
153
+ hint: 'set COPPERHEAD_BASE_URL, or "baseURL" in .copperhead/config.json.',
154
+ };
155
+ }
156
+ if (isLocalEndpoint(settings.baseURL)) {
157
+ return {
158
+ name: 'provider',
159
+ status: 'ok',
160
+ detail: `${shownModel} -> compat: ${where} (local endpoint, no key required)`,
161
+ };
162
+ }
163
+ return env[settings.apiKeyEnv]
164
+ ? { name: 'provider', status: 'ok', detail: `${shownModel} -> compat: ${where} (${settings.apiKeyEnv} set)` }
165
+ : {
166
+ name: 'provider',
167
+ status: 'fail',
168
+ detail: `${shownModel} -> compat: ${where} (${settings.apiKeyEnv} not set)`,
169
+ hint: `export ${settings.apiKeyEnv}=... for that endpoint.`,
170
+ };
171
+ }
172
+ return checkKeyedCredential(model, env);
173
+ }
174
+
175
+ /**
176
+ * Hosts whose free tier may train on submitted prompts. Keyed on hostname
177
+ * rather than model name: hostnames are stable, model and tier names rot in
178
+ * months, so the tier detail belongs in docs (design D5). Never `fail` — a
179
+ * contributor deliberately using a free tier on a non-proprietary board is not
180
+ * misconfigured, so this must not make `doctor` exit non-zero.
181
+ */
182
+ const TRAINING_RISK_HOSTS: Record<string, string> = {
183
+ 'generativelanguage.googleapis.com': "Gemini's free tier may train on submitted prompts",
184
+ 'openrouter.ai': 'OpenRouter `:free` models may route to providers that train on prompts',
185
+ };
186
+
187
+ /**
188
+ * True loopback only — unlike `isLocalEndpoint` (config.ts), this excludes
189
+ * `.local`/mDNS hostnames. `isLocalEndpoint`'s broader definition is correct
190
+ * for "does this need a credential" (many LAN-hosted servers skip auth), but
191
+ * wrong for the privacy bypass below: a request to `nas.local` genuinely
192
+ * leaves the machine onto the LAN to a different physical device, so "nothing
193
+ * leaves the machine" does not hold the way it does for real loopback.
194
+ */
195
+ function isLoopbackHost(baseURL: string): boolean {
196
+ try {
197
+ const h = new URL(baseURL).hostname.toLowerCase();
198
+ return h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '[::1]';
199
+ } catch {
200
+ return false;
201
+ }
202
+ }
203
+
204
+ /** A `warn` line when the configured endpoint's host is a documented training risk. */
205
+ export function checkPromptPrivacy(model: string, compat?: CompatSettings | undefined): DoctorCheck | null {
206
+ if (model !== 'compat' && !model.startsWith('compat:')) return null;
207
+ if (!compat?.baseURL) return null;
208
+ // A true loopback endpoint has no third party to have a policy about:
209
+ // nothing leaves the machine, so "check the provider's terms" would be
210
+ // nonsensical. A LAN host (including .local) does not get this bypass.
211
+ if (isLoopbackHost(compat.baseURL)) return null;
212
+ let host: string;
213
+ try {
214
+ host = new URL(compat.baseURL).hostname.toLowerCase();
215
+ } catch {
216
+ return null;
217
+ }
218
+ const noPolicyOnRecord = (): DoctorCheck => ({
219
+ name: 'privacy',
220
+ status: 'info',
221
+ detail: `${host}: no known training-on-prompts policy on record (copperhead cannot verify this; check the provider's terms)`,
222
+ });
223
+ const risk = Object.entries(TRAINING_RISK_HOSTS).find(([h]) => host === h || host.endsWith(`.${h}`));
224
+ if (!risk) return noPolicyOnRecord();
225
+ // OpenRouter's documented risk is specific to its `:free`-suffixed models
226
+ // (their own wording), not the host as a whole. Warning on a fully paid
227
+ // OpenRouter model would be a false positive that undermines trust in the
228
+ // other, host-wide warnings (Gemini's applies to its whole free tier).
229
+ if (risk[0] === 'openrouter.ai') {
230
+ const compatModel = model.startsWith('compat:') ? model.slice('compat:'.length) : '';
231
+ if (!compatModel.endsWith(':free')) return noPolicyOnRecord();
232
+ }
233
+ return {
234
+ name: 'privacy',
235
+ status: 'warn',
236
+ detail: `${host}: ${risk[1]}`,
237
+ hint: 'PCB designs are often proprietary. Use a paid tier or a local endpoint for confidential work.',
238
+ };
239
+ }
240
+
241
+ function checkKeyedCredential(model: string, env: NodeJS.ProcessEnv): DoctorCheck {
101
242
  // A pasted API key can end up as the model value (--model sk-..., a stray
102
243
  // COPPERHEAD_MODEL); redact it before it reaches the report, same policy as
103
244
  // transcripts (AC-4.1). Routing below still uses the raw value.
@@ -152,16 +293,20 @@ function providerCheck(
152
293
  ): DoctorCheck {
153
294
  try {
154
295
  const { model } = resolveModel(flag, config, env);
155
- return checkCredential(model, env);
296
+ return checkCredential(model, env, resolveCompatSettings(config, env));
156
297
  } 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.
298
+ // resolveModel throws for two distinct reasons: nothing selects a model at
299
+ // all ("no model configured: ..."), or two-plus credentials are present
300
+ // with nothing to break the tie ("ambiguous: ..."). Strip whichever prefix
301
+ // matched for the hint, and reflect which case it was in the detail so an
302
+ // ambiguous setup does not misreport as "nothing configured".
303
+ const message = (err as Error).message;
304
+ const ambiguous = message.startsWith('ambiguous:');
160
305
  return {
161
306
  name: 'provider',
162
307
  status: 'fail',
163
- detail: 'no model configured',
164
- hint: (err as Error).message.replace(/^no model configured:\s*/, ''),
308
+ detail: ambiguous ? 'ambiguous: multiple credentials, no model selected' : 'no model configured',
309
+ hint: message.replace(/^(no model configured|ambiguous):\s*/, ''),
165
310
  };
166
311
  }
167
312
  }
@@ -222,17 +367,31 @@ export async function runDoctor(opts: RunDoctorOptions): Promise<DoctorReport> {
222
367
  hint: 'check that it is a regular file (not a directory) and that you have permission to read it.',
223
368
  };
224
369
  }
370
+ // Resolving the model can fail (nothing configured); providerCheck reports
371
+ // that, and the compat-only checks simply do not apply in that case.
372
+ const compat = resolveCompatSettings(config, deps.env);
373
+ let resolvedModel: string | null = null;
374
+ try {
375
+ resolvedModel = resolveModel(opts.model, config, deps.env).model;
376
+ } catch {
377
+ resolvedModel = null;
378
+ }
225
379
  const checks: DoctorCheck[] = [
226
380
  nodeCheck(deps.nodeVersion),
227
381
  await kicadCheck(deps.kicadVersion),
228
382
  await gitCheck(deps.gitVersion),
229
383
  providerCheck(opts.model, config, deps.env),
230
- configError ?? projectCheck(config, opts.repoRoot),
231
384
  ];
385
+ if (resolvedModel) {
386
+ const privacy = checkPromptPrivacy(resolvedModel, compat);
387
+ if (privacy) checks.push(privacy);
388
+ }
389
+ checks.push(configError ?? projectCheck(config, opts.repoRoot));
390
+ // `warn` and `info` never block: only a hard failure means "not ready".
232
391
  return { ok: checks.every((c) => c.status !== 'fail'), checks };
233
392
  }
234
393
 
235
- const TAG: Record<DoctorStatus, string> = { ok: '[ok]', fail: '[FAIL]', info: '[info]' };
394
+ const TAG: Record<DoctorStatus, string> = { ok: '[ok]', fail: '[FAIL]', warn: '[warn]', info: '[info]' };
236
395
  const TAG_COL = 2; // leading indent
237
396
  const NAME_COL = TAG_COL + 7; // widest tag "[FAIL]" + one space
238
397
  const DETAIL_COL = NAME_COL + 10; // widest name "kicad-cli" + one space
@@ -242,7 +401,7 @@ const DETAIL_COL = NAME_COL + 10; // widest name "kicad-cli" + one space
242
401
  // tests see plain text. Colored text is padded before painting — escape codes
243
402
  // have zero display width but nonzero string length, so painting first would
244
403
  // break the column math.
245
- const ANSI: Record<DoctorStatus, string> = { ok: '32', fail: '31', info: '36' };
404
+ const ANSI: Record<DoctorStatus, string> = { ok: '32', fail: '31', warn: '33', info: '36' };
246
405
  const DIM = '2';
247
406
  function paint(text: string, code: string, on: boolean): string {
248
407
  return on ? `\u001b[${code}m${text}\u001b[0m` : text;
package/src/config.ts CHANGED
@@ -26,6 +26,18 @@ export interface CopperheadConfig {
26
26
  /** Cache each turn's LLM response to disk and replay it on identical inputs,
27
27
  * so retries/restarts reuse work already paid for. Default on. */
28
28
  llmCache: boolean;
29
+ /**
30
+ * Base URL of an OpenAI-compatible endpoint (Groq, OpenRouter, Gemini's
31
+ * compat endpoint, a local Ollama). Consulted only by the `compat`
32
+ * route (design D2), so a stray value never redirects a plain `gpt-5` run.
33
+ */
34
+ baseURL?: string;
35
+ /**
36
+ * Name of the environment variable holding the compat endpoint's key, e.g.
37
+ * `GROQ_API_KEY`. The *name*, never the key itself: credentials stay in the
38
+ * environment (AC-4.1).
39
+ */
40
+ apiKeyEnv?: string;
29
41
  /** Content hashes of generated docs, for init idempotency (AC-1.4). */
30
42
  generatedHashes?: Record<string, string>;
31
43
  /**
@@ -90,6 +102,8 @@ export async function loadConfig(repoRoot: string): Promise<CopperheadConfig> {
90
102
  ? (raw.maxStageRetries as number)
91
103
  : DEFAULTS.maxStageRetries,
92
104
  llmCache: raw.llmCache !== false,
105
+ ...(typeof raw.baseURL === 'string' && raw.baseURL.trim() ? { baseURL: raw.baseURL.trim() } : {}),
106
+ ...(typeof raw.apiKeyEnv === 'string' && raw.apiKeyEnv.trim() ? { apiKeyEnv: raw.apiKeyEnv.trim() } : {}),
93
107
  ...(raw.generatedHashes ? { generatedHashes: raw.generatedHashes } : {}),
94
108
  ...(raw.origin === 'create' || raw.origin === 'init' ? { origin: raw.origin } : {}),
95
109
  };
@@ -140,9 +154,76 @@ export function resolveModel(flag: string | undefined, config: CopperheadConfig,
140
154
  if (flag) return { model: flag, source: 'flag' };
141
155
  if (env.COPPERHEAD_MODEL) return { model: env.COPPERHEAD_MODEL, source: 'env' };
142
156
  if (config.model) return { model: config.model, source: 'config' };
143
- if (env.OPENAI_API_KEY) return { model: 'gpt-5', source: 'openai-key' };
144
- if (env.ANTHROPIC_API_KEY) return { model: 'claude', source: 'anthropic-key' };
157
+ // Auto-fallback is only safe when exactly one credential is present: guessing
158
+ // is a convenience when there is nothing to guess wrong. With two or more
159
+ // keys set (a common dev setup once a compat endpoint's key sits alongside
160
+ // OPENAI_API_KEY/ANTHROPIC_API_KEY), silently favoring whichever is checked
161
+ // first can send a request to the wrong provider with no signal — including
162
+ // a paid one when a free key was what was actually intended. Refuse instead
163
+ // of guessing; the compat route itself is never a fallback candidate here,
164
+ // since it is opt-in only via an explicit `compat:` prefix (design D2).
165
+ const available: { keyVar: string; model: string; source: ModelSource }[] = [
166
+ ...(env.OPENAI_API_KEY ? [{ keyVar: 'OPENAI_API_KEY', model: 'gpt-5', source: 'openai-key' as const }] : []),
167
+ ...(env.ANTHROPIC_API_KEY ? [{ keyVar: 'ANTHROPIC_API_KEY', model: 'claude', source: 'anthropic-key' as const }] : []),
168
+ ];
169
+ if (available.length === 1) return { model: available[0]!.model, source: available[0]!.source };
170
+ if (available.length > 1) {
171
+ throw new Error(
172
+ `ambiguous: ${available.length} credentials found (${available.map((a) => a.keyVar).join(', ')}) and no model was ` +
173
+ 'selected; pass --model, set COPPERHEAD_MODEL, or set "model" in .copperhead/config.json.',
174
+ );
175
+ }
145
176
  throw new Error(
146
177
  'no model configured: pass --model, set COPPERHEAD_MODEL, or export an API key; see https://docs.copperhead.sh/reference/configuration/',
147
178
  );
148
179
  }
180
+
181
+ /** Where an OpenAI-compatible run points, and which variable holds its key. */
182
+ export interface CompatSettings {
183
+ /** Endpoint base URL; undefined means the client's own default (OpenAI). */
184
+ baseURL?: string;
185
+ /** Name of the env var holding the key. Never the key itself. */
186
+ apiKeyEnv: string;
187
+ }
188
+
189
+ /** The credential variable used when nothing else is configured. */
190
+ export const DEFAULT_API_KEY_ENV = 'OPENAI_API_KEY';
191
+
192
+ /**
193
+ * Resolve the compatible-endpoint settings: environment wins over config, the
194
+ * same direction as `resolveModel`'s chain. These are *settings*, not a
195
+ * provider selector — only the `compat` route reads them (design D1/D2), so an
196
+ * exported COPPERHEAD_BASE_URL never silently redirects a `gpt-5` run.
197
+ */
198
+ export function resolveCompatSettings(config: CopperheadConfig, env = process.env): CompatSettings {
199
+ const baseURL = env.COPPERHEAD_BASE_URL?.trim() || config.baseURL?.trim();
200
+ const apiKeyEnv = env.COPPERHEAD_API_KEY_ENV?.trim() || config.apiKeyEnv?.trim() || DEFAULT_API_KEY_ENV;
201
+ return { ...(baseURL ? { baseURL } : {}), apiKeyEnv };
202
+ }
203
+
204
+ /**
205
+ * True when a resolved model id routes through the `compat` provider (D1/D2).
206
+ * The single source of truth for that gate: `makeProvider` uses it to decide
207
+ * whether to consult `CompatSettings` at all, and the response cache (loop.ts)
208
+ * uses it to decide whether a run's cache key may depend on `baseURL` — a
209
+ * non-compat run (gpt-5, claude, ...) never reads COPPERHEAD_BASE_URL, so its
210
+ * cache key must not vary with it either, or every entry gets orphaned each
211
+ * time the endpoint used for unrelated compat testing changes.
212
+ */
213
+ export function isCompatModel(model: string): boolean {
214
+ return model === 'compat' || model.startsWith('compat:');
215
+ }
216
+
217
+ /**
218
+ * True when the endpoint is loopback, i.e. a local server such as Ollama.
219
+ * Those need no credential (design D4); a remote endpoint always does.
220
+ */
221
+ export function isLocalEndpoint(baseURL: string | undefined): boolean {
222
+ if (!baseURL) return false;
223
+ try {
224
+ const h = new URL(baseURL).hostname.toLowerCase();
225
+ return h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '[::1]' || h.endsWith('.local');
226
+ } catch {
227
+ return false; // an unparseable URL is not a local endpoint; the run fails later with a clearer error
228
+ }
229
+ }
package/src/kicad/cli.ts CHANGED
@@ -4,7 +4,7 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { normalizeReport, type CheckReport } from './report.js';
7
- import { PreflightError } from '../util/preflight.js';
7
+ import { PreflightError, isNotFoundError } from '../util/preflight.js';
8
8
 
9
9
  export class KicadCliMissingError extends PreflightError {
10
10
  constructor() {
@@ -109,7 +109,7 @@ function fallbackAfterMissing(): string {
109
109
  async function runKicad(args: string[], opts?: { reject?: boolean }): Promise<Awaited<ReturnType<typeof execa>>> {
110
110
  let bin = resolveKicadCli();
111
111
  let res = await execa(bin, args, { reject: false });
112
- if (res.failed && (res as unknown as ExecaError).code === 'ENOENT') {
112
+ if (res.failed && isNotFoundError(res)) {
113
113
  if (bin === 'kicad-cli') {
114
114
  bin = fallbackAfterMissing();
115
115
  res = await execa(bin, args, { reject: false });
@@ -117,10 +117,10 @@ async function runKicad(args: string[], opts?: { reject?: boolean }): Promise<Aw
117
117
  throw new KicadCliMissingError();
118
118
  }
119
119
  }
120
- if (opts?.reject === false) return res;
121
- if (res.failed && (res as unknown as ExecaError).code === 'ENOENT') {
120
+ if (res.failed && isNotFoundError(res)) {
122
121
  throw new KicadCliMissingError();
123
122
  }
123
+ if (opts?.reject === false) return res;
124
124
  if (res.failed) {
125
125
  throw Object.assign(new Error(res.stderr || res.stdout || `kicad-cli exited ${res.exitCode}`), res);
126
126
  }
@@ -143,15 +143,8 @@ export function setKicadFallbackBinaries(paths?: readonly string[]): void {
143
143
  }
144
144
 
145
145
  export async function kicadCliVersion(): Promise<string> {
146
- try {
147
- const res = await runKicad(['version']);
148
- return String(res.stdout ?? '').trim();
149
- } catch (err) {
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;
153
- throw err;
154
- }
146
+ const res = await runKicad(['version']);
147
+ return String(res.stdout ?? '').trim();
155
148
  }
156
149
 
157
150
  async function runCheck(
@@ -167,9 +160,6 @@ async function runCheck(
167
160
  [...sub, '--format', 'json', '--exit-code-violations', '--output', out, ...extraArgs, filePath],
168
161
  { reject: false },
169
162
  );
170
- if (res.failed && (res as unknown as ExecaError).code === 'ENOENT') {
171
- throw new KicadCliMissingError();
172
- }
173
163
  let raw: unknown;
174
164
  try {
175
165
  raw = JSON.parse(await readFile(out, 'utf8'));
@@ -218,7 +208,6 @@ export async function kicadLoadError(filePath: string): Promise<string | null> {
218
208
  : ['pcb', 'export', 'pos', '--output', path.join(dir, 'probe.pos'), filePath];
219
209
  try {
220
210
  const res = await runKicad(args, { reject: false });
221
- if (res.failed && (res as unknown as ExecaError).code === 'ENOENT') throw new KicadCliMissingError();
222
211
  if (res.exitCode === 0) return null;
223
212
  return [res.stderr, res.stdout].filter(Boolean).join('\n').trim() || `kicad-cli exited ${res.exitCode}`;
224
213
  } finally {
@@ -258,7 +247,6 @@ export async function exportFab(pcbPath: string, schPath: string | null, outDir:
258
247
  result.produced.push(job.artifact);
259
248
  } catch (err) {
260
249
  if (err instanceof KicadCliMissingError) throw err;
261
- if ((err as ExecaError).code === 'ENOENT') throw new KicadCliMissingError();
262
250
  result.failed.push({ artifact: job.artifact, reason: String((err as ExecaError).stderr ?? (err as Error).message).slice(0, 200) });
263
251
  }
264
252
  }
@@ -275,7 +263,6 @@ export async function exportSvg(kind: 'sch' | 'pcb', filePath: string, outDir: s
275
263
  await runKicad(args);
276
264
  } catch (err) {
277
265
  if (err instanceof KicadCliMissingError) throw err;
278
- if ((err as ExecaError).code === 'ENOENT') throw new KicadCliMissingError();
279
266
  throw err;
280
267
  }
281
268
  return outDir;