@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.
@@ -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
+ }