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.
- package/README.md +9 -1
- package/dist/agent/loop.js +38 -4
- package/dist/agent/loop.js.map +1 -1
- package/dist/agent/providers/openai.js +28 -6
- package/dist/agent/providers/openai.js.map +1 -1
- package/dist/agent/response-cache.js +18 -2
- package/dist/agent/response-cache.js.map +1 -1
- package/dist/cli.js +2 -2
- package/dist/cli.js.map +1 -1
- package/dist/commands/create.js +113 -9
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/doctor.js +164 -11
- package/dist/commands/doctor.js.map +1 -1
- package/dist/config.js +60 -4
- package/dist/config.js.map +1 -1
- package/dist/kicad/cli.js +7 -26
- package/dist/kicad/cli.js.map +1 -1
- package/dist/kicad/sexp.js +23 -4
- package/dist/kicad/sexp.js.map +1 -1
- package/dist/memory/bom-table.js +48 -10
- package/dist/memory/bom-table.js.map +1 -1
- package/dist/openspec/cli.js +2 -1
- package/dist/openspec/cli.js.map +1 -1
- package/dist/util/preflight.js +17 -0
- package/dist/util/preflight.js.map +1 -1
- package/dist/util/redact.js +6 -2
- package/dist/util/redact.js.map +1 -1
- package/package.json +2 -5
- package/src/agent/loop.ts +60 -4
- package/src/agent/providers/openai.ts +38 -4
- package/src/agent/response-cache.ts +17 -1
- package/src/cli.ts +2 -2
- package/src/commands/create.ts +107 -10
- package/src/commands/doctor.ts +171 -12
- package/src/config.ts +83 -2
- package/src/kicad/cli.ts +6 -19
- package/src/kicad/sexp.ts +24 -4
- package/src/memory/bom-table.ts +51 -10
- package/src/openspec/cli.ts +3 -2
- package/src/util/preflight.ts +18 -0
- package/src/util/redact.ts +6 -2
package/src/commands/create.ts
CHANGED
|
@@ -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) =>
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
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,
|
package/src/commands/doctor.ts
CHANGED
|
@@ -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 {
|
|
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(
|
|
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
|
|
158
|
-
//
|
|
159
|
-
//
|
|
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:
|
|
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
|
-
|
|
144
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
147
|
-
|
|
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;
|