@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/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 +787 -0
- package/dist/build-info.json +8 -0
- package/dist/circuit.d.ts +115 -0
- package/dist/circuit.js +393 -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 +326 -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 +203 -0
- package/dist/types.d.ts +239 -0
- package/dist/types.js +6 -0
- package/package.json +50 -4
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode agent — injects SSE circuits as OpenAI-compatible providers
|
|
3
|
+
* via `OPENCODE_CONFIG_CONTENT`, plus the global `modelworks` MCP server.
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { refreshToken } from "../auth.js";
|
|
12
|
+
import { loadCredentials } from "../auth.js";
|
|
13
|
+
import { readState, readConfig } from "../project.js";
|
|
14
|
+
import { startProxy } from "../proxy.js";
|
|
15
|
+
import { fetchRunFromApi, saveRunRecord } from "../history.js";
|
|
16
|
+
import buildInfo from "../build-info.json" with { type: "json" };
|
|
17
|
+
const AGENT_VERSION = buildInfo.version;
|
|
18
|
+
const AGENT_BUILD = `${AGENT_VERSION}+${buildInfo.build} (${buildInfo.commit}${buildInfo.dirty ? "-dirty" : ""})`;
|
|
19
|
+
/** `npm` package for OpenAI-compatible providers. */
|
|
20
|
+
const OPENAI_COMPATIBLE_PACKAGE = "@opencode/ai/providers/openai-compatible";
|
|
21
|
+
/** Detect whether a binary is on PATH. */
|
|
22
|
+
function hasBinary(bin) {
|
|
23
|
+
try {
|
|
24
|
+
const cmd = process.platform === "win32" ? "where" : "which";
|
|
25
|
+
const result = spawnSync(cmd, [bin], { stdio: "ignore" });
|
|
26
|
+
return result.status === 0;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Check if a file exists and is executable at a specific path. */
|
|
33
|
+
function hasBinaryAt(binPath) {
|
|
34
|
+
try {
|
|
35
|
+
fs.accessSync(binPath, fs.constants.X_OK);
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** Resolve the opencode binary: prefer V2 (MCP 2.0 support), fall back to V1. */
|
|
43
|
+
function resolveOpencodeBinary() {
|
|
44
|
+
// V2: on PATH as "opencode2", or at ~/.opencode/bin/opencode2
|
|
45
|
+
if (hasBinary("opencode2"))
|
|
46
|
+
return { bin: "opencode2", isV2: true };
|
|
47
|
+
const v2Path = path.join(os.homedir(), ".opencode", "bin", "opencode2");
|
|
48
|
+
if (hasBinaryAt(v2Path))
|
|
49
|
+
return { bin: v2Path, isV2: true };
|
|
50
|
+
// V1: on PATH as "opencode"
|
|
51
|
+
if (hasBinary("opencode"))
|
|
52
|
+
return { bin: "opencode", isV2: false };
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
/** Install methods for opencode, in priority order. */
|
|
56
|
+
const OPENCODE_INSTALL = [
|
|
57
|
+
{ label: "curl", cmd: "curl -fsSL https://opencode.ai/install | bash", check: () => hasBinary("curl") },
|
|
58
|
+
{ label: "npm", cmd: "npm i -g @opencode/cli", check: () => hasBinary("npm") },
|
|
59
|
+
{ label: "bun", cmd: "bun install -g @opencode/cli", check: () => hasBinary("bun") },
|
|
60
|
+
{ label: "brew", cmd: "brew install opencode", check: () => hasBinary("brew") },
|
|
61
|
+
];
|
|
62
|
+
/** Should this circuit be injected as an OpenAI-compatible provider? */
|
|
63
|
+
function isInjectableSseCircuit(entry) {
|
|
64
|
+
return (entry.adapters.includes("OPEN_AI_SSE") && entry.sse_adaptor_uid !== null);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Resolve which circuit to use as the default OpenCode model.
|
|
68
|
+
*
|
|
69
|
+
* Resolution order:
|
|
70
|
+
* 1. `config.default_circuit` if it's SSE-compatible and injected
|
|
71
|
+
* 2. First injectable SSE circuit in state (fallback, not persisted)
|
|
72
|
+
* 3. `null` — no default model injected
|
|
73
|
+
*
|
|
74
|
+
* Prints a warning to stderr if `default_circuit` is stale (not in the
|
|
75
|
+
* circuits array) or not SSE-compatible.
|
|
76
|
+
*
|
|
77
|
+
* @returns the OpenCode model ID (`circuit-<uid>/<uid>`) or `null`.
|
|
78
|
+
*/
|
|
79
|
+
export function resolveDefaultModel(config, state) {
|
|
80
|
+
const injectedCircuits = Object.values(state.circuits).filter(isInjectableSseCircuit);
|
|
81
|
+
if (injectedCircuits.length === 0)
|
|
82
|
+
return null;
|
|
83
|
+
const configured = config.default_circuit;
|
|
84
|
+
if (configured) {
|
|
85
|
+
// Check the circuit exists in the config's circuits array.
|
|
86
|
+
const inConfig = config.circuits.some((c) => c.circuit_uid === configured);
|
|
87
|
+
if (!inConfig) {
|
|
88
|
+
console.error(`⚠ default_circuit '${configured}' not found in circuits — using ${injectedCircuits[0].circuit_name} as default`);
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
const entry = state.circuits[configured];
|
|
92
|
+
if (entry && isInjectableSseCircuit(entry)) {
|
|
93
|
+
return `circuit-${configured}/${configured}`;
|
|
94
|
+
}
|
|
95
|
+
console.error(`⚠ default_circuit '${configured}' is not SSE-compatible — using ${injectedCircuits[0].circuit_name} as default`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// Fallback: first injectable SSE circuit.
|
|
99
|
+
const first = injectedCircuits[0];
|
|
100
|
+
return `circuit-${first.circuit_uid}/${first.circuit_uid}`;
|
|
101
|
+
}
|
|
102
|
+
/** Build the OpenCode config from project state. */
|
|
103
|
+
export function buildOpenCodeConfig(state, port, defaultModel) {
|
|
104
|
+
const proxyBase = `http://localhost:${port}`;
|
|
105
|
+
const config = {
|
|
106
|
+
$schema: "https://opencode.ai/config.json",
|
|
107
|
+
mcp: {
|
|
108
|
+
servers: {
|
|
109
|
+
"ModelWorks x.MCP": {
|
|
110
|
+
type: "remote",
|
|
111
|
+
url: `${proxyBase}/api/x.mcp`,
|
|
112
|
+
oauth: false,
|
|
113
|
+
// x.mcp requires MCP 2.0 (server/discover) and rejects the
|
|
114
|
+
// legacy initialize handshake. "auto" probes then falls back
|
|
115
|
+
// to legacy, which gets rejected — so force 2026-07-28.
|
|
116
|
+
protocol: "2026-07-28",
|
|
117
|
+
// Dummy auth header — the proxy overrides it with the real
|
|
118
|
+
// Bearer token. Without this, OpenCode sees no auth config and
|
|
119
|
+
// prompts the user to authenticate ("MCP Authentication Required").
|
|
120
|
+
headers: {
|
|
121
|
+
Authorization: "Bearer quack",
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
providers: {},
|
|
127
|
+
};
|
|
128
|
+
if (defaultModel) {
|
|
129
|
+
config.model = defaultModel;
|
|
130
|
+
}
|
|
131
|
+
for (const entry of Object.values(state.circuits)) {
|
|
132
|
+
if (!isInjectableSseCircuit(entry))
|
|
133
|
+
continue;
|
|
134
|
+
const providerKey = `circuit-${entry.circuit_uid}`;
|
|
135
|
+
const provider = {
|
|
136
|
+
name: "ModelWorks",
|
|
137
|
+
package: OPENAI_COMPATIBLE_PACKAGE,
|
|
138
|
+
settings: {
|
|
139
|
+
baseURL: `${proxyBase}/api/circuit-adaptors/${entry.sse_adaptor_uid}/query`,
|
|
140
|
+
// Dummy apiKey — the proxy overrides the Authorization header with the real token.
|
|
141
|
+
apiKey: "quack",
|
|
142
|
+
},
|
|
143
|
+
models: {
|
|
144
|
+
[entry.circuit_uid]: { name: `${entry.owner_username}/${entry.circuit_name}` },
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
config.providers[providerKey] = provider;
|
|
148
|
+
}
|
|
149
|
+
return config;
|
|
150
|
+
}
|
|
151
|
+
export function summarizeInjection(state) {
|
|
152
|
+
const injected = [];
|
|
153
|
+
const skipped = [];
|
|
154
|
+
for (const entry of Object.values(state.circuits)) {
|
|
155
|
+
if (isInjectableSseCircuit(entry)) {
|
|
156
|
+
injected.push({
|
|
157
|
+
circuit_uid: entry.circuit_uid,
|
|
158
|
+
name: entry.circuit_name,
|
|
159
|
+
sse_adaptor_uid: entry.sse_adaptor_uid,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
else if (!entry.adapters.includes("OPEN_AI_SSE")) {
|
|
163
|
+
skipped.push({
|
|
164
|
+
circuit_uid: entry.circuit_uid,
|
|
165
|
+
name: entry.circuit_name,
|
|
166
|
+
reason: "not SSE-compatible",
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
else if (entry.sse_adaptor_uid === null) {
|
|
170
|
+
skipped.push({
|
|
171
|
+
circuit_uid: entry.circuit_uid,
|
|
172
|
+
name: entry.circuit_name,
|
|
173
|
+
reason: "no SSE adaptor",
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return { injected, skipped };
|
|
178
|
+
}
|
|
179
|
+
/** Print install hints when the `opencode` binary is missing. */
|
|
180
|
+
function printInstallHint() {
|
|
181
|
+
console.error("\n✗ opencode is not installed.\n");
|
|
182
|
+
console.error("Install with one of:");
|
|
183
|
+
for (const method of OPENCODE_INSTALL) {
|
|
184
|
+
const detected = method.check() ? " (detected)" : "";
|
|
185
|
+
console.error(` ${method.label.padEnd(5)} ${method.cmd}${detected}`);
|
|
186
|
+
}
|
|
187
|
+
console.error("");
|
|
188
|
+
}
|
|
189
|
+
/** Load credentials for the history callback. Returns null if not logged in. */
|
|
190
|
+
function loadCredentialsForHistory() {
|
|
191
|
+
return loadCredentials();
|
|
192
|
+
}
|
|
193
|
+
/** Resolve the caller's username from the API (for run history records). */
|
|
194
|
+
async function resolveCallerUsername(creds) {
|
|
195
|
+
try {
|
|
196
|
+
const res = await fetch(`${creds.api_base}/api/users/token`, {
|
|
197
|
+
headers: { Authorization: `Bearer ${creds.access_token}` },
|
|
198
|
+
});
|
|
199
|
+
if (res.ok) {
|
|
200
|
+
const body = (await res.json());
|
|
201
|
+
return body.payload?.username ?? creds.user_uid;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
// Fall back to user_uid.
|
|
206
|
+
}
|
|
207
|
+
return creds.user_uid;
|
|
208
|
+
}
|
|
209
|
+
/** The OpenCode agent plugin. */
|
|
210
|
+
export const opencodeAgent = {
|
|
211
|
+
name: "opencode",
|
|
212
|
+
description: "Launch OpenCode with SSE circuits as model providers",
|
|
213
|
+
binary: "opencode",
|
|
214
|
+
installHints: OPENCODE_INSTALL.map((m) => `${m.label}: ${m.cmd}`),
|
|
215
|
+
buildConfig(ctx) {
|
|
216
|
+
const defaultModel = resolveDefaultModel(ctx.config, ctx.state);
|
|
217
|
+
const config = buildOpenCodeConfig(ctx.state, ctx.proxyPort, defaultModel);
|
|
218
|
+
return {
|
|
219
|
+
OPENCODE_CONFIG: JSON.stringify(config, null, 2),
|
|
220
|
+
};
|
|
221
|
+
},
|
|
222
|
+
async launch(ctx, opts) {
|
|
223
|
+
// Resolve the opencode binary (prefer V2 for MCP 2.0 support).
|
|
224
|
+
const resolved = resolveOpencodeBinary();
|
|
225
|
+
if (opts.debug) {
|
|
226
|
+
console.error(`[quack] resolved opencode binary: ${resolved ? resolved.bin + " (V" + (resolved.isV2 ? "2" : "1") + ")" : "null"}`);
|
|
227
|
+
}
|
|
228
|
+
if (!opts.print && !resolved) {
|
|
229
|
+
printInstallHint();
|
|
230
|
+
return { exitCode: 1, proxyPort: 0 };
|
|
231
|
+
}
|
|
232
|
+
// Open debug log file if --debug is set.
|
|
233
|
+
let debugFd;
|
|
234
|
+
let debugPath;
|
|
235
|
+
const writeDebug = (entry) => {
|
|
236
|
+
if (debugFd !== undefined) {
|
|
237
|
+
fs.writeSync(debugFd, entry + "\n");
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
if (opts.debug && !opts.print) {
|
|
241
|
+
debugPath = path.join(process.cwd(), ".quack", "debug.log");
|
|
242
|
+
// Truncate the file at start.
|
|
243
|
+
try {
|
|
244
|
+
fs.writeFileSync(debugPath, "");
|
|
245
|
+
debugFd = fs.openSync(debugPath, "a");
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
// If .quack/ doesn't exist, fall back to /tmp.
|
|
249
|
+
debugPath = `/tmp/quack-debug-${Date.now()}.log`;
|
|
250
|
+
fs.writeFileSync(debugPath, "");
|
|
251
|
+
debugFd = fs.openSync(debugPath, "a");
|
|
252
|
+
}
|
|
253
|
+
process.stderr.write(`[quack] debug log: ${debugPath}\n`);
|
|
254
|
+
}
|
|
255
|
+
// Start the proxy.
|
|
256
|
+
const proxy = await startProxy({
|
|
257
|
+
apiBase: ctx.apiBase,
|
|
258
|
+
getAccessToken: ctx.getAccessToken,
|
|
259
|
+
refreshToken,
|
|
260
|
+
onLog: (opts.verbose || opts.debug)
|
|
261
|
+
? (method, path, status) => {
|
|
262
|
+
process.stderr.write(`[quack] ${method} ${path} → ${status}\n`);
|
|
263
|
+
}
|
|
264
|
+
: undefined,
|
|
265
|
+
onDebug: opts.debug
|
|
266
|
+
? (entry) => writeDebug(entry)
|
|
267
|
+
: undefined,
|
|
268
|
+
// Fire-and-forget: fetch and save run records after each stream completes.
|
|
269
|
+
onRunComplete: opts.print
|
|
270
|
+
? undefined
|
|
271
|
+
: (runUid) => {
|
|
272
|
+
// Don't block the proxy — fire and forget.
|
|
273
|
+
void (async () => {
|
|
274
|
+
try {
|
|
275
|
+
const creds = loadCredentialsForHistory();
|
|
276
|
+
if (!creds)
|
|
277
|
+
return;
|
|
278
|
+
const currentState = readState();
|
|
279
|
+
const callerUsername = await resolveCallerUsername(creds);
|
|
280
|
+
const record = await fetchRunFromApi(runUid, creds, currentState.circuits, callerUsername);
|
|
281
|
+
if (record)
|
|
282
|
+
saveRunRecord(record);
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
// Silent — don't crash the proxy on history save failures.
|
|
286
|
+
}
|
|
287
|
+
})();
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
if (opts.verbose || opts.debug) {
|
|
291
|
+
process.stderr.write(`[quack] proxy listening on http://localhost:${proxy.port} → ${ctx.apiBase}\n`);
|
|
292
|
+
}
|
|
293
|
+
// Build the config.
|
|
294
|
+
const state = readState();
|
|
295
|
+
const config = readConfig();
|
|
296
|
+
const defaultModel = resolveDefaultModel(config, state);
|
|
297
|
+
const openCodeConfig = buildOpenCodeConfig(state, proxy.port, defaultModel);
|
|
298
|
+
const configJson = JSON.stringify(openCodeConfig, null, 2);
|
|
299
|
+
const summary = summarizeInjection(state);
|
|
300
|
+
if (opts.print) {
|
|
301
|
+
console.log(`Proxy: http://localhost:${proxy.port} → ${ctx.apiBase}`);
|
|
302
|
+
console.log("");
|
|
303
|
+
console.log("Injected OpenCode config (OPENCODE_CONFIG):");
|
|
304
|
+
console.log(configJson);
|
|
305
|
+
console.log("");
|
|
306
|
+
console.log(`Injected ${summary.injected.length} circuit(s) as OpenAI-compatible providers:`);
|
|
307
|
+
for (const c of summary.injected) {
|
|
308
|
+
console.log(` ✓ ${c.name} (${c.circuit_uid}) → ${c.sse_adaptor_uid}`);
|
|
309
|
+
}
|
|
310
|
+
if (summary.skipped.length > 0) {
|
|
311
|
+
console.log(`Skipped ${summary.skipped.length} circuit(s):`);
|
|
312
|
+
for (const c of summary.skipped) {
|
|
313
|
+
console.log(` ✗ ${c.name} (${c.circuit_uid}) — ${c.reason}`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
proxy.close();
|
|
317
|
+
return { exitCode: null, proxyPort: proxy.port };
|
|
318
|
+
}
|
|
319
|
+
// Write the config to a temp file and point OPENCODE_CONFIG at it.
|
|
320
|
+
// V1's OPENCODE_CONFIG_CONTENT (inline JSON) works for simple top-level
|
|
321
|
+
// overrides like `model`, but doesn't reliably deep-merge `mcp.servers`.
|
|
322
|
+
// OPENCODE_CONFIG (file path, "custom-path" scope) is what the user's
|
|
323
|
+
// own wrapper uses for full configs with MCP servers.
|
|
324
|
+
//
|
|
325
|
+
// The filename includes the quack version + timestamp to avoid any
|
|
326
|
+
// config caching by file path.
|
|
327
|
+
const configFile = path.join(os.tmpdir(), `quack-opencode-config-v${AGENT_VERSION}-${Date.now()}-${proxy.port}.json`);
|
|
328
|
+
fs.writeFileSync(configFile, configJson);
|
|
329
|
+
// Write startup info to the debug log.
|
|
330
|
+
// Spawn opencode with the config file path injected via env.
|
|
331
|
+
// V2 uses a background service — pass --standalone so it runs a private
|
|
332
|
+
// server that picks up OPENCODE_CONFIG fresh every launch.
|
|
333
|
+
const spawnArgs = resolved?.isV2
|
|
334
|
+
? ["--standalone", "--log-level", "debug", ...opts.args]
|
|
335
|
+
: ["--log-level", "debug", ...opts.args];
|
|
336
|
+
if (opts.debug) {
|
|
337
|
+
writeDebug(`=== quack startup ${new Date().toISOString()} ===`);
|
|
338
|
+
writeDebug(`quack version: ${AGENT_BUILD}`);
|
|
339
|
+
writeDebug(`quack PID: ${process.pid}`);
|
|
340
|
+
writeDebug(`opencode binary: ${resolved.bin} (V${resolved?.isV2 ? "2" : "1"})`);
|
|
341
|
+
writeDebug(`spawn args: ${JSON.stringify(spawnArgs)}`);
|
|
342
|
+
writeDebug(`config file: ${configFile}`);
|
|
343
|
+
writeDebug(`config:\n${configJson}`);
|
|
344
|
+
writeDebug("");
|
|
345
|
+
}
|
|
346
|
+
if (opts.debug || opts.verbose) {
|
|
347
|
+
console.error(`[quack] spawning opencode: ${resolved.bin} ${spawnArgs.join(" ")}`);
|
|
348
|
+
console.error(`[quack] OPENCODE_CONFIG: ${configFile}`);
|
|
349
|
+
console.error(`[quack] quack PID: ${process.pid}`);
|
|
350
|
+
}
|
|
351
|
+
const child = spawn(resolved.bin, spawnArgs, {
|
|
352
|
+
stdio: "inherit",
|
|
353
|
+
env: {
|
|
354
|
+
...process.env,
|
|
355
|
+
OPENCODE_CONFIG: configFile,
|
|
356
|
+
// Clear any inherited OPENCODE_CONFIG_CONTENT from the user's shell
|
|
357
|
+
// (e.g. from oc.sh wrapper) — it would override our model setting.
|
|
358
|
+
OPENCODE_CONFIG_CONTENT: "",
|
|
359
|
+
QUACK_API_KEY: "quack",
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
// Log the opencode PID once it spawns.
|
|
363
|
+
if (opts.debug || opts.verbose) {
|
|
364
|
+
child.on("spawn", () => {
|
|
365
|
+
console.error(`[quack] opencode PID: ${child.pid}`);
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
// Forward signals to opencode.
|
|
369
|
+
const forwardSignal = (sig) => {
|
|
370
|
+
if (!child.killed)
|
|
371
|
+
child.kill(sig);
|
|
372
|
+
};
|
|
373
|
+
process.on("SIGINT", () => forwardSignal("SIGINT"));
|
|
374
|
+
process.on("SIGTERM", () => forwardSignal("SIGTERM"));
|
|
375
|
+
// Wait for opencode to exit, then close the proxy.
|
|
376
|
+
const exitCode = await new Promise((resolve) => {
|
|
377
|
+
child.on("exit", (code) => {
|
|
378
|
+
resolve(code ?? 0);
|
|
379
|
+
});
|
|
380
|
+
child.on("error", (err) => {
|
|
381
|
+
if (err.code === "ENOENT") {
|
|
382
|
+
printInstallHint();
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
console.error(`opencode failed to start: ${err.message}`);
|
|
386
|
+
}
|
|
387
|
+
resolve(1);
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
// Clean up the temp config file.
|
|
391
|
+
try {
|
|
392
|
+
fs.unlinkSync(configFile);
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
// Ignore cleanup errors.
|
|
396
|
+
}
|
|
397
|
+
proxy.close();
|
|
398
|
+
// Close debug log file if open.
|
|
399
|
+
if (debugFd !== undefined) {
|
|
400
|
+
try {
|
|
401
|
+
fs.closeSync(debugFd);
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
// Ignore.
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return { exitCode, proxyPort: proxy.port };
|
|
408
|
+
},
|
|
409
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent registry — lists all built-in agents and resolves which one
|
|
3
|
+
* to launch when the user runs bare `quack` (no args).
|
|
4
|
+
*
|
|
5
|
+
* Adding a new agent: create `src/agents/<name>.ts` exporting an
|
|
6
|
+
* `Agent`, then add it to the `BUILTIN_AGENTS` array below. It will
|
|
7
|
+
* automatically appear in `quack agents list` and become available
|
|
8
|
+
* as `quack <name>`.
|
|
9
|
+
*
|
|
10
|
+
* @module
|
|
11
|
+
*/
|
|
12
|
+
import type { Agent } from "./types.js";
|
|
13
|
+
/** All agents built into this version of quack. */
|
|
14
|
+
export declare const BUILTIN_AGENTS: Agent[];
|
|
15
|
+
/** Detect whether a binary is on PATH. */
|
|
16
|
+
export declare function hasBinary(bin: string): boolean;
|
|
17
|
+
/** Check if a file exists and is executable at a specific path. */
|
|
18
|
+
export declare function hasBinaryAt(binPath: string): boolean;
|
|
19
|
+
/** Is this agent's binary installed (on PATH or at a known location)? */
|
|
20
|
+
export declare function isAgentAvailable(agent: Agent): boolean;
|
|
21
|
+
/** List all built-in agents. */
|
|
22
|
+
export declare function listAgents(): Agent[];
|
|
23
|
+
/** Look up an agent by name. Returns `undefined` if not found. */
|
|
24
|
+
export declare function getAgent(name: string): Agent | undefined;
|
|
25
|
+
/** List agents whose binary is installed on PATH. */
|
|
26
|
+
export declare function listAvailableAgents(): Agent[];
|
|
27
|
+
/** Whether stdin is an interactive TTY (for the picker prompt). */
|
|
28
|
+
export declare function isInteractive(): boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Result of resolving which agent to launch for bare `quack`.
|
|
31
|
+
*
|
|
32
|
+
* - `agent` — the resolved agent (binary is installed).
|
|
33
|
+
* - `error` — resolution failed; `message` explains what to do.
|
|
34
|
+
*/
|
|
35
|
+
export type ResolveResult = {
|
|
36
|
+
ok: true;
|
|
37
|
+
agent: Agent;
|
|
38
|
+
} | {
|
|
39
|
+
ok: false;
|
|
40
|
+
message: string;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Resolve which agent to launch for bare `quack`.
|
|
44
|
+
*
|
|
45
|
+
* Resolution order:
|
|
46
|
+
* 1. `default_agent` from config.json — validate + availability check
|
|
47
|
+
* 2. `last_agent` from state.json — validate + availability check
|
|
48
|
+
* 3. Interactive picker (if TTY) — shows installed agents to pick from
|
|
49
|
+
* 4. Non-interactive — error with guidance
|
|
50
|
+
*
|
|
51
|
+
* `available`/`last` are caller-provided to keep this function pure
|
|
52
|
+
* (no file I/O). The caller reads config/state and passes the values.
|
|
53
|
+
*/
|
|
54
|
+
export declare function resolveAgent(defaultAgent: string | undefined, lastAgent: string | undefined): ResolveResult;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent registry — lists all built-in agents and resolves which one
|
|
3
|
+
* to launch when the user runs bare `quack` (no args).
|
|
4
|
+
*
|
|
5
|
+
* Adding a new agent: create `src/agents/<name>.ts` exporting an
|
|
6
|
+
* `Agent`, then add it to the `BUILTIN_AGENTS` array below. It will
|
|
7
|
+
* automatically appear in `quack agents list` and become available
|
|
8
|
+
* as `quack <name>`.
|
|
9
|
+
*
|
|
10
|
+
* @module
|
|
11
|
+
*/
|
|
12
|
+
import { spawnSync } from "node:child_process";
|
|
13
|
+
import { accessSync, constants } from "node:fs";
|
|
14
|
+
import { opencodeAgent } from "./opencode.js";
|
|
15
|
+
/** All agents built into this version of quack. */
|
|
16
|
+
export const BUILTIN_AGENTS = [
|
|
17
|
+
opencodeAgent,
|
|
18
|
+
];
|
|
19
|
+
/** Detect whether a binary is on PATH. */
|
|
20
|
+
export function hasBinary(bin) {
|
|
21
|
+
try {
|
|
22
|
+
const cmd = process.platform === "win32" ? "where" : "which";
|
|
23
|
+
const result = spawnSync(cmd, [bin], { stdio: "ignore" });
|
|
24
|
+
return result.status === 0;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Check if a file exists and is executable at a specific path. */
|
|
31
|
+
export function hasBinaryAt(binPath) {
|
|
32
|
+
try {
|
|
33
|
+
accessSync(binPath, constants.X_OK);
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Is this agent's binary installed (on PATH or at a known location)? */
|
|
41
|
+
export function isAgentAvailable(agent) {
|
|
42
|
+
// opencode: check both V2 (opencode2) and V1 (opencode)
|
|
43
|
+
if (agent.name === "opencode") {
|
|
44
|
+
if (hasBinary("opencode2"))
|
|
45
|
+
return true;
|
|
46
|
+
const home = process.env.HOME ?? "";
|
|
47
|
+
if (home && hasBinaryAt(`${home}/.opencode/bin/opencode2`))
|
|
48
|
+
return true;
|
|
49
|
+
if (hasBinary("opencode"))
|
|
50
|
+
return true;
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
return hasBinary(agent.binary);
|
|
54
|
+
}
|
|
55
|
+
/** List all built-in agents. */
|
|
56
|
+
export function listAgents() {
|
|
57
|
+
return BUILTIN_AGENTS;
|
|
58
|
+
}
|
|
59
|
+
/** Look up an agent by name. Returns `undefined` if not found. */
|
|
60
|
+
export function getAgent(name) {
|
|
61
|
+
return BUILTIN_AGENTS.find((a) => a.name === name);
|
|
62
|
+
}
|
|
63
|
+
/** List agents whose binary is installed on PATH. */
|
|
64
|
+
export function listAvailableAgents() {
|
|
65
|
+
return BUILTIN_AGENTS.filter(isAgentAvailable);
|
|
66
|
+
}
|
|
67
|
+
/** Whether stdin is an interactive TTY (for the picker prompt). */
|
|
68
|
+
export function isInteractive() {
|
|
69
|
+
return process.stdin.isTTY === true;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Resolve which agent to launch for bare `quack`.
|
|
73
|
+
*
|
|
74
|
+
* Resolution order:
|
|
75
|
+
* 1. `default_agent` from config.json — validate + availability check
|
|
76
|
+
* 2. `last_agent` from state.json — validate + availability check
|
|
77
|
+
* 3. Interactive picker (if TTY) — shows installed agents to pick from
|
|
78
|
+
* 4. Non-interactive — error with guidance
|
|
79
|
+
*
|
|
80
|
+
* `available`/`last` are caller-provided to keep this function pure
|
|
81
|
+
* (no file I/O). The caller reads config/state and passes the values.
|
|
82
|
+
*/
|
|
83
|
+
export function resolveAgent(defaultAgent, lastAgent) {
|
|
84
|
+
// 1. config.json default_agent
|
|
85
|
+
if (defaultAgent) {
|
|
86
|
+
const agent = getAgent(defaultAgent);
|
|
87
|
+
if (!agent) {
|
|
88
|
+
return {
|
|
89
|
+
ok: false,
|
|
90
|
+
message: `Default agent "${defaultAgent}" is not a known agent. Supported: ${BUILTIN_AGENTS.map((a) => a.name).join(", ")}. Fix .quack/config.json.`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
if (!isAgentAvailable(agent)) {
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
message: `Default agent "${defaultAgent}" is not installed. Install it, or change "default_agent" in .quack/config.json to an installed agent.\n\nSupported agents: ${BUILTIN_AGENTS.map((a) => ` ${a.name} (${a.binary})`).join("\n")}`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
return { ok: true, agent };
|
|
100
|
+
}
|
|
101
|
+
// 2. state.json last_agent
|
|
102
|
+
if (lastAgent) {
|
|
103
|
+
const agent = getAgent(lastAgent);
|
|
104
|
+
if (agent && isAgentAvailable(agent)) {
|
|
105
|
+
return { ok: true, agent };
|
|
106
|
+
}
|
|
107
|
+
// Fall through to picker if last_agent is gone/uninstalled.
|
|
108
|
+
}
|
|
109
|
+
// 3. Interactive picker — only if TTY
|
|
110
|
+
const available = listAvailableAgents();
|
|
111
|
+
if (available.length === 0) {
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
message: `No supported agent is installed. Install one of:\n${BUILTIN_AGENTS.map((a) => ` ${a.name} (${a.binary})`).join("\n")}`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
if (!isInteractive()) {
|
|
118
|
+
// Non-interactive: if exactly one agent is available, use it.
|
|
119
|
+
if (available.length === 1) {
|
|
120
|
+
return { ok: true, agent: available[0] };
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
ok: false,
|
|
124
|
+
message: `No default agent set and multiple are installed. Set "default_agent" in .quack/config.json to one of: ${available.map((a) => a.name).join(", ")}.`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
// Interactive with one available agent — just use it (no pointless prompt).
|
|
128
|
+
if (available.length === 1) {
|
|
129
|
+
return { ok: true, agent: available[0] };
|
|
130
|
+
}
|
|
131
|
+
// Interactive with multiple — will be handled by the caller's prompt.
|
|
132
|
+
// Signal that a prompt is needed.
|
|
133
|
+
return { ok: false, message: "__PROMPT__" };
|
|
134
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent plugin interface.
|
|
3
|
+
*
|
|
4
|
+
* An agent is a tool that quack can launch with ModelWorks circuits
|
|
5
|
+
* wired in (e.g. OpenCode, Cursor, Claude Code). Agents are built into
|
|
6
|
+
* quack — no external installation. New agents appear automatically
|
|
7
|
+
* when quack is updated.
|
|
8
|
+
*
|
|
9
|
+
* @module
|
|
10
|
+
*/
|
|
11
|
+
import type { QuackConfig, QuackState } from "../types.js";
|
|
12
|
+
/** Options passed to {@link Agent.buildConfig} and {@link Agent.launch}. */
|
|
13
|
+
export interface AgentContext {
|
|
14
|
+
/** The local proxy port (requests to the proxy get auth injected). */
|
|
15
|
+
proxyPort: number;
|
|
16
|
+
/** Upstream API base URL, e.g. `https://modelworks.ai`. */
|
|
17
|
+
apiBase: string;
|
|
18
|
+
/** Returns the current (possibly-refreshed) access token. */
|
|
19
|
+
getAccessToken: () => string;
|
|
20
|
+
/** The project's cached state (circuits, adaptors, versions). */
|
|
21
|
+
state: QuackState;
|
|
22
|
+
/** The project's committable config (default_agent, default_circuit, circuits). */
|
|
23
|
+
config: QuackConfig;
|
|
24
|
+
}
|
|
25
|
+
/** Options for {@link Agent.launch}. */
|
|
26
|
+
export interface LaunchOptions {
|
|
27
|
+
/** Args to forward to the agent binary. */
|
|
28
|
+
args: string[];
|
|
29
|
+
/** If true, log proxy requests to stderr. */
|
|
30
|
+
verbose: boolean;
|
|
31
|
+
/** If true, log full proxy request/response details to .quack/debug.log. */
|
|
32
|
+
debug: boolean;
|
|
33
|
+
/** If true, print the config and exit without launching. */
|
|
34
|
+
print: boolean;
|
|
35
|
+
}
|
|
36
|
+
/** Result of a launch. `exitCode` is null if `--print` was used. */
|
|
37
|
+
export interface LaunchResult {
|
|
38
|
+
exitCode: number | null;
|
|
39
|
+
proxyPort: number;
|
|
40
|
+
}
|
|
41
|
+
/** A built-in agent plugin. */
|
|
42
|
+
export interface Agent {
|
|
43
|
+
/** Short name used as the `quack <name>` command (e.g. `"opencode"`). */
|
|
44
|
+
name: string;
|
|
45
|
+
/** One-line description shown in `quack agents list`. */
|
|
46
|
+
description: string;
|
|
47
|
+
/** The binary name to look for on PATH (e.g. `"opencode"`). */
|
|
48
|
+
binary: string;
|
|
49
|
+
/** Install hint lines shown when the binary is missing. */
|
|
50
|
+
installHints: string[];
|
|
51
|
+
/**
|
|
52
|
+
* Build the tool-specific config/env from the project state.
|
|
53
|
+
* Returns env vars to inject when spawning the agent.
|
|
54
|
+
*/
|
|
55
|
+
buildConfig(ctx: AgentContext): Record<string, string>;
|
|
56
|
+
/**
|
|
57
|
+
* Start the proxy (already started by the caller), build the config,
|
|
58
|
+
* and spawn the agent. On `--print`, print the config and return
|
|
59
|
+
* without spawning.
|
|
60
|
+
*/
|
|
61
|
+
launch(ctx: AgentContext, opts: LaunchOptions): Promise<LaunchResult>;
|
|
62
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent plugin interface.
|
|
3
|
+
*
|
|
4
|
+
* An agent is a tool that quack can launch with ModelWorks circuits
|
|
5
|
+
* wired in (e.g. OpenCode, Cursor, Claude Code). Agents are built into
|
|
6
|
+
* quack — no external installation. New agents appear automatically
|
|
7
|
+
* when quack is updated.
|
|
8
|
+
*
|
|
9
|
+
* @module
|
|
10
|
+
*/
|
|
11
|
+
export {};
|