llm-cli-gateway 1.4.0 → 1.5.13

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.
Files changed (62) hide show
  1. package/CHANGELOG.md +135 -1
  2. package/README.md +358 -15
  3. package/dist/approval-manager.d.ts +1 -1
  4. package/dist/async-job-manager.d.ts +32 -2
  5. package/dist/async-job-manager.js +101 -16
  6. package/dist/auth.d.ts +15 -0
  7. package/dist/auth.js +46 -0
  8. package/dist/cli-updater.d.ts +19 -2
  9. package/dist/cli-updater.js +110 -7
  10. package/dist/codex-json-parser.d.ts +34 -0
  11. package/dist/codex-json-parser.js +105 -0
  12. package/dist/config.d.ts +30 -0
  13. package/dist/config.js +167 -0
  14. package/dist/doctor.d.ts +110 -0
  15. package/dist/doctor.js +280 -0
  16. package/dist/endpoint-exposure.d.ts +22 -0
  17. package/dist/endpoint-exposure.js +231 -0
  18. package/dist/entrypoint-url.d.ts +1 -0
  19. package/dist/entrypoint-url.js +5 -0
  20. package/dist/executor.d.ts +9 -1
  21. package/dist/executor.js +52 -17
  22. package/dist/flight-recorder.d.ts +3 -1
  23. package/dist/flight-recorder.js +31 -2
  24. package/dist/gateway-server.d.ts +2 -0
  25. package/dist/gateway-server.js +1 -0
  26. package/dist/gemini-json-parser.d.ts +21 -0
  27. package/dist/gemini-json-parser.js +47 -0
  28. package/dist/health.d.ts +7 -0
  29. package/dist/health.js +22 -0
  30. package/dist/http-transport.d.ts +22 -0
  31. package/dist/http-transport.js +164 -0
  32. package/dist/index.d.ts +186 -2
  33. package/dist/index.js +2761 -1454
  34. package/dist/job-store.d.ts +118 -2
  35. package/dist/job-store.js +176 -5
  36. package/dist/logger.d.ts +9 -0
  37. package/dist/logger.js +14 -0
  38. package/dist/model-registry.js +40 -6
  39. package/dist/provider-login-guidance.d.ts +21 -0
  40. package/dist/provider-login-guidance.js +98 -0
  41. package/dist/provider-status.d.ts +41 -0
  42. package/dist/provider-status.js +203 -0
  43. package/dist/request-helpers.d.ts +484 -4
  44. package/dist/request-helpers.js +613 -0
  45. package/dist/resources.js +44 -0
  46. package/dist/session-manager-pg.js +1 -0
  47. package/dist/session-manager.d.ts +1 -1
  48. package/dist/session-manager.js +2 -1
  49. package/dist/upstream-contracts.d.ts +62 -0
  50. package/dist/upstream-contracts.js +620 -0
  51. package/dist/validation-normalizer.d.ts +23 -0
  52. package/dist/validation-normalizer.js +79 -0
  53. package/dist/validation-orchestrator.d.ts +47 -0
  54. package/dist/validation-orchestrator.js +145 -0
  55. package/dist/validation-prompts.d.ts +15 -0
  56. package/dist/validation-prompts.js +52 -0
  57. package/dist/validation-report.d.ts +57 -0
  58. package/dist/validation-report.js +129 -0
  59. package/dist/validation-tools.d.ts +7 -0
  60. package/dist/validation-tools.js +198 -0
  61. package/package.json +25 -10
  62. package/setup/status.schema.json +271 -0
@@ -1,7 +1,7 @@
1
1
  import type { Logger } from "./logger.js";
2
2
  import { type JobHealth } from "./process-monitor.js";
3
3
  import { JobStore } from "./job-store.js";
4
- export type LlmCli = "claude" | "codex" | "gemini" | "grok";
4
+ export type LlmCli = "claude" | "codex" | "gemini" | "grok" | "mistral";
5
5
  export type AsyncJobStatus = "running" | "completed" | "failed" | "canceled" | "orphaned";
6
6
  export interface AsyncJobSnapshot {
7
7
  id: string;
@@ -29,6 +29,22 @@ export interface StartJobOptions {
29
29
  outputFormat?: string;
30
30
  /** Bypass dedup and force a fresh CLI run even if a recent matching job exists. */
31
31
  forceRefresh?: boolean;
32
+ /**
33
+ * Extra environment variables to inject when spawning the child CLI.
34
+ * Used by Mistral Vibe to pass `VIBE_ACTIVE_MODEL` (Vibe has no `--model` flag).
35
+ *
36
+ * IMPORTANT: env vars participate in the dedup key (canonicalised by sorted
37
+ * keys + JSON-stringified). Two requests that differ only in env (e.g. two
38
+ * Mistral requests with the same prompt but different VIBE_ACTIVE_MODEL)
39
+ * therefore do NOT collide on dedup.
40
+ */
41
+ env?: Record<string, string>;
42
+ /**
43
+ * Optional hook fired exactly once when the job reaches a terminal state.
44
+ * Used by callers that own per-request resources (outputSchema temp files,
45
+ * etc.) that must persist for the lifetime of the spawned CLI process.
46
+ */
47
+ onComplete?: () => void;
32
48
  }
33
49
  export interface StartJobOutcome {
34
50
  snapshot: AsyncJobSnapshot;
@@ -45,13 +61,26 @@ export declare class AsyncJobManager {
45
61
  private processMonitor;
46
62
  private store;
47
63
  constructor(logger?: Logger, onJobComplete?: ((cli: LlmCli, durationMs: number, success: boolean) => void) | undefined, store?: JobStore | null);
64
+ /**
65
+ * True iff a durable (or memory) job store is attached. The MCP-tool
66
+ * registration layer ANDs this with persistence.asyncJobsEnabled when
67
+ * deciding whether to register the *_request_async / llm_job_* tools.
68
+ * Without a store, async tools must not be registered, otherwise we
69
+ * re-open the silent in-memory loss path the structural invariant closes.
70
+ */
71
+ hasStore(): boolean;
48
72
  private emitMetrics;
49
73
  private evictCompletedJobs;
50
74
  /**
51
75
  * Compute the dedup key for a job. Stable across re-issues of the same request,
52
76
  * which is exactly what allows agents to safely retry without restarting the run.
77
+ *
78
+ * U22 fix: env vars participate in the key via a deterministic canonicalisation
79
+ * (sorted keys → JSON-stringified). This prevents two Mistral requests with the
80
+ * same argv but different `VIBE_ACTIVE_MODEL` from deduping onto each other.
53
81
  */
54
82
  private buildRequestKey;
83
+ private fireOnComplete;
55
84
  private safeStoreCall;
56
85
  /**
57
86
  * Flush in-memory stdout/stderr to the durable store if anything changed
@@ -71,7 +100,7 @@ export declare class AsyncJobManager {
71
100
  * Existing callers keep working unchanged; forceRefresh is exposed as a trailing
72
101
  * optional param for the dedup-aware path.
73
102
  */
74
- startJob(cli: LlmCli, args: string[], correlationId: string, cwd?: string, idleTimeoutMs?: number, outputFormat?: string, forceRefresh?: boolean): AsyncJobSnapshot;
103
+ startJob(cli: LlmCli, args: string[], correlationId: string, cwd?: string, idleTimeoutMs?: number, outputFormat?: string, forceRefresh?: boolean, env?: Record<string, string>, onComplete?: () => void): AsyncJobSnapshot;
75
104
  /**
76
105
  * Start a job, with optional dedup against recent identical requests.
77
106
  * Returns `{ snapshot, deduped }` so callers can log/report the short-circuit.
@@ -82,6 +111,7 @@ export declare class AsyncJobManager {
82
111
  */
83
112
  startJobWithDedup(cli: LlmCli, args: string[], correlationId: string, opts?: StartJobOptions): StartJobOutcome;
84
113
  getJobSnapshot(jobId: string): AsyncJobSnapshot | null;
114
+ getJobSnapshots(jobIds: string[]): Record<string, AsyncJobSnapshot | null>;
85
115
  getJobResult(jobId: string, maxChars?: number): AsyncJobResult | null;
86
116
  cancelJob(jobId: string): {
87
117
  canceled: boolean;
@@ -1,6 +1,5 @@
1
- import { spawn } from "child_process";
2
1
  import { randomUUID } from "crypto";
3
- import { getExtendedPath, killProcessGroup, registerProcessGroup, unregisterProcessGroup, } from "./executor.js";
2
+ import { getExtendedPath, killProcessGroup, spawnCliProcess, unregisterProcessGroup, } from "./executor.js";
4
3
  import { noopLogger } from "./logger.js";
5
4
  import { ProcessMonitor } from "./process-monitor.js";
6
5
  import { computeRequestKey } from "./job-store.js";
@@ -8,6 +7,35 @@ const MAX_OUTPUT_SIZE = 50 * 1024 * 1024;
8
7
  const JOB_TTL_MS = 60 * 60 * 1000; // 1 hour in-memory retention; durable store has its own (longer) retention
9
8
  const EVICTION_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes
10
9
  const OUTPUT_FLUSH_INTERVAL_MS = 1000; // Throttle DB writes for streaming stdout/stderr
10
+ function describeProcessLaunchError(cli, error) {
11
+ const code = error.code;
12
+ if (code === "ENOENT") {
13
+ return {
14
+ exitCode: 127,
15
+ message: `The '${cli}' command was not found. Install the ${cli} CLI and make sure it is on PATH. (${error.message})`,
16
+ };
17
+ }
18
+ return {
19
+ exitCode: 126,
20
+ message: `Failed to launch ${cli} CLI: ${error.message}`,
21
+ };
22
+ }
23
+ /**
24
+ * U22 fix: deterministic canonicalisation of an env-var map for the dedup key.
25
+ * Returns "" when env is undefined or empty (preserves dedup key continuity for
26
+ * pre-U22 callers that pass no env).
27
+ */
28
+ function canonicaliseEnvForKey(env) {
29
+ if (!env)
30
+ return "";
31
+ const entries = Object.entries(env)
32
+ .filter(([, v]) => v !== undefined && v !== null)
33
+ .map(([k, v]) => [k, String(v)]);
34
+ if (entries.length === 0)
35
+ return "";
36
+ entries.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
37
+ return JSON.stringify(entries);
38
+ }
11
39
  function truncateText(value, maxChars) {
12
40
  if (value.length <= maxChars) {
13
41
  return { text: value, truncated: false };
@@ -46,6 +74,16 @@ export class AsyncJobManager {
46
74
  this.evictionTimer.unref();
47
75
  }
48
76
  }
77
+ /**
78
+ * True iff a durable (or memory) job store is attached. The MCP-tool
79
+ * registration layer ANDs this with persistence.asyncJobsEnabled when
80
+ * deciding whether to register the *_request_async / llm_job_* tools.
81
+ * Without a store, async tools must not be registered, otherwise we
82
+ * re-open the silent in-memory loss path the structural invariant closes.
83
+ */
84
+ hasStore() {
85
+ return this.store !== null;
86
+ }
49
87
  emitMetrics(job) {
50
88
  if (job.metricsRecorded)
51
89
  return;
@@ -82,6 +120,7 @@ export class AsyncJobManager {
82
120
  this.logger.error(`Job ${id} process ${job.process.pid} no longer exists, marking as failed`);
83
121
  this.emitMetrics(job);
84
122
  this.persistComplete(job);
123
+ this.fireOnComplete(job);
85
124
  }
86
125
  // EPERM: process exists but we can't signal it — ignore
87
126
  }
@@ -96,6 +135,7 @@ export class AsyncJobManager {
96
135
  this.logger.error(`Job ${id} has exited flag but was still in running state, marking as failed`);
97
136
  this.emitMetrics(job);
98
137
  this.persistComplete(job);
138
+ this.fireOnComplete(job);
99
139
  }
100
140
  }
101
141
  for (const [id, job] of this.jobs) {
@@ -126,9 +166,26 @@ export class AsyncJobManager {
126
166
  /**
127
167
  * Compute the dedup key for a job. Stable across re-issues of the same request,
128
168
  * which is exactly what allows agents to safely retry without restarting the run.
169
+ *
170
+ * U22 fix: env vars participate in the key via a deterministic canonicalisation
171
+ * (sorted keys → JSON-stringified). This prevents two Mistral requests with the
172
+ * same argv but different `VIBE_ACTIVE_MODEL` from deduping onto each other.
129
173
  */
130
- buildRequestKey(cli, args) {
131
- return computeRequestKey(cli, args);
174
+ buildRequestKey(cli, args, env) {
175
+ return computeRequestKey(cli, args, canonicaliseEnvForKey(env));
176
+ }
177
+ fireOnComplete(job) {
178
+ if (job.onCompleteFired)
179
+ return;
180
+ if (!job.onComplete)
181
+ return;
182
+ job.onCompleteFired = true;
183
+ try {
184
+ job.onComplete();
185
+ }
186
+ catch (err) {
187
+ this.logger.error(`Job ${job.id} onComplete hook threw`, err);
188
+ }
132
189
  }
133
190
  safeStoreCall(label, fn) {
134
191
  if (!this.store)
@@ -234,12 +291,14 @@ export class AsyncJobManager {
234
291
  * Existing callers keep working unchanged; forceRefresh is exposed as a trailing
235
292
  * optional param for the dedup-aware path.
236
293
  */
237
- startJob(cli, args, correlationId, cwd, idleTimeoutMs, outputFormat, forceRefresh) {
294
+ startJob(cli, args, correlationId, cwd, idleTimeoutMs, outputFormat, forceRefresh, env, onComplete) {
238
295
  return this.startJobWithDedup(cli, args, correlationId, {
239
296
  cwd,
240
297
  idleTimeoutMs,
241
298
  outputFormat,
242
299
  forceRefresh,
300
+ env,
301
+ onComplete,
243
302
  }).snapshot;
244
303
  }
245
304
  /**
@@ -251,8 +310,8 @@ export class AsyncJobManager {
251
310
  * is returned without spawning a new process. forceRefresh skips dedup entirely.
252
311
  */
253
312
  startJobWithDedup(cli, args, correlationId, opts = {}) {
254
- const { cwd, idleTimeoutMs, outputFormat, forceRefresh } = opts;
255
- const requestKey = this.buildRequestKey(cli, args);
313
+ const { cwd, idleTimeoutMs, outputFormat, forceRefresh, env: extraEnv, onComplete } = opts;
314
+ const requestKey = this.buildRequestKey(cli, args, extraEnv);
256
315
  if (!forceRefresh && this.store) {
257
316
  try {
258
317
  const existing = this.store.findByRequestKey(requestKey);
@@ -268,6 +327,19 @@ export class AsyncJobManager {
268
327
  originalCorrelationId: record.correlationId,
269
328
  status: record.status,
270
329
  });
330
+ // U26 fix: the caller's per-request resources (e.g. outputSchema temp
331
+ // file) are NOT consumed by the deduped job, which reuses its own
332
+ // original resources. Release the new request's cleanup immediately
333
+ // to avoid an orphaned temp file. The original job's onComplete (if
334
+ // any) remains attached to that original job record.
335
+ if (onComplete) {
336
+ try {
337
+ onComplete();
338
+ }
339
+ catch (err) {
340
+ this.logger.error("dedup onComplete cleanup threw", err);
341
+ }
342
+ }
271
343
  return {
272
344
  snapshot: this.snapshot(record),
273
345
  deduped: true,
@@ -282,15 +354,14 @@ export class AsyncJobManager {
282
354
  }
283
355
  const id = randomUUID();
284
356
  const startedAt = new Date().toISOString();
285
- const child = spawn(cli, args, {
357
+ // Mistral Vibe ships as the `vibe` binary; the gateway uses `mistral` as the
358
+ // provider key but spawns `vibe` on the shell.
359
+ const command = cli === "mistral" ? "vibe" : cli;
360
+ const child = spawnCliProcess(command, args, {
286
361
  cwd,
287
- detached: true,
288
362
  stdio: ["ignore", "pipe", "pipe"],
289
- env: { ...process.env, PATH: getExtendedPath() },
363
+ env: { ...process.env, PATH: getExtendedPath(), ...(extraEnv ?? {}) },
290
364
  });
291
- if (child.pid)
292
- registerProcessGroup(child.pid);
293
- child.unref();
294
365
  // Single cleanup flag to prevent double-unregister
295
366
  let groupCleaned = false;
296
367
  const cleanupGroup = () => {
@@ -320,6 +391,8 @@ export class AsyncJobManager {
320
391
  metricsRecorded: false,
321
392
  outputFormat,
322
393
  cleanupGroup,
394
+ onComplete,
395
+ onCompleteFired: false,
323
396
  outputDirty: false,
324
397
  lastOutputFlushAt: Date.now(),
325
398
  };
@@ -356,6 +429,7 @@ export class AsyncJobManager {
356
429
  });
357
430
  this.emitMetrics(job);
358
431
  this.persistComplete(job);
432
+ this.fireOnComplete(job);
359
433
  setTimeout(() => {
360
434
  if (!job.exited && job.process)
361
435
  killProcessGroup(job.process, "SIGKILL");
@@ -380,12 +454,16 @@ export class AsyncJobManager {
380
454
  job.clearIdleTimer?.();
381
455
  job.cleanupGroup?.();
382
456
  if (job.status === "running") {
457
+ const launchError = describeProcessLaunchError(cli, error);
383
458
  job.status = job.canceled ? "canceled" : "failed";
384
- job.error = error.message;
459
+ job.exitCode = launchError.exitCode;
460
+ job.error = launchError.message;
461
+ job.stderr = job.stderr ? `${job.stderr}\n${launchError.message}` : launchError.message;
385
462
  job.finishedAt = new Date().toISOString();
386
- this.logger.error(`Job ${id} error: ${error.message}`, { correlationId });
463
+ this.logger.error(`Job ${id} error: ${launchError.message}`, { correlationId });
387
464
  this.emitMetrics(job);
388
465
  this.persistComplete(job);
466
+ this.fireOnComplete(job);
389
467
  }
390
468
  });
391
469
  child.on("close", (code) => {
@@ -396,12 +474,13 @@ export class AsyncJobManager {
396
474
  job.cleanupGroup?.();
397
475
  }
398
476
  if (job.status !== "running") {
399
- job.exitCode = code ?? job.exitCode;
477
+ job.exitCode = job.exitCode ?? code ?? null;
400
478
  if (!job.finishedAt) {
401
479
  job.finishedAt = new Date().toISOString();
402
480
  }
403
481
  // Ensure terminal state reaches the durable store (idle-timeout/output-overflow already persisted).
404
482
  this.persistComplete(job);
483
+ this.fireOnComplete(job);
405
484
  return;
406
485
  }
407
486
  job.exitCode = code ?? 0;
@@ -417,6 +496,7 @@ export class AsyncJobManager {
417
496
  }
418
497
  this.emitMetrics(job);
419
498
  this.persistComplete(job);
499
+ this.fireOnComplete(job);
420
500
  });
421
501
  return { snapshot: this.snapshot(job), deduped: false };
422
502
  }
@@ -429,6 +509,9 @@ export class AsyncJobManager {
429
509
  }
430
510
  return this.snapshot(job);
431
511
  }
512
+ getJobSnapshots(jobIds) {
513
+ return Object.fromEntries(jobIds.map(jobId => [jobId, this.getJobSnapshot(jobId)]));
514
+ }
432
515
  getJobResult(jobId, maxChars = 200000) {
433
516
  let job = this.jobs.get(jobId);
434
517
  if (!job) {
@@ -468,6 +551,7 @@ export class AsyncJobManager {
468
551
  killProcessGroup(job.process, "SIGTERM");
469
552
  this.logger.info(`Job ${jobId} canceled`, { correlationId: job.correlationId });
470
553
  this.persistComplete(job);
554
+ this.fireOnComplete(job);
471
555
  setTimeout(() => {
472
556
  if (!job.exited && job.process)
473
557
  killProcessGroup(job.process, "SIGKILL");
@@ -539,6 +623,7 @@ export class AsyncJobManager {
539
623
  });
540
624
  this.emitMetrics(job);
541
625
  this.persistComplete(job);
626
+ this.fireOnComplete(job);
542
627
  setTimeout(() => {
543
628
  if (!job.exited && job.process)
544
629
  killProcessGroup(job.process, "SIGKILL");
package/dist/auth.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ export interface AuthConfig {
3
+ required: boolean;
4
+ tokenConfigured: boolean;
5
+ source: "env" | "disabled";
6
+ }
7
+ export interface AuthResult {
8
+ ok: boolean;
9
+ status?: number;
10
+ message?: string;
11
+ }
12
+ export declare function loadAuthConfig(env?: NodeJS.ProcessEnv): AuthConfig;
13
+ export declare function getRequiredBearerToken(env?: NodeJS.ProcessEnv): string | null;
14
+ export declare function authorizeBearerRequest(req: IncomingMessage, token?: string | null): AuthResult;
15
+ export declare function writeAuthFailure(res: ServerResponse, result: AuthResult): void;
package/dist/auth.js ADDED
@@ -0,0 +1,46 @@
1
+ const AUTH_SCHEME = "Bearer ";
2
+ export function loadAuthConfig(env = process.env) {
3
+ const token = env.LLM_GATEWAY_AUTH_TOKEN;
4
+ const disabled = env.LLM_GATEWAY_AUTH_DISABLED === "1";
5
+ return {
6
+ required: !disabled,
7
+ tokenConfigured: Boolean(token),
8
+ source: disabled ? "disabled" : "env",
9
+ };
10
+ }
11
+ export function getRequiredBearerToken(env = process.env) {
12
+ const config = loadAuthConfig(env);
13
+ if (!config.required)
14
+ return null;
15
+ return env.LLM_GATEWAY_AUTH_TOKEN || null;
16
+ }
17
+ export function authorizeBearerRequest(req, token = getRequiredBearerToken()) {
18
+ if (!loadAuthConfig().required) {
19
+ return { ok: true };
20
+ }
21
+ if (!token) {
22
+ return {
23
+ ok: false,
24
+ status: 503,
25
+ message: "HTTP transport requires LLM_GATEWAY_AUTH_TOKEN",
26
+ };
27
+ }
28
+ const header = req.headers.authorization;
29
+ const value = Array.isArray(header) ? header[0] : header;
30
+ if (!value || !value.startsWith(AUTH_SCHEME)) {
31
+ return { ok: false, status: 401, message: "Unauthorized" };
32
+ }
33
+ const supplied = value.slice(AUTH_SCHEME.length);
34
+ if (supplied !== token) {
35
+ return { ok: false, status: 401, message: "Unauthorized" };
36
+ }
37
+ return { ok: true };
38
+ }
39
+ export function writeAuthFailure(res, result) {
40
+ const status = result.status ?? 401;
41
+ res.writeHead(status, {
42
+ "content-type": "application/json",
43
+ "www-authenticate": 'Bearer realm="llm-cli-gateway"',
44
+ });
45
+ res.end(JSON.stringify({ error: result.message || "Unauthorized" }));
46
+ }
@@ -1,11 +1,15 @@
1
1
  import type { Logger } from "./logger.js";
2
2
  import type { CliType } from "./session-manager.js";
3
+ import { type ProviderLoginStatus } from "./provider-status.js";
4
+ import type { ProviderLoginGuidance } from "./provider-login-guidance.js";
3
5
  export interface CliVersionInfo {
4
6
  cli: CliType;
5
7
  command: string;
6
8
  args: string[];
7
9
  installed: boolean;
8
10
  version?: string;
11
+ loginStatus?: ProviderLoginStatus;
12
+ loginGuidance?: ProviderLoginGuidance["login"];
9
13
  stdout: string;
10
14
  stderr: string;
11
15
  error?: string;
@@ -15,10 +19,23 @@ export interface CliUpgradePlan {
15
19
  target: string;
16
20
  command: string;
17
21
  args: string[];
18
- strategy: "self-update" | "npm-global-install";
22
+ strategy: "self-update" | "npm-global-install" | "pip-install" | "uv-tool-upgrade" | "brew-upgrade";
19
23
  requiresNetwork: boolean;
20
24
  note?: string;
21
25
  }
26
+ export type MistralInstallMethod = "pip" | "uv" | "brew" | "unknown";
27
+ /**
28
+ * Detect how Vibe was installed on this machine. Vibe does not self-update, so
29
+ * cli_upgrade has to dispatch to the package manager that owns the binary.
30
+ *
31
+ * Probe order: pip → uv → brew. The first one that returns a positive signal
32
+ * wins; if none do, callers should surface an actionable error rather than
33
+ * blindly running `vibe update` (a command that does not exist).
34
+ */
35
+ export declare function detectMistralInstallMethod(exec?: (cmd: string, args: string[]) => {
36
+ exitCode: number | null;
37
+ stdout: string;
38
+ }): MistralInstallMethod;
22
39
  export interface CliUpgradeResult {
23
40
  dryRun: boolean;
24
41
  plan: CliUpgradePlan;
@@ -26,7 +43,7 @@ export interface CliUpgradeResult {
26
43
  stderr?: string;
27
44
  exitCode?: number;
28
45
  }
29
- export declare function buildCliUpgradePlan(cli: CliType, target?: string): CliUpgradePlan;
46
+ export declare function buildCliUpgradePlan(cli: CliType, target?: string, detectMistral?: () => MistralInstallMethod): CliUpgradePlan;
30
47
  export declare function getCliVersion(cli: CliType): Promise<CliVersionInfo>;
31
48
  export declare function getCliVersions(cli?: CliType): Promise<CliVersionInfo[]>;
32
49
  export declare function runCliUpgrade(params: {
@@ -1,16 +1,51 @@
1
+ import { spawnSync } from "node:child_process";
1
2
  import { executeCli } from "./executor.js";
3
+ import { getProviderRuntimeStatus } from "./provider-status.js";
4
+ /**
5
+ * Detect how Vibe was installed on this machine. Vibe does not self-update, so
6
+ * cli_upgrade has to dispatch to the package manager that owns the binary.
7
+ *
8
+ * Probe order: pip → uv → brew. The first one that returns a positive signal
9
+ * wins; if none do, callers should surface an actionable error rather than
10
+ * blindly running `vibe update` (a command that does not exist).
11
+ */
12
+ export function detectMistralInstallMethod(exec = (cmd, args) => {
13
+ const result = spawnSync(cmd, args, { encoding: "utf8", timeout: 5_000, windowsHide: true });
14
+ return {
15
+ exitCode: typeof result.status === "number" ? result.status : null,
16
+ stdout: result.stdout || "",
17
+ };
18
+ }) {
19
+ const pip = exec("pip", ["show", "vibe-cli"]);
20
+ if (pip.exitCode === 0 && /Name:\s*vibe-cli/i.test(pip.stdout)) {
21
+ return "pip";
22
+ }
23
+ const uv = exec("uv", ["tool", "list"]);
24
+ if (uv.exitCode === 0 && /\bvibe(?:-cli)?\b/i.test(uv.stdout)) {
25
+ return "uv";
26
+ }
27
+ const brew = exec("brew", ["list", "mistral-vibe"]);
28
+ if (brew.exitCode === 0) {
29
+ return "brew";
30
+ }
31
+ return "unknown";
32
+ }
2
33
  const VERSION_ARGS = {
3
34
  claude: ["--version"],
4
35
  codex: ["--version"],
5
36
  gemini: ["--version"],
6
37
  grok: ["--version"],
38
+ mistral: ["--version"],
7
39
  };
8
40
  const NPM_PACKAGES = {
9
41
  codex: "@openai/codex",
10
42
  gemini: "@google/gemini-cli",
11
43
  };
12
- export function buildCliUpgradePlan(cli, target = "latest") {
44
+ export function buildCliUpgradePlan(cli, target = "latest", detectMistral = detectMistralInstallMethod) {
13
45
  const normalizedTarget = normalizeTarget(target);
46
+ if (cli === "mistral") {
47
+ return buildMistralUpgradePlan(normalizedTarget, detectMistral);
48
+ }
14
49
  if (cli === "claude") {
15
50
  if (normalizedTarget === "latest") {
16
51
  return {
@@ -79,18 +114,23 @@ export function buildCliUpgradePlan(cli, target = "latest") {
79
114
  export async function getCliVersion(cli) {
80
115
  const args = VERSION_ARGS[cli];
81
116
  try {
82
- const result = await executeCli(cli, args, { timeout: 15_000 });
117
+ const status = getProviderRuntimeStatus(cli);
83
118
  return {
84
119
  cli,
85
120
  command: cli,
86
121
  args,
87
- installed: true,
88
- version: extractVersion(result.stdout, result.stderr),
89
- stdout: result.stdout,
90
- stderr: result.stderr,
122
+ installed: status.installed,
123
+ version: status.version || undefined,
124
+ loginStatus: status.loginStatus,
125
+ loginGuidance: status.guidance.login,
126
+ stdout: status.version || "",
127
+ stderr: "",
91
128
  };
92
129
  }
93
130
  catch (error) {
131
+ const result = await fallbackCliVersion(cli, args);
132
+ if (result)
133
+ return result;
94
134
  const message = error instanceof Error ? error.message : String(error);
95
135
  return {
96
136
  cli,
@@ -104,9 +144,72 @@ export async function getCliVersion(cli) {
104
144
  }
105
145
  }
106
146
  export async function getCliVersions(cli) {
107
- const clis = cli ? [cli] : ["claude", "codex", "gemini", "grok"];
147
+ const clis = cli ? [cli] : ["claude", "codex", "gemini", "grok", "mistral"];
108
148
  return Promise.all(clis.map(item => getCliVersion(item)));
109
149
  }
150
+ function buildMistralUpgradePlan(normalizedTarget, detectMistral) {
151
+ const method = detectMistral();
152
+ // Vibe ships no self-update command. cli_upgrade dispatches to the installer
153
+ // it detects; if none can be detected the caller gets an actionable error
154
+ // (we surface it as a no-op plan with `command: ""` so runCliUpgrade can
155
+ // throw before spawning anything).
156
+ if (method === "pip") {
157
+ const pkg = normalizedTarget === "latest" ? "vibe-cli" : `vibe-cli==${normalizedTarget}`;
158
+ return {
159
+ cli: "mistral",
160
+ target: normalizedTarget,
161
+ command: "pip",
162
+ args: ["install", "-U", pkg],
163
+ strategy: "pip-install",
164
+ requiresNetwork: true,
165
+ note: "Mistral Vibe has no self-update command; gateway detected a pip install.",
166
+ };
167
+ }
168
+ if (method === "uv") {
169
+ return {
170
+ cli: "mistral",
171
+ target: normalizedTarget,
172
+ command: "uv",
173
+ args: ["tool", "upgrade", "vibe-cli"],
174
+ strategy: "uv-tool-upgrade",
175
+ requiresNetwork: true,
176
+ note: normalizedTarget === "latest"
177
+ ? "Mistral Vibe has no self-update command; gateway detected a uv tool install."
178
+ : "uv tool upgrade does not honour explicit version targets; running upgrade to latest.",
179
+ };
180
+ }
181
+ if (method === "brew") {
182
+ return {
183
+ cli: "mistral",
184
+ target: normalizedTarget,
185
+ command: "brew",
186
+ args: ["upgrade", "mistral-vibe"],
187
+ strategy: "brew-upgrade",
188
+ requiresNetwork: true,
189
+ note: normalizedTarget === "latest"
190
+ ? "Mistral Vibe has no self-update command; gateway detected a Homebrew install."
191
+ : "brew upgrade does not honour explicit version targets; running upgrade to latest.",
192
+ };
193
+ }
194
+ throw new Error("Could not detect how Mistral Vibe was installed. Install it via pip (`pip install vibe-cli`), uv (`uv tool install vibe-cli`), or Homebrew (`brew install mistral-vibe`) before running cli_upgrade.");
195
+ }
196
+ async function fallbackCliVersion(cli, args) {
197
+ try {
198
+ const result = await executeCli(cli, args, { timeout: 15_000 });
199
+ return {
200
+ cli,
201
+ command: cli,
202
+ args,
203
+ installed: true,
204
+ version: extractVersion(result.stdout, result.stderr),
205
+ stdout: result.stdout,
206
+ stderr: result.stderr,
207
+ };
208
+ }
209
+ catch {
210
+ return null;
211
+ }
212
+ }
110
213
  export async function runCliUpgrade(params) {
111
214
  const plan = buildCliUpgradePlan(params.cli, params.target);
112
215
  if (params.dryRun) {
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Parser for Codex CLI `--json` JSONL event stream.
3
+ *
4
+ * Codex emits one JSON object per line, e.g.:
5
+ * {"type":"thread.started","thread_id":"t-abc"}
6
+ * {"type":"turn.started","turn_id":"u-001"}
7
+ * {"type":"item.started","item":{...}}
8
+ * {"type":"item.completed","item":{"type":"agent_message","text":"..."}}
9
+ * {"type":"turn.completed","usage":{"input_tokens":...,"output_tokens":...,...}}
10
+ * {"type":"turn.failed","error":{...}}
11
+ * {"type":"error","message":"..."}
12
+ *
13
+ * This parser is lenient: malformed lines are skipped, partial streams are
14
+ * tolerated (usage is `undefined` if no turn.completed event arrived), and
15
+ * error events are surfaced.
16
+ *
17
+ * Cost is intentionally NOT computed here — Codex does not price client-side
18
+ * and U23 only plumbs tokens. A future unit can compute cost from the model
19
+ * registry.
20
+ */
21
+ export interface CodexUsage {
22
+ input_tokens: number;
23
+ output_tokens: number;
24
+ cache_read_tokens?: number;
25
+ cache_creation_tokens?: number;
26
+ cost_usd?: number;
27
+ }
28
+ export interface CodexJsonParseResult {
29
+ usage?: CodexUsage;
30
+ error?: string;
31
+ threadId?: string;
32
+ finalMessage?: string;
33
+ }
34
+ export declare function parseCodexJsonStream(stdout: string): CodexJsonParseResult;