copperhead 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +36 -4
  2. package/dist/agent/animate.js +76 -0
  3. package/dist/agent/animate.js.map +1 -0
  4. package/dist/agent/box.js +89 -0
  5. package/dist/agent/box.js.map +1 -0
  6. package/dist/agent/dock-renderer.js +173 -0
  7. package/dist/agent/dock-renderer.js.map +1 -0
  8. package/dist/agent/logo.js +21 -0
  9. package/dist/agent/logo.js.map +1 -0
  10. package/dist/agent/loop.js +15 -5
  11. package/dist/agent/loop.js.map +1 -1
  12. package/dist/agent/providers/claude-code.js +1 -212
  13. package/dist/agent/providers/claude-code.js.map +1 -1
  14. package/dist/agent/providers/cursor.js +317 -0
  15. package/dist/agent/providers/cursor.js.map +1 -0
  16. package/dist/agent/providers/tool-protocol.js +205 -0
  17. package/dist/agent/providers/tool-protocol.js.map +1 -0
  18. package/dist/agent/render.js +32 -15
  19. package/dist/agent/render.js.map +1 -1
  20. package/dist/agent/runmeta.js +4 -5
  21. package/dist/agent/runmeta.js.map +1 -1
  22. package/dist/agent/theme.js +84 -0
  23. package/dist/agent/theme.js.map +1 -0
  24. package/dist/cli.js +134 -13
  25. package/dist/cli.js.map +1 -1
  26. package/dist/commands/create.js +41 -32
  27. package/dist/commands/create.js.map +1 -1
  28. package/dist/commands/demo.js +146 -0
  29. package/dist/commands/demo.js.map +1 -0
  30. package/dist/commands/doctor.js +240 -0
  31. package/dist/commands/doctor.js.map +1 -0
  32. package/dist/commands/repl-inspect.js +342 -0
  33. package/dist/commands/repl-inspect.js.map +1 -0
  34. package/dist/commands/repl.js +618 -0
  35. package/dist/commands/repl.js.map +1 -0
  36. package/dist/config.js +5 -2
  37. package/dist/config.js.map +1 -1
  38. package/dist/kicad/cli.js +126 -6
  39. package/dist/kicad/cli.js.map +1 -1
  40. package/dist/util/cli-args.js +35 -0
  41. package/dist/util/cli-args.js.map +1 -0
  42. package/dist/util/dock.js +155 -0
  43. package/dist/util/dock.js.map +1 -0
  44. package/dist/util/git.js +129 -4
  45. package/dist/util/git.js.map +1 -1
  46. package/dist/util/live-prompt.js +542 -0
  47. package/dist/util/live-prompt.js.map +1 -0
  48. package/dist/util/paths.js +9 -0
  49. package/dist/util/paths.js.map +1 -1
  50. package/dist/util/select.js +172 -0
  51. package/dist/util/select.js.map +1 -0
  52. package/package.json +3 -2
  53. package/src/agent/animate.ts +90 -0
  54. package/src/agent/box.ts +99 -0
  55. package/src/agent/dock-renderer.ts +181 -0
  56. package/src/agent/logo.ts +23 -0
  57. package/src/agent/loop.ts +15 -5
  58. package/src/agent/providers/claude-code.ts +2 -216
  59. package/src/agent/providers/cursor.ts +364 -0
  60. package/src/agent/providers/tool-protocol.ts +212 -0
  61. package/src/agent/render.ts +33 -16
  62. package/src/agent/runmeta.ts +6 -7
  63. package/src/agent/theme.ts +91 -0
  64. package/src/cli.ts +139 -15
  65. package/src/commands/create.ts +81 -30
  66. package/src/commands/demo.ts +184 -0
  67. package/src/commands/doctor.ts +289 -0
  68. package/src/commands/repl-inspect.ts +353 -0
  69. package/src/commands/repl.ts +685 -0
  70. package/src/config.ts +6 -3
  71. package/src/kicad/cli.ts +132 -7
  72. package/src/layout/claude-ui-layout.md +72 -0
  73. package/src/layout/repl-ui-layout.md +139 -0
  74. package/src/util/cli-args.ts +42 -0
  75. package/src/util/dock.ts +161 -0
  76. package/src/util/git.ts +140 -4
  77. package/src/util/live-prompt.ts +595 -0
  78. package/src/util/paths.ts +10 -0
  79. package/src/util/select.ts +192 -0
@@ -0,0 +1,212 @@
1
+ import type { Msg, ToolCall, ToolSchema } from '../types.js';
2
+
3
+ export function renderToolProtocol(tools: ToolSchema[]): string {
4
+ if (!tools.length) return '';
5
+ const lines = [
6
+ '# Tool protocol',
7
+ '',
8
+ 'You are the reasoning half of a tool-driven workflow; you cannot run anything yourself.',
9
+ 'To take an action, reply with EXACTLY ONE JSON object and nothing else, wrapped in a',
10
+ '```json fenced code block:',
11
+ '',
12
+ '```json',
13
+ '{"tool": "<tool_name>", "args": { ... }}',
14
+ '```',
15
+ '',
16
+ 'Use only the tools listed below, with `args` matching the tool\'s JSON Schema. If you have',
17
+ 'no tool to call and only want to say something, reply with plain prose and no JSON block.',
18
+ '',
19
+ '## Available tools',
20
+ ];
21
+ for (const t of tools) {
22
+ lines.push(
23
+ '',
24
+ `### ${t.name}`,
25
+ t.description,
26
+ `Parameters (JSON Schema): ${JSON.stringify(t.parameters)}`,
27
+ );
28
+ }
29
+ return lines.join('\n');
30
+ }
31
+
32
+ /** Delta prompt for a resumed CLI session: new user lines and tool results only. */
33
+ export function renderDelta(messages: Msg[], from: number): string {
34
+ const idToName = new Map<string, string>();
35
+ for (const m of messages) {
36
+ if (m.role === 'assistant') for (const call of m.toolCalls ?? []) idToName.set(call.id, call.name);
37
+ }
38
+ const parts: string[] = [];
39
+ for (const m of messages.slice(Math.max(0, from))) {
40
+ if (m.role === 'user') {
41
+ parts.push(`[user]\n${m.content}`);
42
+ } else if (m.role === 'tool') {
43
+ const name = idToName.get(m.toolCallId) ?? m.toolCallId;
44
+ parts.push(`[result of ${name}]\n${m.content}`);
45
+ }
46
+ }
47
+ return parts.join('\n\n');
48
+ }
49
+
50
+ export function renderConversation(messages: Msg[]): string {
51
+ const idToName = new Map<string, string>();
52
+ const parts: string[] = [];
53
+ for (const m of messages) {
54
+ if (m.role === 'system') continue;
55
+ if (m.role === 'user') {
56
+ parts.push(`[user]\n${m.content}`);
57
+ } else if (m.role === 'assistant') {
58
+ if (m.content) parts.push(`[assistant]\n${m.content}`);
59
+ for (const call of m.toolCalls ?? []) {
60
+ idToName.set(call.id, call.name);
61
+ parts.push(
62
+ `[assistant tool call]\n\`\`\`json\n${JSON.stringify({ tool: call.name, args: call.args })}\n\`\`\``,
63
+ );
64
+ }
65
+ } else {
66
+ const name = idToName.get(m.toolCallId) ?? m.toolCallId;
67
+ parts.push(`[result of ${name}]\n${m.content}`);
68
+ }
69
+ }
70
+ return parts.join('\n\n');
71
+ }
72
+
73
+ export interface ParsedToolTurn {
74
+ text: string | null;
75
+ toolCalls: ToolCall[];
76
+ nudge?: string;
77
+ }
78
+
79
+ /**
80
+ * Detect a malformed-but-intended tool call in a turn that dispatched none
81
+ * (#I10). The signature is machine-recognizable: the text contains
82
+ * `"tool":"<name>"` naming a tool in the current catalog, yet nothing parsed.
83
+ * That is the exact case where the tolerant extractor's silence misleads the
84
+ * model — the JSON was near-miss malformed (a brace short, or the outer object
85
+ * split so only an inner `{args}` with no `tool` key balanced), not the tool
86
+ * being broken. Returns a one-line steer to re-emit it, or undefined when the
87
+ * absence of a call is genuine (plain prose, no tool named).
88
+ */
89
+ function detectMalformedCall(text: string, catalog: Set<string>): string | undefined {
90
+ const re = /"tool"\s*:\s*"([^"]+)"/g;
91
+ let m: RegExpExecArray | null;
92
+ while ((m = re.exec(text)) !== null) {
93
+ const name = m[1]!;
94
+ if (catalog.has(name)) {
95
+ return (
96
+ `A tool call for "${name}" looks malformed — it named the tool but did not parse as ` +
97
+ 'valid JSON (likely unbalanced braces or a missing closing brace), so no call ran. ' +
98
+ 'Re-emit it as exactly one complete JSON object: {"tool": "...", "args": { ... }}.'
99
+ );
100
+ }
101
+ }
102
+ return undefined;
103
+ }
104
+
105
+ /**
106
+ * Extract tool-call JSON from the model's reply. Tolerant by design (D1):
107
+ * unparseable output is returned as plain text with no tool calls rather than
108
+ * throwing, so a non-conforming turn degrades to the loop's stall/nudge path.
109
+ * A parsed block only counts as a tool call when its name is in the current
110
+ * turn's catalog (`availableTools(ctx)`): a hallucinated or locked tool name is
111
+ * left as prose so the loop nudges, rather than dispatching a bogus call.
112
+ */
113
+ export function parseToolCalls(
114
+ text: string | null,
115
+ nextId: () => string,
116
+ catalog: Set<string>,
117
+ ): ParsedToolTurn {
118
+ if (!text) return { text: null, toolCalls: [] };
119
+ const toolCalls: ToolCall[] = [];
120
+ const matched: Array<[number, number]> = [];
121
+
122
+ // Extract tool calls by scanning for complete JSON objects, NOT by matching
123
+ // ``` fences. A tool call's `content`/`args` can hold a full markdown doc that
124
+ // itself contains ``` code fences; a fence regex truncates the JSON at the
125
+ // first inner fence, JSON.parse fails, and the call is silently dropped (the
126
+ // model then assumes it wrote a file it never did). The brace scan is
127
+ // string-aware, so braces and backticks inside JSON string values are ignored.
128
+ let searchFrom = 0;
129
+ while (searchFrom < text.length) {
130
+ const braceAt = text.indexOf('{', searchFrom);
131
+ if (braceAt < 0) break;
132
+ const span = scanJsonObject(text, braceAt);
133
+ if (!span) {
134
+ // Unbalanced '{' (stray brace in prose): retry from the next candidate so
135
+ // one bad brace can't hide a well-formed call later in the reply.
136
+ searchFrom = braceAt + 1;
137
+ continue;
138
+ }
139
+ const call = toToolCall(text.slice(span.start, span.end), nextId, catalog);
140
+ if (call) {
141
+ toolCalls.push(call);
142
+ matched.push([span.start, span.end]);
143
+ }
144
+ searchFrom = span.end;
145
+ }
146
+
147
+ if (!toolCalls.length) {
148
+ // No call dispatched — but did the model clearly *intend* one? A fenced
149
+ // ```json block that names a catalog tool yet produced zero calls is a
150
+ // malformed near-miss (unbalanced braces, a missing `}`, or an inner object
151
+ // with no `tool` key). Silently dropping it gives the model no signal, so it
152
+ // misreads "no result" as "this tool is broken" and can bake that false
153
+ // conclusion into a committed summary (#I10). Surface a nudge instead.
154
+ return { text: text.trim() ? text : null, toolCalls, nudge: detectMalformedCall(text, catalog) };
155
+ }
156
+
157
+ // Prose is whatever survives once the tool-call objects (and any now-empty
158
+ // ```json fences around them) are removed.
159
+ let prose = '';
160
+ let cursor = 0;
161
+ for (const [start, end] of matched) {
162
+ prose += text.slice(cursor, start);
163
+ cursor = end;
164
+ }
165
+ prose += text.slice(cursor);
166
+ prose = prose.replace(/```(?:json)?\s*```/gi, '').replace(/```(?:json)?\s*$/gi, '').trim();
167
+ return { text: prose.length ? prose : null, toolCalls };
168
+ }
169
+
170
+ /**
171
+ * Find the first complete, brace-balanced JSON object at or after `from`,
172
+ * respecting JSON string quoting/escaping so braces or backticks inside string
173
+ * values do not end the scan. Returns its `[start, end)` bounds or null.
174
+ */
175
+ function scanJsonObject(text: string, from: number): { start: number; end: number } | null {
176
+ const start = text.indexOf('{', from);
177
+ if (start < 0) return null;
178
+ let depth = 0;
179
+ let inStr = false;
180
+ let esc = false;
181
+ for (let i = start; i < text.length; i++) {
182
+ const ch = text[i];
183
+ if (inStr) {
184
+ if (esc) esc = false;
185
+ else if (ch === '\\') esc = true;
186
+ else if (ch === '"') inStr = false;
187
+ continue;
188
+ }
189
+ if (ch === '"') inStr = true;
190
+ else if (ch === '{') depth++;
191
+ else if (ch === '}' && --depth === 0) return { start, end: i + 1 };
192
+ }
193
+ return null;
194
+ }
195
+
196
+ function toToolCall(raw: string | undefined, nextId: () => string, catalog: Set<string>): ToolCall | null {
197
+ if (!raw) return null;
198
+ let obj: unknown;
199
+ try {
200
+ obj = JSON.parse(raw.trim());
201
+ } catch {
202
+ return null;
203
+ }
204
+ if (!obj || typeof obj !== 'object') return null;
205
+ const rec = obj as Record<string, unknown>;
206
+ if (typeof rec.tool !== 'string') return null;
207
+ // Only accept names the turn actually advertised. An empty catalog means the
208
+ // turn offered no tools, so nothing parses as a call.
209
+ if (!catalog.has(rec.tool)) return null;
210
+ const args = rec.args && typeof rec.args === 'object' ? (rec.args as Record<string, unknown>) : {};
211
+ return { id: nextId(), name: rec.tool, args };
212
+ }
@@ -3,8 +3,13 @@
3
3
  * once at startup: interactive (TTY, no --json/--plain) pins a status line to
4
4
  * the bottom of the terminal and redraws it in place; plain emits line-oriented
5
5
  * output with zero ANSI escapes — the mode CI, pipes, and tests see.
6
+ *
7
+ * Interactive mode adds subtle SGR chrome (copper accent, dim secondary text).
8
+ * Plain mode never emits color (AC-8.9).
6
9
  */
7
10
 
11
+ import { copper, dim, setColorEnabled, styleOutcome, toolLine, warn } from './theme.js';
12
+
8
13
  export interface ProgressRenderer {
9
14
  log(line: string): void;
10
15
  /** Called at the start of each turn with cumulative token totals so far. */
@@ -106,20 +111,29 @@ export class InteractiveRenderer implements ProgressRenderer {
106
111
  }
107
112
 
108
113
  private statusText(): string {
109
- const parts = [
110
- `turn ${this.turn}/${this.maxTurns}`,
111
- `${fmtTokens(this.tokensIn)} in / ${fmtTokens(this.tokensOut)} out`,
112
- fmtDuration(Date.now() - this.startMs),
113
- ];
114
- if (this.busy) {
115
- // Fold streamed-output volume into the busy segment so a large turn's
116
- // status line visibly grows — a hung one stays frozen (5.1).
117
- parts.push(this.streamedChars ? `${this.busy} ~${fmtTokens(this.streamedChars)} ch` : this.busy);
118
- }
119
- const spinner = this.busy ? FRAMES[this.frame % FRAMES.length] : '·';
120
- const line = `${spinner} ${parts.join(' · ')}`;
114
+ // Raw (uncolored) segments once; both the colored line and the
115
+ // narrow-terminal fallback are assembled from these.
116
+ const spinner = this.busy ? FRAMES[this.frame % FRAMES.length]! : '·';
117
+ const turn = `turn ${this.turn}/${this.maxTurns}`;
118
+ const tokens = `${fmtTokens(this.tokensIn)} in / ${fmtTokens(this.tokensOut)} out`;
119
+ const elapsed = fmtDuration(Date.now() - this.startMs);
120
+ // Fold streamed-output volume into the busy segment so a large turn's
121
+ // status line visibly grows — a hung one stays frozen (5.1).
122
+ const busy = this.busy
123
+ ? this.streamedChars
124
+ ? `${this.busy} ~${fmtTokens(this.streamedChars)} ch`
125
+ : this.busy
126
+ : undefined;
127
+ const parts = [turn, dim(tokens), dim(elapsed), ...(busy ? [warn(busy)] : [])];
128
+ const line = `${this.busy ? copper(spinner) : dim(spinner)} ${parts.join(dim(' · '))}`;
129
+ // Truncate by visible length roughly: strip SGR when measuring so color
130
+ // codes don't eat the column budget and clip the readable text early.
121
131
  const width = this.out.columns ?? 80;
122
- return line.length > width ? line.slice(0, width - 1) : line;
132
+ const visible = line.replace(/\x1b\[[0-9;]*m/g, '');
133
+ if (visible.length <= width) return line;
134
+ // Fall back to an uncolored truncated line when the terminal is too narrow.
135
+ const plain = [spinner, turn, tokens, elapsed, ...(busy ? [busy] : [])].join(' · ');
136
+ return plain.length > width ? plain.slice(0, width - 1) : plain;
123
137
  }
124
138
 
125
139
  private redraw(): void {
@@ -162,7 +176,7 @@ export class InteractiveRenderer implements ProgressRenderer {
162
176
  }
163
177
 
164
178
  toolResult(name: string, firstLine: string): void {
165
- this.log(` [${name}] ${firstLine}`);
179
+ this.log(toolLine(name, firstLine));
166
180
  }
167
181
 
168
182
  status(text: string | null): void {
@@ -181,7 +195,7 @@ export class InteractiveRenderer implements ProgressRenderer {
181
195
 
182
196
  finish(line: string): void {
183
197
  if (this.statusShown) this.out.write(CLEAR_LINE);
184
- this.out.write(line + '\n');
198
+ this.out.write(styleOutcome(line) + '\n');
185
199
  this.suspend();
186
200
  }
187
201
 
@@ -215,7 +229,10 @@ export class InteractiveRenderer implements ProgressRenderer {
215
229
  * (AC-2.4): the only thing a --json invocation writes to stdout is its JSON.
216
230
  */
217
231
  export function makeRenderer(opts: { json: boolean; plain: boolean }): ProgressRenderer {
232
+ const interactive = !opts.json && !opts.plain && Boolean(process.stdout.isTTY);
233
+ // Color tracks the interactive path so --plain / pipes stay zero-ANSI (AC-8.9).
234
+ setColorEnabled(interactive && !process.env.NO_COLOR);
218
235
  if (opts.json) return plainRenderer((line) => console.error(line));
219
- if (!opts.plain && process.stdout.isTTY) return new InteractiveRenderer();
236
+ if (interactive) return new InteractiveRenderer();
220
237
  return plainRenderer((line) => console.log(line));
221
238
  }
@@ -9,7 +9,7 @@ import type { CopperheadConfig, ModelSource } from '../config.js';
9
9
 
10
10
  /** Caller-supplied run identity: facts the loop cannot probe for itself. */
11
11
  export interface RunMetaInput {
12
- command?: 'do' | 'create' | 'sync';
12
+ command?: 'do' | 'create' | 'sync' | 'repl';
13
13
  modelSource?: ModelSource;
14
14
  version?: string;
15
15
  kicadCliVersion?: string;
@@ -29,7 +29,7 @@ export interface RunMeta {
29
29
  modelSource: ModelSource | null;
30
30
  runId: string;
31
31
  startedAt: string;
32
- command: 'do' | 'create' | 'sync' | null;
32
+ command: 'do' | 'create' | 'sync' | 'repl' | null;
33
33
  interactive: boolean;
34
34
  stage: { name: string; index: number; total: number } | null;
35
35
  brief: { path: string; sha256: string } | null;
@@ -150,14 +150,14 @@ export async function collectRunMeta(opts: CollectRunMetaOptions): Promise<RunMe
150
150
 
151
151
  const unk = (v: string | null | undefined): string => v ?? 'unknown';
152
152
 
153
- /** ≤ 2 lines, printed before the first turn (AC-8.4). */
153
+ /** ≤ 2 lines, printed before the first turn (AC-8.4).
154
+ * Live header stays compact: install path / platform live in summary.md only. */
154
155
  export function renderCliHeader(meta: RunMeta): string[] {
155
156
  const v = meta.versions;
156
157
  const line1 = [
157
- `copperhead v${unk(v.copperhead)}${v.installPath ? ` (${v.installPath})` : ''}`,
158
+ `copperhead v${unk(v.copperhead)}`,
158
159
  `kicad-cli ${unk(v.kicadCli)}`,
159
160
  `node ${v.node}`,
160
- v.platform,
161
161
  ].join(' · ');
162
162
 
163
163
  const repoState =
@@ -167,12 +167,11 @@ export function renderCliHeader(meta: RunMeta): string[] {
167
167
  ? `dirty(${meta.git.uncommittedFiles})`
168
168
  : 'clean';
169
169
  const line2 = [
170
- `run ${meta.runId}`,
171
170
  unk(meta.command),
172
171
  ...(meta.stage ? [`stage ${meta.stage.name} (${meta.stage.index}/${meta.stage.total})`] : []),
173
172
  `model ${meta.model} (${meta.provider}, via ${unk(meta.modelSource)})`,
174
173
  `turns ≤${meta.config.maxTurns}`,
175
- `repo ${unk(meta.git.branch)}@${meta.git.commit?.slice(0, 7) ?? 'unknown'} ${repoState}`,
174
+ `${unk(meta.git.branch)}@${meta.git.commit?.slice(0, 7) ?? 'unknown'} ${repoState}`,
176
175
  ].join(' · ');
177
176
  return [line1, line2];
178
177
  }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Subtle interactive-TTY chrome: muted hierarchy with a copper accent.
3
+ * Plain / --json / piped output must stay free of SGR (AC-8.9), so helpers
4
+ * no-op unless color has been explicitly enabled for this process.
5
+ */
6
+
7
+ const ESC = '\x1b[';
8
+ const RESET = `${ESC}0m`;
9
+
10
+ /** True after makeRenderer selects the interactive path. */
11
+ let colorEnabled = false;
12
+
13
+ export function setColorEnabled(on: boolean): void {
14
+ colorEnabled = on;
15
+ }
16
+
17
+ export function isColorEnabled(): boolean {
18
+ return colorEnabled;
19
+ }
20
+
21
+ function paint(code: string, s: string): string {
22
+ if (!colorEnabled || s === '') return s;
23
+ return `${ESC}${code}m${s}${RESET}`;
24
+ }
25
+
26
+ const TRUECOLOR = /truecolor|24bit/i.test(process.env.COLORTERM ?? '');
27
+
28
+ /** Secondary metadata — soft gray #999999 (truecolor), SGR 90 fallback. */
29
+ export const dim = (s: string): string => paint(TRUECOLOR ? '38;2;153;153;153' : '90', s);
30
+ /** Rules/separators — one step darker than dim: #888888. */
31
+ export const ruleDim = (s: string): string => paint(TRUECOLOR ? '38;2;136;136;136' : '90', s);
32
+ /** Primary content — bright white (typed input, key values). */
33
+ export const bright = (s: string): string => paint('97', s);
34
+
35
+ /** Exact brand copper #b87333 where truecolor is available; warm 256 fallback. */
36
+ const COPPER_SGR = TRUECOLOR ? '38;2;184;115;51' : '38;5;173';
37
+ /** Light copper tint (brand accent-high #eec9a5) — menu hover. */
38
+ export const copperLight = (s: string): string =>
39
+ paint(TRUECOLOR ? '38;2;238;201;165' : '38;5;223', s);
40
+ /** Title emphasis — bold in the terminal's default foreground (theme-adaptive). */
41
+ export const bold = (s: string): string => paint('1', s);
42
+ /** Brand / active accent — exact copper #b87333 (truecolor), 256-color 173 fallback. */
43
+ export const copper = (s: string): string => paint(COPPER_SGR, s);
44
+ /** Success — PCB green. */
45
+ export const ok = (s: string): string => paint('32', s);
46
+ /** Busy / caution — amber. */
47
+ export const warn = (s: string): string => paint('33', s);
48
+ /** Failure. */
49
+ export const err = (s: string): string => paint('31', s);
50
+
51
+ /**
52
+ * Style a create-pipeline stage line. Keeps the `stage <name>:` prefix so
53
+ * existing log greps and operator muscle memory still work.
54
+ */
55
+ export function stageLine(name: string, detail: string, kind: 'info' | 'ok' | 'warn' | 'err' = 'info'): string {
56
+ const label = dim(`stage ${name}:`);
57
+ const body =
58
+ kind === 'ok' ? ok(detail) : kind === 'warn' ? warn(detail) : kind === 'err' ? err(detail) : detail;
59
+ return `${label} ${body}`;
60
+ }
61
+
62
+ /** Tool-result scrollback line: short glyph + name + first line of result. */
63
+ export function toolLine(name: string, firstLine: string): string {
64
+ const clean = /\b(clean|ok|pass(?:ed)?|success|done)\b/i.test(firstLine) && !/\b(fail|error|violat)/i.test(firstLine);
65
+ const glyph = clean ? ok('✓') : copper('▸');
66
+ return ` ${glyph} ${dim(name)} ${firstLine}`;
67
+ }
68
+
69
+ /** Color the final outcome line from its exit-path token. */
70
+ export function styleOutcome(line: string): string {
71
+ if (!colorEnabled) return line;
72
+ const head = line.split(' · ')[0] ?? line;
73
+ const rest = line.slice(head.length);
74
+ if (head === 'done') return ok(head) + dim(rest);
75
+ if (/refus|fail|error|exhaust|stall/i.test(head)) return err(head) + dim(rest);
76
+ return copper(head) + dim(rest);
77
+ }
78
+
79
+ /** Dim secondary segments of the two-line CLI header (brand stays copper). */
80
+ export function styleHeaderLines(lines: string[]): string[] {
81
+ if (!colorEnabled) return lines;
82
+ return lines.map((line, i) => {
83
+ if (i === 0) {
84
+ // copperhead vX · rest…
85
+ const m = line.match(/^(copperhead v\S+)(.*)$/);
86
+ if (!m) return dim(line);
87
+ return copper(m[1]!) + dim(m[2]!);
88
+ }
89
+ return dim(line);
90
+ });
91
+ }
package/src/cli.ts CHANGED
@@ -1,13 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
- import path from 'node:path';
4
3
  import { createRequire } from 'node:module';
5
4
  import { createInterface } from 'node:readline/promises';
6
- import { loadConfig, resolveModel } from './config.js';
5
+ import { loadConfig, resolveModel, type ModelSource } from './config.js';
6
+ import { pickModel } from './util/select.js';
7
7
  import { runInit, InitError } from './memory/scaffold.js';
8
8
  import { runCheck } from './commands/check.js';
9
+ import { runDoctor, formatDoctor } from './commands/doctor.js';
9
10
  import { syncVerify, syncResolve, formatSyncReport } from './commands/sync.js';
10
11
  import { runCreate } from './commands/create.js';
12
+ import { runDemo, demoTourText } from './commands/demo.js';
13
+ import { runRepl } from './commands/repl.js';
11
14
  import {
12
15
  runExportBom,
13
16
  parseSupplier,
@@ -20,6 +23,7 @@ import { runAgentLoop, type BudgetExhaustedStats } from './agent/loop.js';
20
23
  import { makeRenderer } from './agent/render.js';
21
24
  import { kicadCliVersion } from './kicad/cli.js';
22
25
  import { loadEnvFile } from './util/env.js';
26
+ import { budgetExtraTurns, budgetPromptText, parseMaxTurns, repoOf } from './util/cli-args.js';
23
27
 
24
28
  // Read .env from the working directory before any command resolves a model or a
25
29
  // provider. Loaded here rather than per-command so `check` behaves identically,
@@ -35,8 +39,6 @@ const { version } = createRequire(import.meta.url)('../package.json') as { versi
35
39
 
36
40
  const program = new Command();
37
41
 
38
- const repoOf = (opts: { repo?: string }): string => path.resolve(opts.repo ?? process.cwd());
39
-
40
42
  async function confirmTty(question: string): Promise<boolean> {
41
43
  const rl = createInterface({ input: process.stdin, output: process.stdout });
42
44
  const answer = await rl.question(`${question} [y/N] `);
@@ -50,14 +52,8 @@ async function confirmTty(question: string): Promise<boolean> {
50
52
  */
51
53
  function budgetContinuePrompt(): ((stats: BudgetExhaustedStats) => Promise<number>) | undefined {
52
54
  if (!process.stdin.isTTY || !process.stdout.isTTY) return undefined;
53
- return async (stats) => {
54
- // ceil of the ORIGINAL budget (design D1), so repeat extensions offer the
55
- // same increment instead of escalating with the extended turn count.
56
- const extra = Math.ceil(stats.maxTurns / 2);
57
- const k = (n: number) => `${(n / 1000).toFixed(1)}k`;
58
- const q = `Turn budget exhausted (${stats.turnsUsed} turns, ${k(stats.tokensIn)} in / ${k(stats.tokensOut)} out, ${stats.filesTouched.length} file(s) touched, ${stats.openObligations} open obligation(s)). Continue with ${extra} more turns?`;
59
- return (await confirmTty(q)) ? extra : 0;
60
- };
55
+ return async (stats) =>
56
+ (await confirmTty(budgetPromptText(stats))) ? budgetExtraTurns(stats) : 0;
61
57
  }
62
58
 
63
59
  program
@@ -71,6 +67,68 @@ program
71
67
  const rendererOf = () =>
72
68
  makeRenderer({ json: Boolean(program.opts().json), plain: Boolean(program.opts().plain) });
73
69
 
70
+ program
71
+ .command('repl', { isDefault: true })
72
+ .description('interactive agent shell (default when no command is given)')
73
+ .argument('[request...]', 'optional first change request before the prompt loop')
74
+ .option('--model <model>', 'codex | cursor | gpt-5 | claude | claude-code (or a provider-specific model id)')
75
+ .option('--max-turns <n>', 'turn budget per request')
76
+ .option('--allow-dirty', 'let turns run on a dirty working tree')
77
+ .option('--interactive', 'pause for approval after each proposal validates')
78
+ .action(
79
+ async (
80
+ requestParts: string[],
81
+ opts: { model?: string; maxTurns?: string; allowDirty?: boolean; interactive?: boolean },
82
+ ) => {
83
+ const repo = repoOf(program.opts());
84
+ if (program.opts().json) {
85
+ console.error(
86
+ 'copperhead: --json is not supported with the interactive shell. Use `copperhead do "<request>" --json`.',
87
+ );
88
+ process.exit(1);
89
+ }
90
+ try {
91
+ const kicadVer = await kicadCliVersion();
92
+ const config = await loadConfig(repo);
93
+ const renderer = rendererOf();
94
+ let model: string;
95
+ let source: ModelSource;
96
+ try {
97
+ ({ model, source } = resolveModel(opts.model, config));
98
+ } catch (err) {
99
+ // No model anywhere (flag, COPPERHEAD_MODEL, config, .env keys):
100
+ // on a TTY, offer an interactive pick instead of refusing to start.
101
+ if (!process.stdin.isTTY || !process.stdout.isTTY) throw err;
102
+ console.log('No model configured for this session, pick one:');
103
+ const chosen = await pickModel();
104
+ if (!chosen) throw err;
105
+ model = chosen;
106
+ source = 'picker';
107
+ }
108
+ const continuePrompt = budgetContinuePrompt();
109
+ const seed = requestParts.length ? requestParts.join(' ') : undefined;
110
+ const res = await runRepl({
111
+ repoRoot: repo,
112
+ model,
113
+ modelSource: source,
114
+ version,
115
+ kicadCliVersion: kicadVer,
116
+ ...(opts.maxTurns ? { maxTurns: parseMaxTurns(opts.maxTurns) } : {}),
117
+ allowDirty: opts.allowDirty ?? false,
118
+ interactive: opts.interactive ?? false,
119
+ ...(seed ? { seed } : {}),
120
+ confirm: confirmTty,
121
+ ...(continuePrompt ? { onBudgetExhausted: continuePrompt } : {}),
122
+ renderer,
123
+ });
124
+ process.exit(res.ok ? 0 : 1);
125
+ } catch (err) {
126
+ console.error((err as Error).message);
127
+ process.exit(1);
128
+ }
129
+ },
130
+ );
131
+
74
132
  program
75
133
  .command('init')
76
134
  .description('scaffold docs/ from an existing schematic; idempotent')
@@ -121,11 +179,30 @@ program
121
179
  .description('ERC + DRC + doc-drift + spec validation; no LLM calls; CI-safe')
122
180
  .action(checkAction);
123
181
 
182
+ program
183
+ .command('doctor')
184
+ .description('env preflight: kicad-cli, git, node, and the model provider credential; no LLM, no network')
185
+ .option('--model <model>', 'model to check the provider credential for (default: resolved like a run)')
186
+ .action(async (opts: { model?: string }) => {
187
+ const repo = repoOf(program.opts());
188
+ // Unlike other commands, doctor never gates on kicad-cli being present:
189
+ // runDoctor probes it and reports a failure instead of throwing, so a user
190
+ // with a missing tool still gets the full report.
191
+ const report = await runDoctor({ repoRoot: repo, model: opts.model });
192
+ if (program.opts().json) console.log(JSON.stringify(report, null, 2));
193
+ else {
194
+ const color = process.stdout.isTTY === true && !process.env.NO_COLOR;
195
+ // || not ??: some non-interactive ptys report columns as 0.
196
+ for (const line of formatDoctor(report, process.stdout.columns || 80, color)) console.log(line);
197
+ }
198
+ process.exit(report.ok ? 0 : 1);
199
+ });
200
+
124
201
  program
125
202
  .command('do')
126
203
  .description('the core loop: propose, edit, verify, propagate, commit')
127
204
  .argument('<request>', 'the change request in natural language')
128
- .option('--model <model>', 'codex | gpt-5 | claude | claude-code (or a provider-specific model id)')
205
+ .option('--model <model>', 'codex | cursor | gpt-5 | claude | claude-code (or a provider-specific model id)')
129
206
  .option('--max-turns <n>', 'turn budget for this run')
130
207
  .option('--allow-dirty', 'allow a dirty tree (snapshot via git stash create)')
131
208
  .option('--dry-run', 'propose the diff, write nothing')
@@ -145,7 +222,7 @@ program
145
222
  repoRoot: repo,
146
223
  request,
147
224
  model,
148
- ...(opts.maxTurns ? { maxTurns: parseInt(opts.maxTurns, 10) } : {}),
225
+ ...(opts.maxTurns ? { maxTurns: parseMaxTurns(opts.maxTurns) } : {}),
149
226
  allowDirty: opts.allowDirty ?? false,
150
227
  dryRun: opts.dryRun ?? false,
151
228
  interactive: opts.interactive ?? false,
@@ -199,11 +276,58 @@ program
199
276
  }
200
277
  });
201
278
 
279
+ program
280
+ .command('demo')
281
+ .description('tour of what copperhead does, or run the USB-C breakout create pipeline')
282
+ .option('--model <model>', 'codex | cursor | gpt-5 | claude | claude-code (or a provider-specific model id)')
283
+ .option('--interactive', 're-enable the human gates (spec approval, pre-export)')
284
+ .option('--dir <path>', 'demo repo directory (default: demo-runs/usb-c-breakout)')
285
+ .option('--tour', 'print the overview only; do not run the pipeline')
286
+ .action(async (opts: { model?: string; interactive?: boolean; dir?: string; tour?: boolean }) => {
287
+ if (opts.tour) {
288
+ const { setColorEnabled } = await import('./agent/theme.js');
289
+ if (program.opts().json) {
290
+ // --json is a contract, not a suggestion: a script that passes it
291
+ // unconditionally must never get prose back. Plain lines, no SGR.
292
+ setColorEnabled(false);
293
+ console.log(JSON.stringify({ tour: demoTourText().split('\n') }, null, 2));
294
+ process.exit(0);
295
+ }
296
+ // Color on for attended TTY tours even without a renderer.
297
+ setColorEnabled(Boolean(process.stdout.isTTY) && !program.opts().plain && !process.env.NO_COLOR);
298
+ console.log(demoTourText());
299
+ process.exit(0);
300
+ }
301
+ try {
302
+ const kicadVer = await kicadCliVersion();
303
+ // Resolve model from the caller's cwd config / env / flag; the demo repo
304
+ // is scaffolded next and typically has no model of its own yet.
305
+ const config = await loadConfig(repoOf(program.opts()));
306
+ const { model, source } = resolveModel(opts.model, config);
307
+ const continuePrompt = budgetContinuePrompt();
308
+ const res = await runDemo({
309
+ model,
310
+ modelSource: source,
311
+ version,
312
+ kicadCliVersion: kicadVer,
313
+ interactive: opts.interactive ?? false,
314
+ ...(opts.dir ? { demoDir: opts.dir } : {}),
315
+ ...(continuePrompt ? { onBudgetExhausted: continuePrompt } : {}),
316
+ log: (s) => console.log(s),
317
+ renderer: rendererOf(),
318
+ });
319
+ process.exit(res.ok ? 0 : 1);
320
+ } catch (err) {
321
+ console.error((err as Error).message);
322
+ process.exit(1);
323
+ }
324
+ });
325
+
202
326
  program
203
327
  .command('create')
204
328
  .description('Mode A: full pipeline from a product brief to the output package')
205
329
  .requiredOption('--brief <file>', 'product brief (markdown)')
206
- .option('--model <model>', 'codex | gpt-5 | claude | claude-code (or a provider-specific model id)')
330
+ .option('--model <model>', 'codex | cursor | gpt-5 | claude | claude-code (or a provider-specific model id)')
207
331
  .option('--interactive', 're-enable the human gates (spec approval, pre-export)')
208
332
  .action(async (opts: { brief: string; model?: string; interactive?: boolean }) => {
209
333
  const repo = repoOf(program.opts());