@modelworks/quack 0.0.0-stage → 0.2.8

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/dist/bin.js ADDED
@@ -0,0 +1,787 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * quack — Launch AI coding agents with ModelWorks circuits.
4
+ *
5
+ * @module
6
+ */
7
+ import { Command } from "commander";
8
+ import { getApiBase, loadCredentials, login, logout } from "./auth.js";
9
+ import { addCircuit, ensureState, refreshCircuits, removeCircuit } from "./circuit.js";
10
+ import { listAgents, getAgent, isAgentAvailable, listAvailableAgents, resolveAgent, isInteractive, } from "./agents/registry.js";
11
+ import { initProject, isQuackProject, requireQuackProject, readConfig, readState, writeConfig, writeState, } from "./project.js";
12
+ import { listRunRecords, getRunRecord } from "./history.js";
13
+ import buildInfo from "./build-info.json" with { type: "json" };
14
+ const VERSION = buildInfo.version;
15
+ const BUILD_ID = `${VERSION}+${buildInfo.build} (${buildInfo.commit}${buildInfo.dirty ? "-dirty" : ""})`;
16
+ // ANSI colors for status indicators.
17
+ const GREEN = "\x1b[32m";
18
+ const RED = "\x1b[31m";
19
+ const YELLOW = "\x1b[33m";
20
+ const DIM = "\x1b[2m";
21
+ const RESET = "\x1b[0m";
22
+ /** Strip ANSI escape codes to measure the visible text length. */
23
+ function stripAnsi(s) {
24
+ // eslint-disable-next-line no-control-regex
25
+ return s.replace(/\x1b\[[0-9;]*m/g, "");
26
+ }
27
+ /**
28
+ * Print a formatted table with dynamic column widths that fit the terminal.
29
+ *
30
+ * Natural widths are computed from header + values (measuring visible
31
+ * text, ignoring ANSI codes). If the total fits in the terminal, natural
32
+ * widths are used (no truncation). If it overflows, truncatable columns
33
+ * are shrunk (with `…`) until it fits; non-truncatable columns keep their
34
+ * natural width.
35
+ *
36
+ * When `process.stdout` is not a TTY (piped), defaults to 80 columns.
37
+ */
38
+ export function printTable(columns) {
39
+ const termWidth = process.stdout.columns ?? 80;
40
+ const gap = 2; // spaces between columns
41
+ const numCols = columns.length;
42
+ const totalGap = (numCols - 1) * gap;
43
+ // Natural width per column: max(header, longest value), measured without ANSI.
44
+ const naturalWidths = columns.map((col) => {
45
+ const valMax = col.values.length > 0
46
+ ? Math.max(...col.values.map((v) => stripAnsi(v).length))
47
+ : 0;
48
+ return Math.max(col.header.length, valMax);
49
+ });
50
+ const totalNatural = naturalWidths.reduce((a, b) => a + b, 0) + totalGap;
51
+ let widths = [...naturalWidths];
52
+ if (totalNatural > termWidth) {
53
+ // Shrink truncatable columns first, starting from the last truncatable
54
+ // column (rightmost — typically the less-important UIDs).
55
+ let deficit = totalNatural - termWidth;
56
+ const truncatableIndices = columns
57
+ .map((c, i) => (c.truncatable ? i : -1))
58
+ .filter((i) => i >= 0)
59
+ .reverse(); // shrink rightmost truncatable first
60
+ for (const idx of truncatableIndices) {
61
+ if (deficit <= 0)
62
+ break;
63
+ const minW = columns[idx].minWidth ?? 10;
64
+ const available = widths[idx] - minW;
65
+ if (available > 0) {
66
+ const shrink = Math.min(available, deficit);
67
+ widths[idx] -= shrink;
68
+ deficit -= shrink;
69
+ }
70
+ }
71
+ }
72
+ /** Truncate a string to `maxLen` visible chars, appending `…`. */
73
+ const truncate = (s, maxLen) => {
74
+ const plain = stripAnsi(s);
75
+ if (plain.length <= maxLen)
76
+ return s;
77
+ // Preserve ANSI if present — truncate the visible text only.
78
+ const cut = maxLen - 1; // leave room for …
79
+ // For simplicity: strip ANSI, truncate, don't re-apply (UIDs have no color).
80
+ return plain.slice(0, cut) + "…";
81
+ };
82
+ // Build header row.
83
+ const headerRow = columns
84
+ .map((col, i) => {
85
+ const w = widths[i];
86
+ return stripAnsi(col.header).length > w
87
+ ? truncate(col.header, w).padEnd(w)
88
+ : col.header.padEnd(w);
89
+ })
90
+ .join(" ");
91
+ const separator = widths.map((w) => "─".repeat(w)).join(" ");
92
+ console.log(headerRow);
93
+ console.log(separator);
94
+ // Data rows.
95
+ const numRows = Math.max(...columns.map((c) => c.values.length));
96
+ for (let row = 0; row < numRows; row++) {
97
+ const cells = columns.map((col, i) => {
98
+ const w = widths[i];
99
+ const val = col.values[row] ?? "";
100
+ const plainLen = stripAnsi(val).length;
101
+ if (plainLen > w) {
102
+ return truncate(val, w).padEnd(w);
103
+ }
104
+ // Preserve ANSI — pad based on visible length.
105
+ return val + " ".repeat(w - plainLen);
106
+ });
107
+ console.log(cells.join(" "));
108
+ }
109
+ }
110
+ /** Resolve the effective API base from env or stored credentials. */
111
+ function effectiveApiBase() {
112
+ return getApiBase(loadCredentials());
113
+ }
114
+ /** Force-login guard — exit if not authenticated. */
115
+ function requireAuth() {
116
+ if (!loadCredentials()) {
117
+ console.error("Not logged in. Run `quack auth login` first.");
118
+ process.exit(1);
119
+ }
120
+ }
121
+ // ---------------------------------------------------------------------------
122
+ // init
123
+ // ---------------------------------------------------------------------------
124
+ async function cmdInit(opts) {
125
+ initProject(opts.force ?? false);
126
+ }
127
+ // ---------------------------------------------------------------------------
128
+ // circuit commands
129
+ // ---------------------------------------------------------------------------
130
+ async function cmdCircuitList(opts) {
131
+ if (!isQuackProject()) {
132
+ console.error("Not a quack project. Run `quack init` first.");
133
+ process.exit(1);
134
+ }
135
+ const long = opts.long ?? false;
136
+ const config = readConfig();
137
+ const state = readState();
138
+ if (config.circuits.length === 0) {
139
+ console.log("No circuits. Run `quack circuit add <circuit-url>` to add one.");
140
+ return;
141
+ }
142
+ // Warn if default_circuit is stale (not in the circuits array).
143
+ if (config.default_circuit) {
144
+ const inConfig = config.circuits.some((c) => c.circuit_uid === config.default_circuit);
145
+ if (!inConfig) {
146
+ const firstCircuit = config.circuits[0];
147
+ console.error(`⚠ default_circuit '${config.default_circuit}' not found in circuits — using ${firstCircuit.name} as default`);
148
+ }
149
+ }
150
+ const rows = [];
151
+ for (const entry of config.circuits) {
152
+ const isDefault = config.default_circuit === entry.circuit_uid;
153
+ const s = state.circuits[entry.circuit_uid];
154
+ // Show the owner in the NAME column as `<username>/<circuit_name>` when
155
+ // synced (matches `quack history` and the /:username/:circuitName slug).
156
+ // The not-synced branch has no owner_username and falls back to the name.
157
+ const baseName = s?.owner_username
158
+ ? `${s.owner_username}/${entry.name}`
159
+ : entry.name;
160
+ const displayName = isDefault ? `${baseName} *` : baseName;
161
+ if (!s) {
162
+ rows.push({
163
+ name: displayName,
164
+ sse: `${RED}✗${RESET}`,
165
+ adapter: `${DIM}not synced (run \`quack refresh\`)${RESET}`,
166
+ adaptorUid: "",
167
+ versionUid: "",
168
+ circuitUid: entry.circuit_uid,
169
+ });
170
+ continue;
171
+ }
172
+ const sseOk = s.adapters.includes("OPEN_AI_SSE") && s.sse_adaptor_uid;
173
+ const sseMark = sseOk
174
+ ? `${GREEN}✓${RESET}`
175
+ : s.adapters.includes("OPEN_AI_SSE")
176
+ ? `${YELLOW}~${RESET}`
177
+ : `${RED}✗${RESET}`;
178
+ // First row: circuit summary + first adapter (if any).
179
+ const firstAdapter = s.adapters.length > 0 ? s.adapters[0] : "";
180
+ const firstAdaptorUid = s.adapters.length > 0
181
+ ? (s.adaptor_uids?.[s.adapters[0]] ?? "—")
182
+ : "";
183
+ rows.push({
184
+ name: displayName,
185
+ sse: sseMark,
186
+ adapter: firstAdapter,
187
+ adaptorUid: firstAdaptorUid,
188
+ versionUid: s.version_uid,
189
+ circuitUid: s.circuit_uid,
190
+ });
191
+ // Remaining adapter rows.
192
+ for (let i = 1; i < s.adapters.length; i++) {
193
+ const adapter = s.adapters[i];
194
+ const uid = s.adaptor_uids?.[adapter] ?? "—";
195
+ rows.push({
196
+ name: "",
197
+ sse: "",
198
+ adapter,
199
+ adaptorUid: uid,
200
+ versionUid: "",
201
+ circuitUid: "",
202
+ });
203
+ }
204
+ }
205
+ // Build columns for printTable.
206
+ const columns = [
207
+ { header: "NAME", values: rows.map((r) => r.name) },
208
+ { header: "SSE", values: rows.map((r) => r.sse) },
209
+ { header: "ADAPTER", values: rows.map((r) => r.adapter) },
210
+ { header: "ADAPTOR UID", values: rows.map((r) => r.adaptorUid), truncatable: true },
211
+ {
212
+ header: "VERSION UID",
213
+ values: rows.map((r) => r.versionUid ? `${DIM}${r.versionUid}${RESET}` : ""),
214
+ truncatable: true,
215
+ },
216
+ ];
217
+ if (long) {
218
+ columns.push({
219
+ header: "CIRCUIT UID",
220
+ values: rows.map((r) => r.circuitUid ? `${DIM}${r.circuitUid}${RESET}` : ""),
221
+ truncatable: true,
222
+ });
223
+ }
224
+ printTable(columns);
225
+ }
226
+ async function cmdCircuitAdd(url) {
227
+ const apiBase = effectiveApiBase();
228
+ const result = await addCircuit(url, apiBase);
229
+ const adapterList = result.adapters.join(", ") || "(none)";
230
+ if (!result.added) {
231
+ console.log(`Circuit already added — refreshed state.`);
232
+ }
233
+ if (result.sse_compatible) {
234
+ console.log(`Added circuit '${result.name}' (SSE). Adapters: ${adapterList}.`);
235
+ }
236
+ else {
237
+ console.log(`Added circuit '${result.name}' (no SSE). Adapters: ${adapterList}. Will not be injected by \`quack opencode\`.`);
238
+ }
239
+ // Auto-set default_circuit if this is SSE-compatible and no default is set.
240
+ if (result.sse_compatible && result.added) {
241
+ const config = readConfig();
242
+ if (!config.default_circuit) {
243
+ config.default_circuit = result.circuit_uid;
244
+ writeConfig(config);
245
+ console.log(`Set as default circuit (use \`quack circuit use <uid>\` to change).`);
246
+ }
247
+ }
248
+ }
249
+ async function cmdCircuitRemove(ref) {
250
+ const result = await removeCircuit(ref);
251
+ console.log(`Removed circuit '${result.name}' (${result.circuit_uid}).`);
252
+ // Clear default_circuit if it pointed to the removed circuit.
253
+ const config = readConfig();
254
+ if (config.default_circuit === result.circuit_uid) {
255
+ config.default_circuit = undefined;
256
+ writeConfig(config);
257
+ console.log(`Cleared default circuit.`);
258
+ }
259
+ }
260
+ /** `quack circuit use <uid>` — set the default circuit. */
261
+ function cmdCircuitUse(uid) {
262
+ requireQuackProject();
263
+ const config = readConfig();
264
+ const entry = config.circuits.find((c) => c.circuit_uid === uid);
265
+ if (!entry) {
266
+ console.error(`Circuit ${uid} is not in this project.`);
267
+ console.error("Run `quack circuit list` to see available circuits.");
268
+ process.exit(1);
269
+ }
270
+ config.default_circuit = uid;
271
+ writeConfig(config);
272
+ // Check SSE compatibility from state.
273
+ const state = readState();
274
+ const s = state.circuits[uid];
275
+ if (s && !s.adapters.includes("OPEN_AI_SSE")) {
276
+ console.log(`Set default circuit to '${entry.name}'.`);
277
+ console.log(`⚠ not SSE-compatible — won't appear as a model in OpenCode.`);
278
+ }
279
+ else {
280
+ console.log(`Set default circuit to '${entry.name}'.`);
281
+ }
282
+ }
283
+ async function cmdCircuitRefresh(uid) {
284
+ const apiBase = effectiveApiBase();
285
+ const results = await refreshCircuits(apiBase, uid);
286
+ for (const r of results) {
287
+ const sse = r.sse_compatible ? "SSE" : "no SSE";
288
+ console.log(`Refreshed '${r.name}' (${r.circuit_uid}) → ${r.version_uid} [${sse}]`);
289
+ }
290
+ }
291
+ async function cmdCircuitDescribe(uid) {
292
+ if (!isQuackProject()) {
293
+ console.error("Not a quack project. Run `quack init` first.");
294
+ process.exit(1);
295
+ }
296
+ const config = readConfig();
297
+ const state = readState();
298
+ const entry = config.circuits.find((c) => c.circuit_uid === uid);
299
+ if (!entry) {
300
+ console.error(`Circuit ${uid} is not in this project.`);
301
+ process.exit(1);
302
+ }
303
+ const s = state.circuits[uid];
304
+ console.log(`Name: ${entry.name}`);
305
+ console.log(`Circuit UID: ${uid}`);
306
+ console.log(`Default: ${config.default_circuit === uid ? "yes" : "no"}`);
307
+ if (s) {
308
+ console.log(`Version UID: ${s.version_uid}`);
309
+ const sseOk = s.adapters.includes("OPEN_AI_SSE") && s.sse_adaptor_uid;
310
+ const sseStatus = sseOk
311
+ ? `${GREEN}✓${RESET} compatible`
312
+ : s.adapters.includes("OPEN_AI_SSE")
313
+ ? `${YELLOW}~${RESET} adapter missing`
314
+ : `${RED}✗${RESET} not compatible`;
315
+ console.log(`SSE: ${sseStatus}`);
316
+ console.log();
317
+ console.log(`Adapters:`);
318
+ for (const adapter of s.adapters) {
319
+ const adaptorUid = s.adaptor_uids?.[adapter] ?? "—";
320
+ console.log(` ${adapter.padEnd(18)} ${DIM}${adaptorUid}${RESET}`);
321
+ }
322
+ console.log();
323
+ console.log(`Added: ${entry.added_at}`);
324
+ console.log(`Last sync: ${s.resolved_at}`);
325
+ }
326
+ else {
327
+ console.log(`SSE: ${RED}✗${RESET} not synced`);
328
+ console.log();
329
+ console.log(`Run \`quack circuit refresh ${uid}\` to sync.`);
330
+ }
331
+ }
332
+ // ---------------------------------------------------------------------------
333
+ // auth commands
334
+ // ---------------------------------------------------------------------------
335
+ async function cmdAuthLogin(apiBase) {
336
+ const result = await login(apiBase);
337
+ console.log(`Logged in as user ${result.user_uid} (role: ${result.role}).`);
338
+ }
339
+ async function cmdAuthLogout() {
340
+ await logout();
341
+ console.log("Logged out.");
342
+ }
343
+ async function cmdAuthStatus() {
344
+ const creds = loadCredentials();
345
+ if (!creds) {
346
+ console.log("Not logged in. Run `quack auth login` first.");
347
+ process.exit(1);
348
+ }
349
+ // Fetch the username from the API for a friendlier display.
350
+ let username = creds.user_uid;
351
+ try {
352
+ const res = await fetch(`${creds.api_base}/api/users/token`, {
353
+ headers: { Authorization: `Bearer ${creds.access_token}` },
354
+ });
355
+ if (res.ok) {
356
+ const body = await res.json();
357
+ username = body.payload?.username ?? username;
358
+ }
359
+ }
360
+ catch {
361
+ // Fall back to user_uid if the API is unreachable.
362
+ }
363
+ console.log(`User: ${username}`);
364
+ console.log(`UID: ${creds.user_uid}`);
365
+ console.log(`API: ${creds.api_base}`);
366
+ const now = Math.floor(Date.now() / 1000);
367
+ const remaining = creds.expires_at - now;
368
+ const expiry = remaining > 0
369
+ ? `${Math.floor(remaining / 60)}m remaining`
370
+ : "expired (will refresh on next use)";
371
+ console.log(`Token: ${expiry}`);
372
+ }
373
+ // ---------------------------------------------------------------------------
374
+ // agent commands
375
+ // ---------------------------------------------------------------------------
376
+ async function cmdAgentList() {
377
+ const agents = listAgents();
378
+ const config = isQuackProject() ? readConfig() : null;
379
+ const defaultAgent = config?.default_agent;
380
+ console.log("Available agents:\n");
381
+ for (const agent of agents) {
382
+ const installed = isAgentAvailable(agent) ? `${GREEN}✓ installed${RESET}` : `${RED}✗ not installed${RESET}`;
383
+ const isDefault = defaultAgent === agent.name ? " default" : "";
384
+ const installedPlain = stripAnsi(installed);
385
+ console.log(` ${agent.name.padEnd(12)} ${installedPlain.padEnd(18)}${isDefault} ${agent.description}`);
386
+ }
387
+ console.log("\nThese agents are built into quack. New agents appear automatically\nwhen you update quack.");
388
+ }
389
+ async function cmdAgentDescribe(name) {
390
+ const agent = getAgent(name);
391
+ if (!agent) {
392
+ console.error(`Unknown agent: ${name}`);
393
+ console.error("Run `quack agent list` to see available agents.");
394
+ process.exit(1);
395
+ }
396
+ const config = isQuackProject() ? readConfig() : null;
397
+ const isDefault = config?.default_agent === name;
398
+ const installed = isAgentAvailable(agent);
399
+ console.log(`Name: ${agent.name}`);
400
+ console.log(`Description: ${agent.description}`);
401
+ console.log(`Binary: ${agent.binary}`);
402
+ console.log(`Status: ${installed ? `${GREEN}✓${RESET} installed` : `${RED}✗${RESET} not installed`}`);
403
+ if (isDefault) {
404
+ console.log(`Default: yes`);
405
+ }
406
+ }
407
+ // ---------------------------------------------------------------------------
408
+ // Interactive picker
409
+ // ---------------------------------------------------------------------------
410
+ /** Prompt the user to pick from a list of agents. Returns the chosen agent. */
411
+ async function pickAgentInteractive() {
412
+ const available = listAvailableAgents();
413
+ if (available.length === 0)
414
+ return null;
415
+ const readline = await import("node:readline/promises");
416
+ const { stdin, stdout } = process;
417
+ const rl = readline.createInterface({ input: stdin, output: stdout });
418
+ console.log("\nNo default agent set. Pick one:\n");
419
+ available.forEach((agent, i) => {
420
+ console.log(` ${i + 1}) ${agent.name} — ${agent.description}`);
421
+ });
422
+ console.log("");
423
+ try {
424
+ const answer = await rl.question(`Pick an agent [1-${available.length}]: `);
425
+ const idx = parseInt(answer.trim(), 10) - 1;
426
+ if (isNaN(idx) || idx < 0 || idx >= available.length) {
427
+ console.error("Invalid selection.");
428
+ return null;
429
+ }
430
+ return available[idx].name;
431
+ }
432
+ finally {
433
+ rl.close();
434
+ }
435
+ }
436
+ // ---------------------------------------------------------------------------
437
+ // Bare `quack` — launch the default agent
438
+ // ---------------------------------------------------------------------------
439
+ async function cmdQuack(opts) {
440
+ if (!isQuackProject()) {
441
+ console.error("Not a quack project. Run `quack init` first.");
442
+ process.exit(1);
443
+ }
444
+ const config = readConfig();
445
+ const state = readState();
446
+ // Resolve which agent to launch.
447
+ let agentName;
448
+ const result = resolveAgent(config.default_agent, state.last_agent);
449
+ if (result.ok) {
450
+ agentName = result.agent.name;
451
+ }
452
+ else if (result.message === "__PROMPT__") {
453
+ // Interactive picker — multiple agents available, no default set.
454
+ if (!isInteractive()) {
455
+ console.error('No default agent set and multiple are installed. Set "default_agent" in .quack/config.json.');
456
+ process.exit(1);
457
+ }
458
+ const picked = await pickAgentInteractive();
459
+ if (!picked) {
460
+ process.exit(1);
461
+ }
462
+ agentName = picked;
463
+ }
464
+ else {
465
+ console.error(result.message);
466
+ process.exit(1);
467
+ }
468
+ const agent = getAgent(agentName);
469
+ if (!agent) {
470
+ console.error(`Agent "${agentName}" not found.`);
471
+ process.exit(1);
472
+ }
473
+ // Auto-sync circuits (cloned repo case).
474
+ const apiBase = effectiveApiBase();
475
+ await ensureState(apiBase);
476
+ // Launch with no args.
477
+ const getAccessToken = () => {
478
+ const c = loadCredentials();
479
+ if (!c) {
480
+ throw new Error("Not logged in. Run `quack auth login` first.");
481
+ }
482
+ return c.access_token;
483
+ };
484
+ const freshState = readState();
485
+ const freshConfig = readConfig();
486
+ const ctx = {
487
+ proxyPort: 0,
488
+ apiBase,
489
+ getAccessToken,
490
+ state: freshState,
491
+ config: freshConfig,
492
+ };
493
+ const launchResult = await agent.launch(ctx, {
494
+ args: [],
495
+ verbose: opts.verbose ?? false,
496
+ debug: opts.debug ?? false,
497
+ print: false,
498
+ });
499
+ // Save the last-used agent.
500
+ if (launchResult.exitCode !== null) {
501
+ const s = readState();
502
+ s.last_agent = agentName;
503
+ writeState(s);
504
+ process.exit(launchResult.exitCode);
505
+ }
506
+ }
507
+ // ---------------------------------------------------------------------------
508
+ // Agent launch (quack opencode / quack <agent>)
509
+ // ---------------------------------------------------------------------------
510
+ async function cmdAgentLaunch(agentName, args, opts) {
511
+ const agent = getAgent(agentName);
512
+ if (!agent) {
513
+ console.error(`Unknown agent: ${agentName}`);
514
+ console.error("Run `quack agent list` to see available agents.");
515
+ process.exit(1);
516
+ }
517
+ const passThrough = args.filter((a) => !a.startsWith("--"));
518
+ const apiBase = effectiveApiBase();
519
+ if (!isQuackProject()) {
520
+ console.error("Not a quack project. Run `quack init` first.");
521
+ process.exit(1);
522
+ }
523
+ // Auto-sync: create adaptors for circuits in config but missing from
524
+ // state (cloned repo case — like `npm install` creating node_modules).
525
+ await ensureState(apiBase);
526
+ // `getAccessToken` must return the live (possibly-refreshed) token.
527
+ // We read from disk on every call so the proxy always sees the latest.
528
+ const getAccessToken = () => {
529
+ const c = loadCredentials();
530
+ if (!c) {
531
+ throw new Error("Not logged in. Run `quack auth login` first.");
532
+ }
533
+ return c.access_token;
534
+ };
535
+ const state = readState();
536
+ const config = readConfig();
537
+ const ctx = {
538
+ proxyPort: 0,
539
+ apiBase,
540
+ getAccessToken,
541
+ state,
542
+ config,
543
+ };
544
+ const result = await agent.launch(ctx, {
545
+ args: passThrough,
546
+ verbose: opts.verbose ?? false,
547
+ debug: opts.debug ?? false,
548
+ print: opts.print ?? false,
549
+ });
550
+ if (result.exitCode !== null) {
551
+ process.exit(result.exitCode);
552
+ }
553
+ }
554
+ // ---------------------------------------------------------------------------
555
+ // history
556
+ // ---------------------------------------------------------------------------
557
+ async function cmdHistory(filter, opts) {
558
+ if (!isQuackProject()) {
559
+ console.error("Not a quack project. Run `quack init` first.");
560
+ process.exit(1);
561
+ }
562
+ // If a specific run UID is given, show full details.
563
+ if (filter && !filter.startsWith("-")) {
564
+ const record = getRunRecord(filter);
565
+ if (!record) {
566
+ console.error(`Run ${filter} not found in local history.`);
567
+ process.exit(1);
568
+ }
569
+ console.log(`Run UID: ${record.run_uid}`);
570
+ console.log(`Circuit: ${record.circuit_name} (${record.circuit_uid})`);
571
+ console.log(`Owner: ${record.owner_username}`);
572
+ console.log(`Caller: ${record.caller_username}`);
573
+ console.log(`Version UID: ${record.version_uid}`);
574
+ console.log(`Status: ${record.status}`);
575
+ console.log(`Created: ${record.created_at}`);
576
+ console.log(`Completed: ${record.completed_at}`);
577
+ if (record.duration_ms !== null) {
578
+ console.log(`Duration: ${(record.duration_ms / 1000).toFixed(1)}s`);
579
+ }
580
+ if (record.cost_credits !== null) {
581
+ console.log(`Cost: ${record.cost_credits} credits`);
582
+ }
583
+ if (record.input_tokens !== null) {
584
+ console.log(`Input tokens: ${record.input_tokens}`);
585
+ }
586
+ if (record.output_tokens !== null) {
587
+ console.log(`Output tokens: ${record.output_tokens}`);
588
+ }
589
+ return;
590
+ }
591
+ // List runs in table format.
592
+ let records = listRunRecords();
593
+ // Apply filters.
594
+ if (opts.user) {
595
+ records = records.filter((r) => r.caller_username === opts.user);
596
+ }
597
+ if (opts.circuit) {
598
+ records = records.filter((r) => r.circuit_uid === opts.circuit);
599
+ }
600
+ if (records.length === 0) {
601
+ console.log("No run history. Runs are saved automatically after each circuit request.");
602
+ return;
603
+ }
604
+ // Table: RUN UID, CIRCUIT, CALLER, STATUS, COST, DURATION
605
+ const rows = records.map((r) => {
606
+ const isCompleted = r.status === "COMPLETED" || r.status === "Completed";
607
+ const isFailed = r.status === "EXCEPTION" || r.status === "FAILED";
608
+ const statusCol = isCompleted
609
+ ? `${GREEN}${r.status}${RESET}`
610
+ : isFailed
611
+ ? `${RED}${r.status}${RESET}`
612
+ : r.status;
613
+ return {
614
+ runUid: r.run_uid,
615
+ circuit: `${r.owner_username}/${r.circuit_name}`,
616
+ caller: r.caller_username,
617
+ status: statusCol,
618
+ cost: r.cost_credits !== null ? String(r.cost_credits) : "—",
619
+ duration: r.duration_ms !== null ? (r.duration_ms / 1000).toFixed(1) + "s" : "—",
620
+ };
621
+ });
622
+ printTable([
623
+ { header: "RUN UID", values: rows.map((r) => `${DIM}${r.runUid}${RESET}`), truncatable: true },
624
+ { header: "CIRCUIT", values: rows.map((r) => r.circuit) },
625
+ { header: "CALLER", values: rows.map((r) => r.caller) },
626
+ { header: "STATUS", values: rows.map((r) => r.status) },
627
+ { header: "COST", values: rows.map((r) => r.cost) },
628
+ { header: "DURATION", values: rows.map((r) => r.duration) },
629
+ ]);
630
+ }
631
+ // ---------------------------------------------------------------------------
632
+ // Build the CLI
633
+ // ---------------------------------------------------------------------------
634
+ function buildCli() {
635
+ const program = new Command();
636
+ program
637
+ .name("quack")
638
+ .description("Launch AI coding agents with ModelWorks circuits")
639
+ .version(BUILD_ID, "-v, --version")
640
+ .option("--api-base <url>", "Override API base URL")
641
+ .option("--verbose", "Verbose output (proxy request logging)")
642
+ .option("--debug", "Debug mode (full proxy request/response logging to .quack/debug.log)")
643
+ .helpOption("-h, --help", "Show help");
644
+ // init
645
+ program
646
+ .command("init")
647
+ .description("Initialize a quack project (.quack/ directory)")
648
+ .option("--force", "Overwrite existing .quack/ directory")
649
+ .action(async (opts) => {
650
+ requireAuth();
651
+ await cmdInit(opts);
652
+ });
653
+ // circuit group
654
+ const circuit = program
655
+ .command("circuit")
656
+ .description("Manage circuits in this project");
657
+ circuit
658
+ .command("list")
659
+ .description("List circuits and their adapters")
660
+ .option("-l, --long", "Show full UIDs (CIRCUIT UID column)")
661
+ .action(async (opts) => {
662
+ requireAuth();
663
+ await cmdCircuitList(opts);
664
+ });
665
+ circuit
666
+ .command("add <url>")
667
+ .description("Add a circuit by URL or UID")
668
+ .action(async (url) => {
669
+ requireAuth();
670
+ await cmdCircuitAdd(url);
671
+ });
672
+ circuit
673
+ .command("remove <uid>")
674
+ .description("Remove a circuit from this project")
675
+ .action(async (uid) => {
676
+ requireAuth();
677
+ await cmdCircuitRemove(uid);
678
+ });
679
+ circuit
680
+ .command("refresh [uid]")
681
+ .description("Re-resolve circuit version and adapters")
682
+ .action(async (uid) => {
683
+ requireAuth();
684
+ await cmdCircuitRefresh(uid);
685
+ });
686
+ circuit
687
+ .command("describe <uid>")
688
+ .description("Show detailed information about a circuit")
689
+ .action(async (uid) => {
690
+ requireAuth();
691
+ await cmdCircuitDescribe(uid);
692
+ });
693
+ circuit
694
+ .command("use <uid>")
695
+ .description("Set the default circuit (used as the default model in OpenCode)")
696
+ .action(async (uid) => {
697
+ requireAuth();
698
+ cmdCircuitUse(uid);
699
+ });
700
+ // agent group
701
+ const agent = program
702
+ .command("agent")
703
+ .description("List and inspect available agents");
704
+ agent
705
+ .command("list")
706
+ .description("List all available agents")
707
+ .action(async () => {
708
+ await cmdAgentList();
709
+ });
710
+ agent
711
+ .command("describe <name>")
712
+ .description("Show details about a specific agent")
713
+ .action(async (name) => {
714
+ await cmdAgentDescribe(name);
715
+ });
716
+ // auth group
717
+ const auth = program
718
+ .command("auth")
719
+ .description("Authentication commands");
720
+ auth
721
+ .command("login")
722
+ .description("Login via OAuth (opens browser)")
723
+ .action(async () => {
724
+ const opts = program.opts();
725
+ await cmdAuthLogin(opts.apiBase);
726
+ });
727
+ auth
728
+ .command("logout")
729
+ .description("Logout and revoke credentials")
730
+ .action(async () => {
731
+ await cmdAuthLogout();
732
+ });
733
+ auth
734
+ .command("status")
735
+ .description("Show current user and token status")
736
+ .action(async () => {
737
+ await cmdAuthStatus();
738
+ });
739
+ // opencode — launch OpenCode (built-in agent)
740
+ program
741
+ .command("opencode [args...]")
742
+ .description("Launch OpenCode with SSE circuits as model providers")
743
+ .option("--print", "Show injected config, don't launch")
744
+ .allowUnknownOption(true)
745
+ .action(async (args, opts) => {
746
+ requireAuth();
747
+ await cmdAgentLaunch("opencode", args ?? [], opts);
748
+ });
749
+ // history — list and show circuit run history
750
+ program
751
+ .command("history [filter]")
752
+ .description("Show circuit run history (list, or show a specific run by UID)")
753
+ .option("--user <name>", "Filter by caller username")
754
+ .option("--circuit <uid>", "Filter by circuit UID")
755
+ .action(async (filter, opts) => {
756
+ requireAuth();
757
+ await cmdHistory(filter, opts);
758
+ });
759
+ // Bare `quack` — launch the default agent.
760
+ // Commander calls the default action when no subcommand is given.
761
+ program.action(async (opts) => {
762
+ requireAuth();
763
+ await cmdQuack(opts);
764
+ });
765
+ return program;
766
+ }
767
+ // ---------------------------------------------------------------------------
768
+ // Main
769
+ // ---------------------------------------------------------------------------
770
+ async function main() {
771
+ const program = buildCli();
772
+ const argv = process.argv.slice(2);
773
+ // Set QUACK_API_BASE from the global --api-base flag before any
774
+ // command handler runs, so effectiveApiBase() picks it up.
775
+ const apiBaseIdx = argv.indexOf("--api-base");
776
+ if (apiBaseIdx !== -1 && apiBaseIdx + 1 < argv.length) {
777
+ process.env.QUACK_API_BASE = argv[apiBaseIdx + 1];
778
+ }
779
+ try {
780
+ await program.parseAsync(process.argv);
781
+ }
782
+ catch (err) {
783
+ console.error(`error: ${err.message}`);
784
+ process.exit(1);
785
+ }
786
+ }
787
+ main();