@peakflo/agent-harness 0.2.8 → 0.2.9-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -0
- package/dist/cli.js +5 -0
- package/dist/harness-discovery.d.ts +142 -0
- package/dist/harness-discovery.js +727 -0
- package/dist/harness.d.ts +68 -1
- package/dist/harness.js +197 -1
- package/dist/index.d.ts +9 -0
- package/dist/index.js +14 -2
- package/dist/options.js +17 -1
- package/dist/types.d.ts +75 -0
- package/package.json +1 -1
|
@@ -0,0 +1,727 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* HARNESS DISCOVERY — which coding harnesses are installed on this machine.
|
|
4
|
+
*
|
|
5
|
+
* Modelled on 20x, where the harness code came from:
|
|
6
|
+
* - `src/main/agent-installer/detect.js` finds the binaries (`claude`,
|
|
7
|
+
* `codex`, `cursor-agent`, `opencode`, `pi`) with `--version`, after it
|
|
8
|
+
* puts the well-known install directories on PATH;
|
|
9
|
+
* - `src/renderer/src/types/index.ts` holds the static model lists the 20x
|
|
10
|
+
* agent form shows for Claude Code, Codex and Cursor.
|
|
11
|
+
*
|
|
12
|
+
* WHAT THIS IS NOT. It is not the list of backends this runner can run.
|
|
13
|
+
* `capabilities.backends` stays that list and the control plane routes on it.
|
|
14
|
+
* An entry here says "this CLI is on the machine", and `runnable` says whether
|
|
15
|
+
* the runner can also drive it. Detection never implies turn support.
|
|
16
|
+
*
|
|
17
|
+
* RULES THIS MODULE KEEPS:
|
|
18
|
+
*
|
|
19
|
+
* 1. NEVER THROWS, NEVER REJECTS. One broken CLI must not hide the others, so
|
|
20
|
+
* every harness is probed on its own, inside its own try/catch and its own
|
|
21
|
+
* deadline.
|
|
22
|
+
* 2. TIME-LIMITED. Each command has a timeout and each harness has a total
|
|
23
|
+
* deadline. A CLI that hangs is killed and reported as timed out.
|
|
24
|
+
* 3. NO SECRETS LEAVE THIS FILE. Sign-in is decided from an exit code, one
|
|
25
|
+
* boolean of a status reply, or the existence of an environment variable or
|
|
26
|
+
* a file. No status text is kept. Every `reason` is a constant from
|
|
27
|
+
* `REASONS` below, never something a CLI printed. Model ids and names are
|
|
28
|
+
* the only CLI text that is reported, and each one is checked first.
|
|
29
|
+
* 4. NO GLOBAL SIDE EFFECTS. `process.env.PATH` is not changed; the search path
|
|
30
|
+
* is local and is handed to each child process.
|
|
31
|
+
*/
|
|
32
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
33
|
+
exports.KNOWN_HARNESSES = exports.CURSOR_STATIC_MODELS = exports.CODEX_STATIC_MODELS = exports.CLAUDE_STATIC_MODELS = exports.REASONS = exports.DISCOVERY_HARNESS_TIMEOUT_MS = exports.DISCOVERY_COMMAND_TIMEOUT_MS = void 0;
|
|
34
|
+
exports.discoverySearchPath = discoverySearchPath;
|
|
35
|
+
exports.discoveryChildEnv = discoveryChildEnv;
|
|
36
|
+
exports.sanitizeHarnessModels = sanitizeHarnessModels;
|
|
37
|
+
exports.parseVersion = parseVersion;
|
|
38
|
+
exports.saysSignedOut = saysSignedOut;
|
|
39
|
+
exports.parseCodexModelCache = parseCodexModelCache;
|
|
40
|
+
exports.parseCodexDefaultModel = parseCodexDefaultModel;
|
|
41
|
+
exports.parseCursorModels = parseCursorModels;
|
|
42
|
+
exports.parseOpencodeModels = parseOpencodeModels;
|
|
43
|
+
exports.discoverHarnesses = discoverHarnesses;
|
|
44
|
+
const node_child_process_1 = require("node:child_process");
|
|
45
|
+
const node_fs_1 = require("node:fs");
|
|
46
|
+
const node_os_1 = require("node:os");
|
|
47
|
+
const node_path_1 = require("node:path");
|
|
48
|
+
/** One command: `--version`, a status check, or a model listing. */
|
|
49
|
+
exports.DISCOVERY_COMMAND_TIMEOUT_MS = 8_000;
|
|
50
|
+
/** Everything for one harness. Harnesses run concurrently, so this is also the bound for the whole pass. */
|
|
51
|
+
exports.DISCOVERY_HARNESS_TIMEOUT_MS = 20_000;
|
|
52
|
+
const MAX_MODELS_PER_HARNESS = 200;
|
|
53
|
+
const MAX_OUTPUT_BYTES = 1024 * 1024;
|
|
54
|
+
/**
|
|
55
|
+
* Every sentence a `reason` can be. A closed set on purpose: text printed by a
|
|
56
|
+
* CLI can hold an account email or a key, and a constant cannot.
|
|
57
|
+
*/
|
|
58
|
+
exports.REASONS = {
|
|
59
|
+
notInstalled: 'Not installed on this machine.',
|
|
60
|
+
versionTimedOut: 'The version check timed out.',
|
|
61
|
+
versionFailed: 'The version check failed.',
|
|
62
|
+
discoveryTimedOut: 'Discovery timed out.',
|
|
63
|
+
discoveryFailed: 'Discovery failed.',
|
|
64
|
+
signInTimedOut: 'The sign-in check timed out.',
|
|
65
|
+
signInUnknown: 'Sign-in could not be confirmed.',
|
|
66
|
+
codexSignIn: 'Not signed in. Run `codex login` on this machine.',
|
|
67
|
+
claudeSignIn: 'Not signed in. Run `claude auth login` on this machine.',
|
|
68
|
+
cursorSignIn: 'Not signed in. Run `cursor-agent login` on this machine.'
|
|
69
|
+
};
|
|
70
|
+
/* -------------------------------------------------------------------------- */
|
|
71
|
+
/* Static model lists — copied from 20x `src/renderer/src/types/index.ts` */
|
|
72
|
+
/* -------------------------------------------------------------------------- */
|
|
73
|
+
/** 20x `CLAUDE_MODELS`. Claude Code has no command that lists models. */
|
|
74
|
+
exports.CLAUDE_STATIC_MODELS = [
|
|
75
|
+
{ id: 'claude-fable-5-1', name: 'Claude Fable 5.1' },
|
|
76
|
+
{ id: 'claude-fable-5', name: 'Claude Fable 5' },
|
|
77
|
+
{ id: 'claude-opus-5-5', name: 'Claude Opus 5.5' },
|
|
78
|
+
{ id: 'claude-opus-5', name: 'Claude Opus 5' },
|
|
79
|
+
{ id: 'claude-sonnet-5-5', name: 'Claude Sonnet 5.5' },
|
|
80
|
+
{ id: 'claude-sonnet-5', name: 'Claude Sonnet 5' },
|
|
81
|
+
{ id: 'claude-opus-4-8', name: 'Claude Opus 4.8' },
|
|
82
|
+
{ id: 'claude-opus-4-7', name: 'Claude Opus 4.7' },
|
|
83
|
+
{ id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6' },
|
|
84
|
+
{ id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5' },
|
|
85
|
+
{ id: 'claude-opus-4-6', name: 'Claude Opus 4.6' },
|
|
86
|
+
{ id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5' },
|
|
87
|
+
{ id: 'claude-3-7-sonnet-20250219', name: 'Claude 3.7 Sonnet' },
|
|
88
|
+
{ id: 'claude-3-5-sonnet-20241022', name: 'Claude 3.5 Sonnet (Oct)' },
|
|
89
|
+
{ id: 'claude-3-5-sonnet-20240620', name: 'Claude 3.5 Sonnet (Jun)' }
|
|
90
|
+
];
|
|
91
|
+
/** 20x `CODEX_MODELS`. Used only when Codex's own model cache cannot be read. */
|
|
92
|
+
exports.CODEX_STATIC_MODELS = [
|
|
93
|
+
{ id: 'gpt-6-astra', name: 'GPT-6 Astra' },
|
|
94
|
+
{ id: 'gpt-6.1-sol', name: 'GPT-6.1 Sol' },
|
|
95
|
+
{ id: 'gpt-6-sol', name: 'GPT-6 Sol' },
|
|
96
|
+
{ id: 'gpt-6-luna', name: 'GPT-6 Luna' },
|
|
97
|
+
{ id: 'gpt-5.6-sol', name: 'GPT-5.6 Sol' },
|
|
98
|
+
{ id: 'gpt-5.6-terra', name: 'GPT-5.6 Terra' },
|
|
99
|
+
{ id: 'gpt-5.6-luna', name: 'GPT-5.6 Luna' },
|
|
100
|
+
{ id: 'gpt-5.5', name: 'GPT-5.5' },
|
|
101
|
+
{ id: 'gpt-5.4', name: 'GPT-5.4' },
|
|
102
|
+
{ id: 'gpt-5.4-mini', name: 'GPT-5.4 Mini' },
|
|
103
|
+
{ id: 'gpt-5.3-codex-spark', name: 'GPT-5.3 Codex Spark' }
|
|
104
|
+
];
|
|
105
|
+
/** 20x `CURSOR_MODELS`. Used only when `cursor-agent models` gives nothing. */
|
|
106
|
+
exports.CURSOR_STATIC_MODELS = [
|
|
107
|
+
{ id: 'composer-2.5', name: 'Composer 2.5' },
|
|
108
|
+
{ id: 'grok-4.5', name: 'Grok 4.5' }
|
|
109
|
+
];
|
|
110
|
+
/**
|
|
111
|
+
* Where to look for a harness binary: PATH first, then the directories the
|
|
112
|
+
* harness installers use. The well-known directories come from 20x
|
|
113
|
+
* `detect.js` (`ensureAgentPaths` and `getPiCommandCandidates`). The directory
|
|
114
|
+
* of the running `node` is added because a runner started by launchd or
|
|
115
|
+
* systemd has a short PATH, and an npm-installed CLI sits beside that `node`.
|
|
116
|
+
*/
|
|
117
|
+
function discoverySearchPath(env, home, platform = process.platform) {
|
|
118
|
+
/*
|
|
119
|
+
* ABSOLUTE ENTRIES ONLY. A relative entry (`.`, `node_modules/.bin`) means
|
|
120
|
+
* "whatever is in the directory the runner was started from", and a file
|
|
121
|
+
* named `codex` there is not the user's Codex.
|
|
122
|
+
*/
|
|
123
|
+
const fromEnv = (env.PATH ?? env.Path ?? '')
|
|
124
|
+
.split(node_path_1.delimiter)
|
|
125
|
+
.filter((entry) => entry !== '' && (0, node_path_1.isAbsolute)(entry));
|
|
126
|
+
const known = platform === 'win32'
|
|
127
|
+
? [
|
|
128
|
+
(0, node_path_1.join)(home, 'AppData', 'Roaming', 'npm'),
|
|
129
|
+
(0, node_path_1.join)(home, '.local', 'bin'),
|
|
130
|
+
(0, node_path_1.join)(home, '.opencode', 'bin')
|
|
131
|
+
]
|
|
132
|
+
: [
|
|
133
|
+
(0, node_path_1.join)(home, '.local', 'bin'),
|
|
134
|
+
(0, node_path_1.join)(home, '.opencode', 'bin'),
|
|
135
|
+
'/opt/homebrew/bin',
|
|
136
|
+
'/usr/local/bin',
|
|
137
|
+
(0, node_path_1.join)(home, '.npm-global', 'bin'),
|
|
138
|
+
(0, node_path_1.join)(home, '.volta', 'bin'),
|
|
139
|
+
(0, node_path_1.join)(home, '.bun', 'bin')
|
|
140
|
+
];
|
|
141
|
+
return [...new Set([...fromEnv, ...known, (0, node_path_1.dirname)(process.execPath)])];
|
|
142
|
+
}
|
|
143
|
+
function isExecutableFile(path) {
|
|
144
|
+
try {
|
|
145
|
+
if (!(0, node_fs_1.statSync)(path).isFile())
|
|
146
|
+
return false;
|
|
147
|
+
(0, node_fs_1.accessSync)(path, node_fs_1.constants.X_OK);
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function defaultResolveCommand(command, searchPath, env) {
|
|
155
|
+
// npm installs `.cmd` wrappers on Windows, so the bare name is not a file.
|
|
156
|
+
const extensions = process.platform === 'win32'
|
|
157
|
+
? (env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)
|
|
158
|
+
: [''];
|
|
159
|
+
for (const dir of searchPath) {
|
|
160
|
+
for (const extension of extensions) {
|
|
161
|
+
const candidate = (0, node_path_1.join)(dir, `${command}${extension}`);
|
|
162
|
+
if (isExecutableFile(candidate))
|
|
163
|
+
return candidate;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
function defaultExec(command, args, timeoutMs, env) {
|
|
169
|
+
return new Promise((resolve) => {
|
|
170
|
+
let settled = false;
|
|
171
|
+
let timer = null;
|
|
172
|
+
const finish = (result) => {
|
|
173
|
+
if (settled)
|
|
174
|
+
return;
|
|
175
|
+
settled = true;
|
|
176
|
+
if (timer)
|
|
177
|
+
clearTimeout(timer);
|
|
178
|
+
resolve(result);
|
|
179
|
+
};
|
|
180
|
+
try {
|
|
181
|
+
const isWindows = process.platform === 'win32';
|
|
182
|
+
const child = (0, node_child_process_1.execFile)(
|
|
183
|
+
// `shell: true` is what resolves `.cmd` wrappers on Windows (20x
|
|
184
|
+
// detect.js). With a shell the path must be quoted by hand.
|
|
185
|
+
isWindows ? `"${command}"` : command, args, {
|
|
186
|
+
timeout: timeoutMs,
|
|
187
|
+
killSignal: 'SIGKILL',
|
|
188
|
+
shell: isWindows,
|
|
189
|
+
windowsHide: true,
|
|
190
|
+
maxBuffer: MAX_OUTPUT_BYTES,
|
|
191
|
+
env: { ...env, NO_COLOR: '1', TERM: 'dumb' }
|
|
192
|
+
}, (error, stdout, stderr) => {
|
|
193
|
+
const failure = error;
|
|
194
|
+
finish({
|
|
195
|
+
code: failure
|
|
196
|
+
? typeof failure.code === 'number'
|
|
197
|
+
? failure.code
|
|
198
|
+
: null
|
|
199
|
+
: 0,
|
|
200
|
+
stdout: String(stdout ?? ''),
|
|
201
|
+
stderr: String(stderr ?? ''),
|
|
202
|
+
timedOut: failure?.killed === true
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
// A CLI that waits for input must see end-of-file, not a terminal.
|
|
206
|
+
child.stdin?.end();
|
|
207
|
+
child.on('error', () => undefined);
|
|
208
|
+
/*
|
|
209
|
+
* THE BACKSTOP. `execFile` calls back only when the output pipes close,
|
|
210
|
+
* and a killed CLI can leave a child of its own holding them open. This
|
|
211
|
+
* timer answers anyway, so a hanging harness costs its timeout and no
|
|
212
|
+
* more.
|
|
213
|
+
*/
|
|
214
|
+
timer = setTimeout(() => {
|
|
215
|
+
try {
|
|
216
|
+
child.kill('SIGKILL');
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
// Already gone.
|
|
220
|
+
}
|
|
221
|
+
finish({ code: null, stdout: '', stderr: '', timedOut: true });
|
|
222
|
+
}, timeoutMs + 250);
|
|
223
|
+
timer.unref();
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
// execFile can throw synchronously (EACCES on a non-executable path).
|
|
227
|
+
finish({ code: null, stdout: '', stderr: '', timedOut: false });
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* The environment a harness CLI is started with.
|
|
233
|
+
*
|
|
234
|
+
* EVERY `PF_*` VARIABLE IS REMOVED. Those are this runner's own settings and
|
|
235
|
+
* include `PF_ENROLMENT_TOKEN`; a third-party CLI has no use for them and
|
|
236
|
+
* must not be handed them. The rest is kept, because each CLI finds its own
|
|
237
|
+
* login through it (`HOME`, `CODEX_HOME`, `ANTHROPIC_API_KEY`, ...).
|
|
238
|
+
*/
|
|
239
|
+
function discoveryChildEnv(env, searchPath) {
|
|
240
|
+
const child = {};
|
|
241
|
+
for (const [key, value] of Object.entries(env)) {
|
|
242
|
+
if (!/^PF_/i.test(key))
|
|
243
|
+
child[key] = value;
|
|
244
|
+
}
|
|
245
|
+
// `Path` is how Windows spells it; one PATH only, the search path.
|
|
246
|
+
delete child.Path;
|
|
247
|
+
child.PATH = searchPath.join(node_path_1.delimiter);
|
|
248
|
+
return child;
|
|
249
|
+
}
|
|
250
|
+
function resolveDeps(overrides = {}) {
|
|
251
|
+
const env = overrides.env ?? process.env;
|
|
252
|
+
const home = overrides.home ?? (0, node_os_1.homedir)();
|
|
253
|
+
const searchPath = discoverySearchPath(env, home);
|
|
254
|
+
const childEnv = discoveryChildEnv(env, searchPath);
|
|
255
|
+
return {
|
|
256
|
+
exec: (command, args, timeoutMs) => defaultExec(command, args, timeoutMs, childEnv),
|
|
257
|
+
resolveCommand: (command) => defaultResolveCommand(command, searchPath, env),
|
|
258
|
+
realpath: (path) => (0, node_fs_1.realpathSync)(path),
|
|
259
|
+
readFile: (path) => {
|
|
260
|
+
try {
|
|
261
|
+
return (0, node_fs_1.readFileSync)(path, 'utf8');
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
exists: (path) => (0, node_fs_1.existsSync)(path),
|
|
268
|
+
now: () => new Date(),
|
|
269
|
+
...overrides,
|
|
270
|
+
env,
|
|
271
|
+
home
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
/* -------------------------------------------------------------------------- */
|
|
275
|
+
/* Cleaning what a CLI printed */
|
|
276
|
+
/* -------------------------------------------------------------------------- */
|
|
277
|
+
// eslint-disable-next-line no-control-regex
|
|
278
|
+
const ANSI = /\u001b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
279
|
+
// eslint-disable-next-line no-control-regex
|
|
280
|
+
const CONTROL = /[\u0000-\u001f\u007f\u200b-\u200d\ufeff]/g;
|
|
281
|
+
const MODEL_ID = /^[A-Za-z0-9][A-Za-z0-9._:/@+[\]-]{0,127}$/;
|
|
282
|
+
/** An email, a bearer value, a JWT, or a key with a well-known prefix. */
|
|
283
|
+
const SECRET_LIKE = /[^\s@]+@[^\s@]+\.[^\s@]+|\bBearer\s|\beyJ[\w-]{10,}|\b(?:sk|pk|rk)-[\w-]{8,}|\bgh[pousr]_\w{8,}|\bxox[abprs]-[\w-]{8,}|\bAKIA[A-Z0-9]{12,}/i;
|
|
284
|
+
function cleanLine(text) {
|
|
285
|
+
return text.replace(ANSI, '').replace(CONTROL, '').trim();
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Keep only entries that look like a model. Anything that looks like a secret
|
|
289
|
+
* is dropped whole: a model list has no reason to hold one, so the safe
|
|
290
|
+
* reading of such a line is that it is not a model.
|
|
291
|
+
*/
|
|
292
|
+
function sanitizeHarnessModels(models) {
|
|
293
|
+
const out = [];
|
|
294
|
+
const seen = new Set();
|
|
295
|
+
for (const entry of models) {
|
|
296
|
+
if (out.length >= MAX_MODELS_PER_HARNESS)
|
|
297
|
+
break;
|
|
298
|
+
if (typeof entry?.id !== 'string')
|
|
299
|
+
continue;
|
|
300
|
+
const id = cleanLine(entry.id);
|
|
301
|
+
if (!MODEL_ID.test(id) || SECRET_LIKE.test(id) || seen.has(id))
|
|
302
|
+
continue;
|
|
303
|
+
const rawName = typeof entry.name === 'string' ? cleanLine(entry.name) : '';
|
|
304
|
+
const name = rawName && !SECRET_LIKE.test(rawName) ? rawName : id;
|
|
305
|
+
seen.add(id);
|
|
306
|
+
out.push({
|
|
307
|
+
id,
|
|
308
|
+
name: name.slice(0, 120),
|
|
309
|
+
...(entry.default === true ? { default: true } : {})
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
return out;
|
|
313
|
+
}
|
|
314
|
+
/** Mark exactly one model as the default: `preferred` when listed, else the first. */
|
|
315
|
+
function withDefault(models, preferred) {
|
|
316
|
+
if (models.length === 0)
|
|
317
|
+
return models;
|
|
318
|
+
const chosen = (preferred && models.find((model) => model.id === preferred)?.id) ||
|
|
319
|
+
models.find((model) => model.default)?.id ||
|
|
320
|
+
models[0].id;
|
|
321
|
+
return models.map(({ id, name }) => id === chosen ? { id, name, default: true } : { id, name });
|
|
322
|
+
}
|
|
323
|
+
/** The first version-like token, as 20x `detect.js` reads it. */
|
|
324
|
+
function parseVersion(output) {
|
|
325
|
+
const match = cleanLine(output).match(/(\d+\.\d+[\w.+-]*)/);
|
|
326
|
+
return match ? match[1].slice(0, 64) : null;
|
|
327
|
+
}
|
|
328
|
+
function hasEnv(env, names) {
|
|
329
|
+
return names.some((name) => Boolean(env[name]));
|
|
330
|
+
}
|
|
331
|
+
/** An on/off variable that is switched ON. Present is not enough. */
|
|
332
|
+
function hasFlag(env, names) {
|
|
333
|
+
return names.some((name) => /^(1|true|yes|on)$/i.test((env[name] ?? '').trim()));
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* A CLI said it is NOT signed in. Tested BEFORE any positive pattern, because
|
|
337
|
+
* "You are not currently logged in" also contains "logged in".
|
|
338
|
+
*/
|
|
339
|
+
const SIGNED_OUT = /\bnot\b[^.\n]{0,40}\b(?:logged|signed)[\s-]?in\b|\bnot\s+authenticated\b|\bunauthenticated\b|\blogged\s+out\b|\bsigned\s+out\b|\blog\s?in\s+required\b|\bplease\s+(?:log|sign)\s?in\b|\bno\s+credentials\b/i;
|
|
340
|
+
const SIGNED_IN = /\b(?:logged|signed)\s+in\b|\bauthenticated\b/i;
|
|
341
|
+
function saysSignedOut(text) {
|
|
342
|
+
return SIGNED_OUT.test(text);
|
|
343
|
+
}
|
|
344
|
+
function parseJsonObject(text) {
|
|
345
|
+
if (!text)
|
|
346
|
+
return null;
|
|
347
|
+
try {
|
|
348
|
+
const parsed = JSON.parse(text);
|
|
349
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
350
|
+
? parsed
|
|
351
|
+
: null;
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
/* ---- Codex --------------------------------------------------------------- */
|
|
358
|
+
function codexHome(deps) {
|
|
359
|
+
return deps.env.CODEX_HOME || (0, node_path_1.join)(deps.home, '.codex');
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Codex keeps the models its account can use in `models_cache.json`. This is a
|
|
363
|
+
* model cache, not a credential file; only `slug`, `display_name`,
|
|
364
|
+
* `visibility` and `priority` are read from it.
|
|
365
|
+
*/
|
|
366
|
+
function parseCodexModelCache(text) {
|
|
367
|
+
const cache = parseJsonObject(text);
|
|
368
|
+
const list = Array.isArray(cache?.models) ? cache.models : [];
|
|
369
|
+
const listed = list
|
|
370
|
+
.filter((entry) => Boolean(entry) &&
|
|
371
|
+
typeof entry === 'object' &&
|
|
372
|
+
typeof entry.slug === 'string' &&
|
|
373
|
+
// 'hide' marks internal models the Codex picker does not show either.
|
|
374
|
+
(entry.visibility ?? 'list') === 'list')
|
|
375
|
+
.sort((a, b) => (typeof a.priority === 'number'
|
|
376
|
+
? a.priority
|
|
377
|
+
: Number.MAX_SAFE_INTEGER) -
|
|
378
|
+
(typeof b.priority === 'number' ? b.priority : Number.MAX_SAFE_INTEGER));
|
|
379
|
+
return sanitizeHarnessModels(listed.map((entry) => ({ id: entry.slug, name: entry.display_name })));
|
|
380
|
+
}
|
|
381
|
+
/** The top-level `model = "..."` of `config.toml`. Nothing else is read. */
|
|
382
|
+
function parseCodexDefaultModel(text) {
|
|
383
|
+
if (!text)
|
|
384
|
+
return null;
|
|
385
|
+
for (const line of text.split('\n')) {
|
|
386
|
+
const trimmed = line.trim();
|
|
387
|
+
// A table header ends the top level; keys below it belong to the table.
|
|
388
|
+
if (trimmed.startsWith('['))
|
|
389
|
+
return null;
|
|
390
|
+
const match = trimmed.match(/^model\s*=\s*["']([^"']{1,128})["']/);
|
|
391
|
+
if (match)
|
|
392
|
+
return match[1];
|
|
393
|
+
}
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
const CODEX = {
|
|
397
|
+
name: 'codex',
|
|
398
|
+
label: 'Codex',
|
|
399
|
+
commands: ['codex'],
|
|
400
|
+
async checkAuth({ deps, run }) {
|
|
401
|
+
// 20x `codex-adapter.ts` `checkHealth` accepts these two keys.
|
|
402
|
+
if (hasEnv(deps.env, ['OPENAI_API_KEY', 'CODEX_API_KEY'])) {
|
|
403
|
+
return { usable: true };
|
|
404
|
+
}
|
|
405
|
+
// Exit code only. The reply names the sign-in method, which is not needed.
|
|
406
|
+
const status = await run(['login', 'status']);
|
|
407
|
+
if (status.timedOut)
|
|
408
|
+
return { usable: false, reason: exports.REASONS.signInTimedOut };
|
|
409
|
+
// The exit code decides; the text is only tested for a plain "not
|
|
410
|
+
// logged in", in case a version exits 0 while saying so. Nothing is kept.
|
|
411
|
+
return status.code === 0 &&
|
|
412
|
+
!saysSignedOut(`${status.stdout}\n${status.stderr}`)
|
|
413
|
+
? { usable: true }
|
|
414
|
+
: { usable: false, reason: exports.REASONS.codexSignIn };
|
|
415
|
+
},
|
|
416
|
+
async listModels({ deps }) {
|
|
417
|
+
const home = codexHome(deps);
|
|
418
|
+
const preferred = parseCodexDefaultModel(deps.readFile((0, node_path_1.join)(home, 'config.toml')));
|
|
419
|
+
const cached = parseCodexModelCache(deps.readFile((0, node_path_1.join)(home, 'models_cache.json')));
|
|
420
|
+
if (cached.length > 0) {
|
|
421
|
+
return { models: withDefault(cached, preferred), source: 'config' };
|
|
422
|
+
}
|
|
423
|
+
return {
|
|
424
|
+
models: withDefault([...exports.CODEX_STATIC_MODELS], preferred),
|
|
425
|
+
source: 'static'
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
};
|
|
429
|
+
/* ---- Claude Code --------------------------------------------------------- */
|
|
430
|
+
function claudeHome(deps) {
|
|
431
|
+
return deps.env.CLAUDE_CONFIG_DIR || (0, node_path_1.join)(deps.home, '.claude');
|
|
432
|
+
}
|
|
433
|
+
const CLAUDE = {
|
|
434
|
+
name: 'claude-code',
|
|
435
|
+
label: 'Claude Code',
|
|
436
|
+
commands: ['claude'],
|
|
437
|
+
async checkAuth({ deps, run }) {
|
|
438
|
+
if (hasEnv(deps.env, [
|
|
439
|
+
'ANTHROPIC_API_KEY',
|
|
440
|
+
'ANTHROPIC_AUTH_TOKEN',
|
|
441
|
+
'CLAUDE_CODE_OAUTH_TOKEN'
|
|
442
|
+
]) ||
|
|
443
|
+
// These two are on/off switches, so `=0` and `=false` mean OFF.
|
|
444
|
+
hasFlag(deps.env, ['CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX'])) {
|
|
445
|
+
return { usable: true };
|
|
446
|
+
}
|
|
447
|
+
const status = await run(['auth', 'status']);
|
|
448
|
+
if (status.timedOut)
|
|
449
|
+
return { usable: false, reason: exports.REASONS.signInTimedOut };
|
|
450
|
+
/*
|
|
451
|
+
* The reply is JSON that also holds the account email and organisation.
|
|
452
|
+
* ONE boolean is read from it and the rest is dropped here.
|
|
453
|
+
*/
|
|
454
|
+
const loggedIn = parseJsonObject(status.stdout)?.loggedIn;
|
|
455
|
+
if (loggedIn === true)
|
|
456
|
+
return { usable: true };
|
|
457
|
+
if (loggedIn === false)
|
|
458
|
+
return { usable: false, reason: exports.REASONS.claudeSignIn };
|
|
459
|
+
if (saysSignedOut(`${status.stdout}\n${status.stderr}`)) {
|
|
460
|
+
return { usable: false, reason: exports.REASONS.claudeSignIn };
|
|
461
|
+
}
|
|
462
|
+
// Exit 0 with a reply that cannot be read is NOT taken as signed in:
|
|
463
|
+
// without the boolean there is nothing to base "yes" on.
|
|
464
|
+
// An older CLI has no `auth status`. On Linux the login is a file; only
|
|
465
|
+
// its existence is checked. On macOS it is in the Keychain, which is not
|
|
466
|
+
// read, so the honest answer there is "could not confirm".
|
|
467
|
+
if (deps.exists((0, node_path_1.join)(claudeHome(deps), '.credentials.json'))) {
|
|
468
|
+
return { usable: true };
|
|
469
|
+
}
|
|
470
|
+
return { usable: false, reason: exports.REASONS.signInUnknown };
|
|
471
|
+
},
|
|
472
|
+
async listModels({ deps }) {
|
|
473
|
+
// Only the `model` key is read. The file can also hold an `env` block.
|
|
474
|
+
const configured = parseJsonObject(deps.readFile((0, node_path_1.join)(claudeHome(deps), 'settings.json')))?.model;
|
|
475
|
+
return {
|
|
476
|
+
models: withDefault([...exports.CLAUDE_STATIC_MODELS], typeof configured === 'string' ? configured : null),
|
|
477
|
+
source: 'static'
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
/* ---- Cursor -------------------------------------------------------------- */
|
|
482
|
+
/**
|
|
483
|
+
* `cursor-agent models` prints one `id - Name` line per model and marks the
|
|
484
|
+
* default with `(current, default)` or `(default)`.
|
|
485
|
+
*/
|
|
486
|
+
function parseCursorModels(output) {
|
|
487
|
+
const parsed = [];
|
|
488
|
+
for (const raw of output.split('\n')) {
|
|
489
|
+
const match = cleanLine(raw).match(/^(\S+)\s+-\s+(.+)$/);
|
|
490
|
+
if (!match)
|
|
491
|
+
continue;
|
|
492
|
+
const marker = match[2].match(/\s*\(([^()]*)\)\s*$/);
|
|
493
|
+
const isMarker = marker ? /\b(current|default)\b/i.test(marker[1]) : false;
|
|
494
|
+
parsed.push({
|
|
495
|
+
id: match[1],
|
|
496
|
+
name: isMarker ? match[2].slice(0, marker.index).trim() : match[2],
|
|
497
|
+
...(isMarker && /\bdefault\b/i.test(marker[1]) ? { default: true } : {})
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
return sanitizeHarnessModels(parsed);
|
|
501
|
+
}
|
|
502
|
+
const CURSOR = {
|
|
503
|
+
name: 'cursor',
|
|
504
|
+
label: 'Cursor',
|
|
505
|
+
// 20x drives `cursor-agent` (`agent-manager.ts`: `cursor-agent acp`). Newer
|
|
506
|
+
// installers also add a plain `agent` command.
|
|
507
|
+
commands: ['cursor-agent', 'agent'],
|
|
508
|
+
/*
|
|
509
|
+
* `agent` IS A NAME OTHER TOOLS USE TOO. On the machine this was written on,
|
|
510
|
+
* `agent` is a different vendor's CLI. So `agent` counts as Cursor only when
|
|
511
|
+
* the file it resolves to lives in a Cursor directory.
|
|
512
|
+
*/
|
|
513
|
+
accept(command, path, deps) {
|
|
514
|
+
if (command !== 'agent')
|
|
515
|
+
return true;
|
|
516
|
+
try {
|
|
517
|
+
// A path SEGMENT, not any mention: `~/work/cursor-notes/bin/agent` is
|
|
518
|
+
// not Cursor. The installer puts the real file under
|
|
519
|
+
// `~/.local/share/cursor-agent/` and older ones under `~/.cursor/`.
|
|
520
|
+
return /[\\/](?:cursor-agent|\.cursor)[\\/]/i.test(deps.realpath(path));
|
|
521
|
+
}
|
|
522
|
+
catch {
|
|
523
|
+
return false;
|
|
524
|
+
}
|
|
525
|
+
},
|
|
526
|
+
async checkAuth({ deps, run }) {
|
|
527
|
+
if (hasEnv(deps.env, ['CURSOR_API_KEY']))
|
|
528
|
+
return { usable: true };
|
|
529
|
+
const status = await run(['status']);
|
|
530
|
+
if (status.timedOut)
|
|
531
|
+
return { usable: false, reason: exports.REASONS.signInTimedOut };
|
|
532
|
+
// The reply names the account. It is tested here and never stored.
|
|
533
|
+
const text = `${status.stdout}\n${status.stderr}`;
|
|
534
|
+
// The negative test comes first and wins.
|
|
535
|
+
if (saysSignedOut(text)) {
|
|
536
|
+
return { usable: false, reason: exports.REASONS.cursorSignIn };
|
|
537
|
+
}
|
|
538
|
+
if (status.code === 0 && SIGNED_IN.test(text)) {
|
|
539
|
+
return { usable: true };
|
|
540
|
+
}
|
|
541
|
+
return { usable: false, reason: exports.REASONS.signInUnknown };
|
|
542
|
+
},
|
|
543
|
+
async listModels({ run }, usable) {
|
|
544
|
+
// The listing needs a signed-in account, so it is not tried without one.
|
|
545
|
+
if (usable) {
|
|
546
|
+
const listed = await run(['models']);
|
|
547
|
+
const models = listed.code === 0 ? parseCursorModels(listed.stdout) : [];
|
|
548
|
+
if (models.length > 0) {
|
|
549
|
+
return { models: withDefault(models), source: 'cli' };
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
return { models: withDefault([...exports.CURSOR_STATIC_MODELS]), source: 'static' };
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
/* ---- Pi ------------------------------------------------------------------ */
|
|
556
|
+
const PI = {
|
|
557
|
+
name: 'pi',
|
|
558
|
+
label: 'Pi',
|
|
559
|
+
commands: ['pi'],
|
|
560
|
+
// Pi needs no login of its own here: each turn brings the Peakflo model
|
|
561
|
+
// gateway and a key for it.
|
|
562
|
+
async checkAuth() {
|
|
563
|
+
return { usable: true };
|
|
564
|
+
},
|
|
565
|
+
// The runner already probes Pi's models for `backends[].models`
|
|
566
|
+
// (model-probe.ts). The caller copies that list in, so Pi is not started
|
|
567
|
+
// twice.
|
|
568
|
+
async listModels() {
|
|
569
|
+
return null;
|
|
570
|
+
}
|
|
571
|
+
};
|
|
572
|
+
/* ---- OpenCode ------------------------------------------------------------ */
|
|
573
|
+
/** `opencode models` prints one `provider/model` id per line. */
|
|
574
|
+
function parseOpencodeModels(output) {
|
|
575
|
+
return sanitizeHarnessModels(output
|
|
576
|
+
.split('\n')
|
|
577
|
+
.map((line) => cleanLine(line))
|
|
578
|
+
.filter((line) => /^[^\s/]+\/\S+$/.test(line))
|
|
579
|
+
.map((id) => ({ id, name: id })));
|
|
580
|
+
}
|
|
581
|
+
const OPENCODE = {
|
|
582
|
+
name: 'opencode',
|
|
583
|
+
label: 'OpenCode',
|
|
584
|
+
commands: ['opencode'],
|
|
585
|
+
// OpenCode has no status command and serves some models with no login.
|
|
586
|
+
async checkAuth() {
|
|
587
|
+
return { usable: true };
|
|
588
|
+
},
|
|
589
|
+
async listModels({ run }) {
|
|
590
|
+
const listed = await run(['models']);
|
|
591
|
+
const models = listed.code === 0 ? parseOpencodeModels(listed.stdout) : [];
|
|
592
|
+
return models.length > 0 ? { models, source: 'cli' } : null;
|
|
593
|
+
}
|
|
594
|
+
};
|
|
595
|
+
/** Every harness this build knows how to look for, in display order. */
|
|
596
|
+
exports.KNOWN_HARNESSES = [
|
|
597
|
+
CODEX,
|
|
598
|
+
CLAUDE,
|
|
599
|
+
CURSOR,
|
|
600
|
+
PI,
|
|
601
|
+
OPENCODE
|
|
602
|
+
].map(({ name, label }) => ({ name, label }));
|
|
603
|
+
const SPECS = [CODEX, CLAUDE, CURSOR, PI, OPENCODE];
|
|
604
|
+
/* -------------------------------------------------------------------------- */
|
|
605
|
+
/* The pass */
|
|
606
|
+
/* -------------------------------------------------------------------------- */
|
|
607
|
+
async function probeHarness(spec, entry, deps, commandTimeoutMs, isExpired) {
|
|
608
|
+
let path = null;
|
|
609
|
+
for (const command of spec.commands) {
|
|
610
|
+
const found = deps.resolveCommand(command);
|
|
611
|
+
if (found && (spec.accept?.(command, found, deps) ?? true)) {
|
|
612
|
+
path = found;
|
|
613
|
+
break;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
if (!path)
|
|
617
|
+
return;
|
|
618
|
+
entry.installed = true;
|
|
619
|
+
entry.version = null;
|
|
620
|
+
delete entry.reason;
|
|
621
|
+
const resolved = path;
|
|
622
|
+
const context = {
|
|
623
|
+
path: resolved,
|
|
624
|
+
deps,
|
|
625
|
+
/*
|
|
626
|
+
* NOTHING IS STARTED AFTER THE HARNESS DEADLINE. The report has already
|
|
627
|
+
* been sent without this harness by then, so a sign-in check or a model
|
|
628
|
+
* listing started now would be a process nobody is waiting for.
|
|
629
|
+
*/
|
|
630
|
+
run: (args) => isExpired()
|
|
631
|
+
? Promise.resolve({
|
|
632
|
+
code: null,
|
|
633
|
+
stdout: '',
|
|
634
|
+
stderr: '',
|
|
635
|
+
timedOut: true
|
|
636
|
+
})
|
|
637
|
+
: deps.exec(resolved, args, commandTimeoutMs)
|
|
638
|
+
};
|
|
639
|
+
const version = await context.run(['--version']);
|
|
640
|
+
if (version.timedOut) {
|
|
641
|
+
entry.reason = exports.REASONS.versionTimedOut;
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
if (version.code !== 0) {
|
|
645
|
+
entry.reason = exports.REASONS.versionFailed;
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
// 20x reads stdout and stderr together: some CLIs print the version on stderr.
|
|
649
|
+
// A version that cannot be parsed stays null and the harness is still reported.
|
|
650
|
+
entry.version = parseVersion(`${version.stdout}\n${version.stderr}`);
|
|
651
|
+
const auth = await spec.checkAuth(context);
|
|
652
|
+
entry.usable = auth.usable;
|
|
653
|
+
if (!auth.usable && auth.reason)
|
|
654
|
+
entry.reason = auth.reason;
|
|
655
|
+
const listed = await spec.listModels(context, auth.usable);
|
|
656
|
+
if (listed && listed.models.length > 0) {
|
|
657
|
+
entry.models = listed.models;
|
|
658
|
+
entry.modelSource = listed.source;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Look for every known harness. ALWAYS resolves, with one entry per known
|
|
663
|
+
* harness in a fixed order — a harness that is missing is reported as
|
|
664
|
+
* `installed: false`, so the reader can tell "not installed" from "this runner
|
|
665
|
+
* does not report harnesses".
|
|
666
|
+
*/
|
|
667
|
+
async function discoverHarnesses(options = {}) {
|
|
668
|
+
const commandTimeoutMs = options.commandTimeoutMs ?? exports.DISCOVERY_COMMAND_TIMEOUT_MS;
|
|
669
|
+
const harnessTimeoutMs = options.harnessTimeoutMs ?? exports.DISCOVERY_HARNESS_TIMEOUT_MS;
|
|
670
|
+
const runnable = new Set(options.runnableBackends ?? []);
|
|
671
|
+
let deps = null;
|
|
672
|
+
let detectedAt = new Date().toISOString();
|
|
673
|
+
try {
|
|
674
|
+
deps = resolveDeps(options.deps);
|
|
675
|
+
detectedAt = deps.now().toISOString();
|
|
676
|
+
}
|
|
677
|
+
catch {
|
|
678
|
+
// `os.homedir()` throws on some locked-down hosts. Report every harness
|
|
679
|
+
// as not found rather than fail the runner.
|
|
680
|
+
}
|
|
681
|
+
return Promise.all(SPECS.map(async (spec) => {
|
|
682
|
+
/*
|
|
683
|
+
* The entry is filled in as the probe goes, so a deadline or a throw
|
|
684
|
+
* part-way still reports what was already learnt (found, and where).
|
|
685
|
+
*/
|
|
686
|
+
const entry = {
|
|
687
|
+
name: spec.name,
|
|
688
|
+
label: spec.label,
|
|
689
|
+
installed: false,
|
|
690
|
+
usable: false,
|
|
691
|
+
reason: exports.REASONS.notInstalled,
|
|
692
|
+
runnable: false,
|
|
693
|
+
detectedAt
|
|
694
|
+
};
|
|
695
|
+
if (!deps) {
|
|
696
|
+
entry.reason = exports.REASONS.discoveryFailed;
|
|
697
|
+
return entry;
|
|
698
|
+
}
|
|
699
|
+
let timer = null;
|
|
700
|
+
let expired = false;
|
|
701
|
+
const work = probeHarness(spec, entry, deps, commandTimeoutMs, () => expired).then(() => 'done', () => 'failed');
|
|
702
|
+
const deadline = new Promise((resolve) => {
|
|
703
|
+
timer = setTimeout(() => {
|
|
704
|
+
expired = true;
|
|
705
|
+
resolve('expired');
|
|
706
|
+
}, harnessTimeoutMs);
|
|
707
|
+
timer.unref();
|
|
708
|
+
});
|
|
709
|
+
const outcome = await Promise.race([work, deadline]);
|
|
710
|
+
if (timer)
|
|
711
|
+
clearTimeout(timer);
|
|
712
|
+
// A copy, so a probe still running after its deadline cannot change
|
|
713
|
+
// what was reported.
|
|
714
|
+
const report = { ...entry };
|
|
715
|
+
if (outcome !== 'done') {
|
|
716
|
+
report.usable = false;
|
|
717
|
+
report.reason = expired
|
|
718
|
+
? exports.REASONS.discoveryTimedOut
|
|
719
|
+
: exports.REASONS.discoveryFailed;
|
|
720
|
+
delete report.models;
|
|
721
|
+
delete report.modelSource;
|
|
722
|
+
}
|
|
723
|
+
// Runnable needs both: the runner drives it AND the binary is there.
|
|
724
|
+
report.runnable = report.installed && runnable.has(spec.name);
|
|
725
|
+
return report;
|
|
726
|
+
}));
|
|
727
|
+
}
|