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.
- 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 +15 -5
- package/dist/agent/loop.js.map +1 -1
- package/dist/agent/providers/claude-code.js +1 -212
- 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/render.js +32 -15
- package/dist/agent/render.js.map +1 -1
- 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/cli.js +134 -13
- package/dist/cli.js.map +1 -1
- package/dist/commands/create.js +41 -32
- 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 +5 -2
- package/dist/config.js.map +1 -1
- package/dist/kicad/cli.js +126 -6
- package/dist/kicad/cli.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 +129 -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/select.js +172 -0
- package/dist/util/select.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 +15 -5
- package/src/agent/providers/claude-code.ts +2 -216
- package/src/agent/providers/cursor.ts +364 -0
- package/src/agent/providers/tool-protocol.ts +212 -0
- package/src/agent/render.ts +33 -16
- package/src/agent/runmeta.ts +6 -7
- package/src/agent/theme.ts +91 -0
- package/src/cli.ts +139 -15
- package/src/commands/create.ts +81 -30
- 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 +6 -3
- package/src/kicad/cli.ts +132 -7
- package/src/layout/claude-ui-layout.md +72 -0
- package/src/layout/repl-ui-layout.md +139 -0
- package/src/util/cli-args.ts +42 -0
- package/src/util/dock.ts +161 -0
- package/src/util/git.ts +140 -4
- package/src/util/live-prompt.ts +595 -0
- package/src/util/paths.ts +10 -0
- 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
|
+
}
|
package/src/agent/render.ts
CHANGED
|
@@ -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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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(
|
|
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 (
|
|
236
|
+
if (interactive) return new InteractiveRenderer();
|
|
220
237
|
return plainRenderer((line) => console.log(line));
|
|
221
238
|
}
|
package/src/agent/runmeta.ts
CHANGED
|
@@ -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)}
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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());
|