@modelworks/quack 0.0.0-stage → 0.2.9
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/LICENSE +21 -0
- package/README.md +242 -2
- package/dist/agents/opencode.d.ts +40 -0
- package/dist/agents/opencode.js +409 -0
- package/dist/agents/registry.d.ts +54 -0
- package/dist/agents/registry.js +134 -0
- package/dist/agents/types.d.ts +62 -0
- package/dist/agents/types.js +11 -0
- package/dist/auth.d.ts +91 -0
- package/dist/auth.js +388 -0
- package/dist/bin.d.ts +35 -0
- package/dist/bin.js +806 -0
- package/dist/build-info.json +8 -0
- package/dist/circuit.d.ts +116 -0
- package/dist/circuit.js +428 -0
- package/dist/history.d.ts +34 -0
- package/dist/history.js +154 -0
- package/dist/install.d.ts +23 -0
- package/dist/install.js +330 -0
- package/dist/project.d.ts +50 -0
- package/dist/project.js +147 -0
- package/dist/proxy.d.ts +18 -0
- package/dist/proxy.js +254 -0
- package/dist/types.d.ts +245 -0
- package/dist/types.js +6 -0
- package/package.json +50 -4
package/dist/bin.js
ADDED
|
@@ -0,0 +1,806 @@
|
|
|
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, effectiveApiBase());
|
|
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
|
+
async function cmdAgentSet(name) {
|
|
408
|
+
const agent = getAgent(name);
|
|
409
|
+
if (!agent) {
|
|
410
|
+
console.error(`Unknown agent: ${name}`);
|
|
411
|
+
console.error("Run `quack agent list` to see available agents.");
|
|
412
|
+
process.exit(1);
|
|
413
|
+
}
|
|
414
|
+
requireQuackProject();
|
|
415
|
+
const config = readConfig();
|
|
416
|
+
config.default_agent = name;
|
|
417
|
+
writeConfig(config);
|
|
418
|
+
console.log(`Default agent set to ${GREEN}${name}${RESET}.`);
|
|
419
|
+
}
|
|
420
|
+
// ---------------------------------------------------------------------------
|
|
421
|
+
// Interactive picker
|
|
422
|
+
// ---------------------------------------------------------------------------
|
|
423
|
+
/** Prompt the user to pick from a list of agents. Returns the chosen agent. */
|
|
424
|
+
async function pickAgentInteractive() {
|
|
425
|
+
const available = listAvailableAgents();
|
|
426
|
+
if (available.length === 0)
|
|
427
|
+
return null;
|
|
428
|
+
const readline = await import("node:readline/promises");
|
|
429
|
+
const { stdin, stdout } = process;
|
|
430
|
+
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
431
|
+
console.log("\nNo default agent set. Pick one:\n");
|
|
432
|
+
available.forEach((agent, i) => {
|
|
433
|
+
console.log(` ${i + 1}) ${agent.name} — ${agent.description}`);
|
|
434
|
+
});
|
|
435
|
+
console.log("");
|
|
436
|
+
try {
|
|
437
|
+
const answer = await rl.question(`Pick an agent [1-${available.length}]: `);
|
|
438
|
+
const idx = parseInt(answer.trim(), 10) - 1;
|
|
439
|
+
if (isNaN(idx) || idx < 0 || idx >= available.length) {
|
|
440
|
+
console.error("Invalid selection.");
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
443
|
+
return available[idx].name;
|
|
444
|
+
}
|
|
445
|
+
finally {
|
|
446
|
+
rl.close();
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
// ---------------------------------------------------------------------------
|
|
450
|
+
// Bare `quack` — launch the default agent
|
|
451
|
+
// ---------------------------------------------------------------------------
|
|
452
|
+
async function cmdQuack(opts) {
|
|
453
|
+
if (!isQuackProject()) {
|
|
454
|
+
console.error("Not a quack project. Run `quack init` first.");
|
|
455
|
+
process.exit(1);
|
|
456
|
+
}
|
|
457
|
+
const config = readConfig();
|
|
458
|
+
const state = readState();
|
|
459
|
+
// Resolve which agent to launch.
|
|
460
|
+
let agentName;
|
|
461
|
+
const result = resolveAgent(config.default_agent, state.last_agent);
|
|
462
|
+
if (result.ok) {
|
|
463
|
+
agentName = result.agent.name;
|
|
464
|
+
}
|
|
465
|
+
else if (result.message === "__PROMPT__") {
|
|
466
|
+
// Interactive picker — multiple agents available, no default set.
|
|
467
|
+
if (!isInteractive()) {
|
|
468
|
+
console.error('No default agent set and multiple are installed. Set "default_agent" in .quack/config.json.');
|
|
469
|
+
process.exit(1);
|
|
470
|
+
}
|
|
471
|
+
const picked = await pickAgentInteractive();
|
|
472
|
+
if (!picked) {
|
|
473
|
+
process.exit(1);
|
|
474
|
+
}
|
|
475
|
+
agentName = picked;
|
|
476
|
+
}
|
|
477
|
+
else {
|
|
478
|
+
console.error(result.message);
|
|
479
|
+
process.exit(1);
|
|
480
|
+
}
|
|
481
|
+
const agent = getAgent(agentName);
|
|
482
|
+
if (!agent) {
|
|
483
|
+
console.error(`Agent "${agentName}" not found.`);
|
|
484
|
+
process.exit(1);
|
|
485
|
+
}
|
|
486
|
+
// Auto-sync circuits (cloned repo case).
|
|
487
|
+
const apiBase = effectiveApiBase();
|
|
488
|
+
await ensureState(apiBase);
|
|
489
|
+
// Launch with no args.
|
|
490
|
+
const getAccessToken = () => {
|
|
491
|
+
const c = loadCredentials();
|
|
492
|
+
if (!c) {
|
|
493
|
+
throw new Error("Not logged in. Run `quack auth login` first.");
|
|
494
|
+
}
|
|
495
|
+
return c.access_token;
|
|
496
|
+
};
|
|
497
|
+
const freshState = readState();
|
|
498
|
+
const freshConfig = readConfig();
|
|
499
|
+
const ctx = {
|
|
500
|
+
proxyPort: 0,
|
|
501
|
+
apiBase,
|
|
502
|
+
getAccessToken,
|
|
503
|
+
state: freshState,
|
|
504
|
+
config: freshConfig,
|
|
505
|
+
};
|
|
506
|
+
const launchResult = await agent.launch(ctx, {
|
|
507
|
+
args: [],
|
|
508
|
+
verbose: opts.verbose ?? false,
|
|
509
|
+
debug: opts.debug ?? false,
|
|
510
|
+
print: false,
|
|
511
|
+
});
|
|
512
|
+
// Save the last-used agent.
|
|
513
|
+
if (launchResult.exitCode !== null) {
|
|
514
|
+
const s = readState();
|
|
515
|
+
s.last_agent = agentName;
|
|
516
|
+
writeState(s);
|
|
517
|
+
process.exit(launchResult.exitCode);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
// ---------------------------------------------------------------------------
|
|
521
|
+
// Agent launch (quack opencode / quack <agent>)
|
|
522
|
+
// ---------------------------------------------------------------------------
|
|
523
|
+
async function cmdAgentLaunch(agentName, args, opts) {
|
|
524
|
+
const agent = getAgent(agentName);
|
|
525
|
+
if (!agent) {
|
|
526
|
+
console.error(`Unknown agent: ${agentName}`);
|
|
527
|
+
console.error("Run `quack agent list` to see available agents.");
|
|
528
|
+
process.exit(1);
|
|
529
|
+
}
|
|
530
|
+
const passThrough = args.filter((a) => !a.startsWith("--"));
|
|
531
|
+
const apiBase = effectiveApiBase();
|
|
532
|
+
if (!isQuackProject()) {
|
|
533
|
+
console.error("Not a quack project. Run `quack init` first.");
|
|
534
|
+
process.exit(1);
|
|
535
|
+
}
|
|
536
|
+
// Auto-sync: create adaptors for circuits in config but missing from
|
|
537
|
+
// state (cloned repo case — like `npm install` creating node_modules).
|
|
538
|
+
await ensureState(apiBase);
|
|
539
|
+
// `getAccessToken` must return the live (possibly-refreshed) token.
|
|
540
|
+
// We read from disk on every call so the proxy always sees the latest.
|
|
541
|
+
const getAccessToken = () => {
|
|
542
|
+
const c = loadCredentials();
|
|
543
|
+
if (!c) {
|
|
544
|
+
throw new Error("Not logged in. Run `quack auth login` first.");
|
|
545
|
+
}
|
|
546
|
+
return c.access_token;
|
|
547
|
+
};
|
|
548
|
+
const state = readState();
|
|
549
|
+
const config = readConfig();
|
|
550
|
+
const ctx = {
|
|
551
|
+
proxyPort: 0,
|
|
552
|
+
apiBase,
|
|
553
|
+
getAccessToken,
|
|
554
|
+
state,
|
|
555
|
+
config,
|
|
556
|
+
};
|
|
557
|
+
const result = await agent.launch(ctx, {
|
|
558
|
+
args: passThrough,
|
|
559
|
+
verbose: opts.verbose ?? false,
|
|
560
|
+
debug: opts.debug ?? false,
|
|
561
|
+
print: opts.print ?? false,
|
|
562
|
+
});
|
|
563
|
+
if (result.exitCode !== null) {
|
|
564
|
+
process.exit(result.exitCode);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
// ---------------------------------------------------------------------------
|
|
568
|
+
// history
|
|
569
|
+
// ---------------------------------------------------------------------------
|
|
570
|
+
async function cmdHistory(filter, opts) {
|
|
571
|
+
if (!isQuackProject()) {
|
|
572
|
+
console.error("Not a quack project. Run `quack init` first.");
|
|
573
|
+
process.exit(1);
|
|
574
|
+
}
|
|
575
|
+
// If a specific run UID is given, show full details.
|
|
576
|
+
if (filter && !filter.startsWith("-")) {
|
|
577
|
+
const record = getRunRecord(filter);
|
|
578
|
+
if (!record) {
|
|
579
|
+
console.error(`Run ${filter} not found in local history.`);
|
|
580
|
+
process.exit(1);
|
|
581
|
+
}
|
|
582
|
+
console.log(`Run UID: ${record.run_uid}`);
|
|
583
|
+
console.log(`Circuit: ${record.circuit_name} (${record.circuit_uid})`);
|
|
584
|
+
console.log(`Owner: ${record.owner_username}`);
|
|
585
|
+
console.log(`Caller: ${record.caller_username}`);
|
|
586
|
+
console.log(`Version UID: ${record.version_uid}`);
|
|
587
|
+
console.log(`Status: ${record.status}`);
|
|
588
|
+
console.log(`Created: ${record.created_at}`);
|
|
589
|
+
console.log(`Completed: ${record.completed_at}`);
|
|
590
|
+
if (record.duration_ms !== null) {
|
|
591
|
+
console.log(`Duration: ${(record.duration_ms / 1000).toFixed(1)}s`);
|
|
592
|
+
}
|
|
593
|
+
if (record.cost_credits !== null) {
|
|
594
|
+
console.log(`Cost: ${record.cost_credits} credits`);
|
|
595
|
+
}
|
|
596
|
+
if (record.input_tokens !== null) {
|
|
597
|
+
console.log(`Input tokens: ${record.input_tokens}`);
|
|
598
|
+
}
|
|
599
|
+
if (record.output_tokens !== null) {
|
|
600
|
+
console.log(`Output tokens: ${record.output_tokens}`);
|
|
601
|
+
}
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
// List runs in table format.
|
|
605
|
+
let records = listRunRecords();
|
|
606
|
+
// Apply filters.
|
|
607
|
+
if (opts.user) {
|
|
608
|
+
records = records.filter((r) => r.caller_username === opts.user);
|
|
609
|
+
}
|
|
610
|
+
if (opts.circuit) {
|
|
611
|
+
records = records.filter((r) => r.circuit_uid === opts.circuit);
|
|
612
|
+
}
|
|
613
|
+
if (records.length === 0) {
|
|
614
|
+
console.log("No run history. Runs are saved automatically after each circuit request.");
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
// Table: RUN UID, CIRCUIT, CALLER, STATUS, COST, DURATION
|
|
618
|
+
const rows = records.map((r) => {
|
|
619
|
+
const isCompleted = r.status === "COMPLETED" || r.status === "Completed";
|
|
620
|
+
const isFailed = r.status === "EXCEPTION" || r.status === "FAILED";
|
|
621
|
+
const statusCol = isCompleted
|
|
622
|
+
? `${GREEN}${r.status}${RESET}`
|
|
623
|
+
: isFailed
|
|
624
|
+
? `${RED}${r.status}${RESET}`
|
|
625
|
+
: r.status;
|
|
626
|
+
return {
|
|
627
|
+
runUid: r.run_uid,
|
|
628
|
+
circuit: `${r.owner_username}/${r.circuit_name}`,
|
|
629
|
+
caller: r.caller_username,
|
|
630
|
+
status: statusCol,
|
|
631
|
+
cost: r.cost_credits !== null ? String(r.cost_credits) : "—",
|
|
632
|
+
duration: r.duration_ms !== null ? (r.duration_ms / 1000).toFixed(1) + "s" : "—",
|
|
633
|
+
};
|
|
634
|
+
});
|
|
635
|
+
printTable([
|
|
636
|
+
{ header: "RUN UID", values: rows.map((r) => `${DIM}${r.runUid}${RESET}`), truncatable: true },
|
|
637
|
+
{ header: "CIRCUIT", values: rows.map((r) => r.circuit) },
|
|
638
|
+
{ header: "CALLER", values: rows.map((r) => r.caller) },
|
|
639
|
+
{ header: "STATUS", values: rows.map((r) => r.status) },
|
|
640
|
+
{ header: "COST", values: rows.map((r) => r.cost) },
|
|
641
|
+
{ header: "DURATION", values: rows.map((r) => r.duration) },
|
|
642
|
+
]);
|
|
643
|
+
}
|
|
644
|
+
// ---------------------------------------------------------------------------
|
|
645
|
+
// Build the CLI
|
|
646
|
+
// ---------------------------------------------------------------------------
|
|
647
|
+
function buildCli() {
|
|
648
|
+
const program = new Command();
|
|
649
|
+
program
|
|
650
|
+
.name("quack")
|
|
651
|
+
.description("Launch AI coding agents with ModelWorks circuits")
|
|
652
|
+
.version(BUILD_ID, "-v, --version")
|
|
653
|
+
.option("--api-base <url>", "Override API base URL")
|
|
654
|
+
.option("--verbose", "Verbose output (proxy request logging)")
|
|
655
|
+
.option("--debug", "Debug mode (full proxy request/response logging to .quack/debug.log)")
|
|
656
|
+
.helpOption("-h, --help", "Show help");
|
|
657
|
+
// init
|
|
658
|
+
program
|
|
659
|
+
.command("init")
|
|
660
|
+
.description("Initialize a quack project (.quack/ directory)")
|
|
661
|
+
.option("--force", "Overwrite existing .quack/ directory")
|
|
662
|
+
.action(async (opts) => {
|
|
663
|
+
requireAuth();
|
|
664
|
+
await cmdInit(opts);
|
|
665
|
+
});
|
|
666
|
+
// circuit group
|
|
667
|
+
const circuit = program
|
|
668
|
+
.command("circuit")
|
|
669
|
+
.description("Manage circuits in this project");
|
|
670
|
+
circuit
|
|
671
|
+
.command("list")
|
|
672
|
+
.description("List circuits and their adapters")
|
|
673
|
+
.option("-l, --long", "Show full UIDs (CIRCUIT UID column)")
|
|
674
|
+
.action(async (opts) => {
|
|
675
|
+
requireAuth();
|
|
676
|
+
await cmdCircuitList(opts);
|
|
677
|
+
});
|
|
678
|
+
circuit
|
|
679
|
+
.command("add <url>")
|
|
680
|
+
.description("Add a circuit by URL or UID")
|
|
681
|
+
.action(async (url) => {
|
|
682
|
+
requireAuth();
|
|
683
|
+
await cmdCircuitAdd(url);
|
|
684
|
+
});
|
|
685
|
+
circuit
|
|
686
|
+
.command("remove <uid>")
|
|
687
|
+
.description("Remove a circuit from this project")
|
|
688
|
+
.action(async (uid) => {
|
|
689
|
+
requireAuth();
|
|
690
|
+
await cmdCircuitRemove(uid);
|
|
691
|
+
});
|
|
692
|
+
circuit
|
|
693
|
+
.command("refresh [uid]")
|
|
694
|
+
.description("Re-resolve circuit version and adapters")
|
|
695
|
+
.action(async (uid) => {
|
|
696
|
+
requireAuth();
|
|
697
|
+
await cmdCircuitRefresh(uid);
|
|
698
|
+
});
|
|
699
|
+
circuit
|
|
700
|
+
.command("describe <uid>")
|
|
701
|
+
.description("Show detailed information about a circuit")
|
|
702
|
+
.action(async (uid) => {
|
|
703
|
+
requireAuth();
|
|
704
|
+
await cmdCircuitDescribe(uid);
|
|
705
|
+
});
|
|
706
|
+
circuit
|
|
707
|
+
.command("use <uid>")
|
|
708
|
+
.description("Set the default circuit (used as the default model in OpenCode)")
|
|
709
|
+
.action(async (uid) => {
|
|
710
|
+
requireAuth();
|
|
711
|
+
cmdCircuitUse(uid);
|
|
712
|
+
});
|
|
713
|
+
// agent group
|
|
714
|
+
const agent = program
|
|
715
|
+
.command("agent")
|
|
716
|
+
.description("List and inspect available agents");
|
|
717
|
+
agent
|
|
718
|
+
.command("list")
|
|
719
|
+
.description("List all available agents")
|
|
720
|
+
.action(async () => {
|
|
721
|
+
await cmdAgentList();
|
|
722
|
+
});
|
|
723
|
+
agent
|
|
724
|
+
.command("describe <name>")
|
|
725
|
+
.description("Show details about a specific agent")
|
|
726
|
+
.action(async (name) => {
|
|
727
|
+
await cmdAgentDescribe(name);
|
|
728
|
+
});
|
|
729
|
+
agent
|
|
730
|
+
.command("set <name>")
|
|
731
|
+
.description("Set the default agent (written to .quack/config.json)")
|
|
732
|
+
.action(async (name) => {
|
|
733
|
+
await cmdAgentSet(name);
|
|
734
|
+
});
|
|
735
|
+
// auth group
|
|
736
|
+
const auth = program
|
|
737
|
+
.command("auth")
|
|
738
|
+
.description("Authentication commands");
|
|
739
|
+
auth
|
|
740
|
+
.command("login")
|
|
741
|
+
.description("Login via OAuth (opens browser)")
|
|
742
|
+
.action(async () => {
|
|
743
|
+
const opts = program.opts();
|
|
744
|
+
await cmdAuthLogin(opts.apiBase);
|
|
745
|
+
});
|
|
746
|
+
auth
|
|
747
|
+
.command("logout")
|
|
748
|
+
.description("Logout and revoke credentials")
|
|
749
|
+
.action(async () => {
|
|
750
|
+
await cmdAuthLogout();
|
|
751
|
+
});
|
|
752
|
+
auth
|
|
753
|
+
.command("status")
|
|
754
|
+
.description("Show current user and token status")
|
|
755
|
+
.action(async () => {
|
|
756
|
+
await cmdAuthStatus();
|
|
757
|
+
});
|
|
758
|
+
// opencode — launch OpenCode (built-in agent)
|
|
759
|
+
program
|
|
760
|
+
.command("opencode [args...]")
|
|
761
|
+
.description("Launch OpenCode with SSE circuits as model providers")
|
|
762
|
+
.option("--print", "Show injected config, don't launch")
|
|
763
|
+
.allowUnknownOption(true)
|
|
764
|
+
.action(async (args, opts) => {
|
|
765
|
+
requireAuth();
|
|
766
|
+
await cmdAgentLaunch("opencode", args ?? [], opts);
|
|
767
|
+
});
|
|
768
|
+
// history — list and show circuit run history
|
|
769
|
+
program
|
|
770
|
+
.command("history [filter]")
|
|
771
|
+
.description("Show circuit run history (list, or show a specific run by UID)")
|
|
772
|
+
.option("--user <name>", "Filter by caller username")
|
|
773
|
+
.option("--circuit <uid>", "Filter by circuit UID")
|
|
774
|
+
.action(async (filter, opts) => {
|
|
775
|
+
requireAuth();
|
|
776
|
+
await cmdHistory(filter, opts);
|
|
777
|
+
});
|
|
778
|
+
// Bare `quack` — launch the default agent.
|
|
779
|
+
// Commander calls the default action when no subcommand is given.
|
|
780
|
+
program.action(async (opts) => {
|
|
781
|
+
requireAuth();
|
|
782
|
+
await cmdQuack(opts);
|
|
783
|
+
});
|
|
784
|
+
return program;
|
|
785
|
+
}
|
|
786
|
+
// ---------------------------------------------------------------------------
|
|
787
|
+
// Main
|
|
788
|
+
// ---------------------------------------------------------------------------
|
|
789
|
+
async function main() {
|
|
790
|
+
const program = buildCli();
|
|
791
|
+
const argv = process.argv.slice(2);
|
|
792
|
+
// Set QUACK_API_BASE from the global --api-base flag before any
|
|
793
|
+
// command handler runs, so effectiveApiBase() picks it up.
|
|
794
|
+
const apiBaseIdx = argv.indexOf("--api-base");
|
|
795
|
+
if (apiBaseIdx !== -1 && apiBaseIdx + 1 < argv.length) {
|
|
796
|
+
process.env.QUACK_API_BASE = argv[apiBaseIdx + 1];
|
|
797
|
+
}
|
|
798
|
+
try {
|
|
799
|
+
await program.parseAsync(process.argv);
|
|
800
|
+
}
|
|
801
|
+
catch (err) {
|
|
802
|
+
console.error(`error: ${err.message}`);
|
|
803
|
+
process.exit(1);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
main();
|