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