llm-cli-gateway 1.5.18 → 1.5.21

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/CHANGELOG.md CHANGED
@@ -2,6 +2,30 @@
2
2
 
3
3
  All notable changes to the llm-cli-gateway project.
4
4
 
5
+ ## [1.5.21] - 2026-05-24
6
+
7
+ ### Fixed
8
+
9
+ - Add a desktop `public-url` command that persists a public HTTPS `/mcp` endpoint for ChatGPT and other web clients.
10
+ - Pass the persisted public URL and verification flag into managed gateway starts and `doctor --json`, instead of relying on one-off shell environment state.
11
+ - Make `print-client-config` prefer the persisted public HTTPS URL while still reporting the local URL separately.
12
+
13
+ ## [1.5.20] - 2026-05-24
14
+
15
+ ### Fixed
16
+
17
+ - Do not inject Mistral `VIBE_ACTIVE_MODEL` when a request omits `model`; let Vibe use its own CLI default unless the caller explicitly asks for a model.
18
+ - Make `list_models`, `list_available_models`, and `models://*` omit bundled fallback entries from `models` and expose them only as `unverifiedModelHints`.
19
+ - Add warnings when model entries are only bundled fallback hints, so clients do not present unvalidated model names as available provider models.
20
+
21
+ ## [1.5.19] - 2026-05-24
22
+
23
+ ### Fixed
24
+
25
+ - Use the gateway's extended provider CLI PATH in `doctor --json`, not only in request execution.
26
+ - Add common Windows npm/Corepack/Scoop/Volta/Chocolatey CLI shim directories to provider PATH discovery.
27
+ - Resolve Windows PowerShell npm shims such as `gemini.ps1` and `claude.ps1` without invoking a shell command string.
28
+
5
29
  ## [1.5.18] - 2026-05-24
6
30
 
7
31
  ### Fixed
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from "crypto";
2
- import { getExtendedPath, killProcessGroup, spawnCliProcess, unregisterProcessGroup, } from "./executor.js";
2
+ import { envWithExtendedPath, getExtendedPath, killProcessGroup, spawnCliProcess, unregisterProcessGroup, } from "./executor.js";
3
3
  import { noopLogger } from "./logger.js";
4
4
  import { ProcessMonitor } from "./process-monitor.js";
5
5
  import { computeRequestKey } from "./job-store.js";
@@ -366,10 +366,11 @@ export class AsyncJobManager {
366
366
  // Mistral Vibe ships as the `vibe` binary; the gateway uses `mistral` as the
367
367
  // provider key but spawns `vibe` on the shell.
368
368
  const command = cli === "mistral" ? "vibe" : cli;
369
+ const baseEnv = envWithExtendedPath(process.env, getExtendedPath());
369
370
  const child = spawnCliProcess(command, args, {
370
371
  cwd,
371
372
  stdio: ["ignore", "pipe", "pipe"],
372
- env: { ...process.env, PATH: getExtendedPath(), ...(extraEnv ?? {}) },
373
+ env: { ...baseEnv, ...(extraEnv ?? {}) },
373
374
  });
374
375
  // Single cleanup flag to prevent double-unregister
375
376
  let groupCleaned = false;
@@ -13,7 +13,17 @@ export interface ExecuteResult {
13
13
  stderr: string;
14
14
  code: number;
15
15
  }
16
+ export declare function buildExtendedPath(env?: NodeJS.ProcessEnv, home?: string, nodePath?: string, platform?: NodeJS.Platform): string;
16
17
  export declare function getExtendedPath(): string;
18
+ export declare function envWithExtendedPath(baseEnv?: NodeJS.ProcessEnv, extendedPath?: string, platform?: NodeJS.Platform): NodeJS.ProcessEnv;
19
+ export interface ResolvedSpawnCommand {
20
+ command: string;
21
+ args: string[];
22
+ }
23
+ export declare function resolveCommandForSpawn(command: string, args: string[], options?: {
24
+ envPath?: string;
25
+ platform?: NodeJS.Platform;
26
+ }): ResolvedSpawnCommand;
17
27
  export declare function shouldDetachProviderProcess(platform?: NodeJS.Platform): boolean;
18
28
  export declare function registerProcessGroup(pid: number): void;
19
29
  export declare function unregisterProcessGroup(pid: number): void;
package/dist/executor.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { spawn, spawnSync } from "child_process";
2
2
  import { homedir } from "os";
3
- import { delimiter, join, dirname } from "path";
3
+ import { delimiter, join, dirname, extname, win32 } from "path";
4
4
  import { readdirSync, existsSync } from "fs";
5
5
  import { createCircuitBreaker, withRetry } from "./retry.js";
6
6
  const MAX_OUTPUT_SIZE = 50 * 1024 * 1024;
@@ -36,22 +36,123 @@ function getNvmPath() {
36
36
  }
37
37
  return cachedNvmPath;
38
38
  }
39
- // Extend PATH to include common locations for CLI tools
40
- export function getExtendedPath() {
41
- const home = homedir();
39
+ function pathDelimiterFor(platform) {
40
+ return platform === "win32" ? ";" : delimiter;
41
+ }
42
+ function pathJoinFor(platform, ...segments) {
43
+ return platform === "win32" ? win32.join(...segments) : join(...segments);
44
+ }
45
+ function dirnameFor(platform, path) {
46
+ return platform === "win32" ? win32.dirname(path) : dirname(path);
47
+ }
48
+ function pathValueFromEnv(env, platform) {
49
+ if (platform === "win32") {
50
+ return env.Path || env.PATH || "";
51
+ }
52
+ return env.PATH || "";
53
+ }
54
+ function addIfPresent(paths, value) {
55
+ if (value)
56
+ paths.push(value);
57
+ }
58
+ function windowsCommonCliPaths(env, home) {
59
+ const paths = [];
60
+ addIfPresent(paths, env.APPDATA ? pathJoinFor("win32", env.APPDATA, "npm") : undefined);
61
+ addIfPresent(paths, env.LOCALAPPDATA ? pathJoinFor("win32", env.LOCALAPPDATA, "pnpm") : undefined);
62
+ addIfPresent(paths, env.LOCALAPPDATA ? pathJoinFor("win32", env.LOCALAPPDATA, "Programs", "nodejs") : undefined);
63
+ addIfPresent(paths, env.LOCALAPPDATA ? pathJoinFor("win32", env.LOCALAPPDATA, "Programs", "npm") : undefined);
64
+ addIfPresent(paths, env.ProgramFiles ? pathJoinFor("win32", env.ProgramFiles, "nodejs") : undefined);
65
+ addIfPresent(paths, env["ProgramFiles(x86)"] ? pathJoinFor("win32", env["ProgramFiles(x86)"], "nodejs") : undefined);
66
+ addIfPresent(paths, env.ProgramData ? pathJoinFor("win32", env.ProgramData, "chocolatey", "bin") : undefined);
67
+ paths.push(pathJoinFor("win32", home, "AppData", "Roaming", "npm"));
68
+ paths.push(pathJoinFor("win32", home, "AppData", "Local", "pnpm"));
69
+ paths.push(pathJoinFor("win32", home, ".volta", "bin"));
70
+ paths.push(pathJoinFor("win32", home, "scoop", "shims"));
71
+ return paths;
72
+ }
73
+ export function buildExtendedPath(env = process.env, home = homedir(), nodePath = process.execPath, platform = process.platform) {
42
74
  const additionalPaths = [
43
- join(home, ".local/bin"),
44
- dirname(process.execPath), // Current node's bin directory
75
+ pathJoinFor(platform, home, ".local", "bin"),
76
+ dirnameFor(platform, nodePath), // Current node's bin directory
45
77
  "/usr/local/bin",
46
78
  "/usr/bin",
47
79
  ];
80
+ if (platform === "win32") {
81
+ additionalPaths.push(...windowsCommonCliPaths(env, home));
82
+ }
48
83
  // Add all nvm node version bin directories
49
84
  const nvmPath = getNvmPath();
50
85
  if (nvmPath) {
51
86
  additionalPaths.push(nvmPath);
52
87
  }
53
- const currentPath = process.env.PATH || "";
54
- return [...additionalPaths, currentPath].join(delimiter);
88
+ const currentPath = pathValueFromEnv(env, platform);
89
+ return [...dedupePaths(additionalPaths, platform), currentPath]
90
+ .filter(Boolean)
91
+ .join(pathDelimiterFor(platform));
92
+ }
93
+ // Extend PATH to include common locations for CLI tools.
94
+ export function getExtendedPath() {
95
+ return buildExtendedPath();
96
+ }
97
+ export function envWithExtendedPath(baseEnv = process.env, extendedPath = getExtendedPath(), platform = process.platform) {
98
+ const env = { ...baseEnv };
99
+ const key = platform === "win32"
100
+ ? Object.keys(env).find(existing => existing.toLowerCase() === "path") || "Path"
101
+ : "PATH";
102
+ env[key] = extendedPath;
103
+ if (platform === "win32") {
104
+ for (const existing of Object.keys(env)) {
105
+ if (existing !== key && existing.toLowerCase() === "path") {
106
+ delete env[existing];
107
+ }
108
+ }
109
+ }
110
+ return env;
111
+ }
112
+ function dedupePaths(paths, platform) {
113
+ const seen = new Set();
114
+ const result = [];
115
+ for (const path of paths) {
116
+ const normalized = platform === "win32" ? path.toLowerCase() : path;
117
+ if (seen.has(normalized))
118
+ continue;
119
+ seen.add(normalized);
120
+ result.push(path);
121
+ }
122
+ return result;
123
+ }
124
+ export function resolveCommandForSpawn(command, args, options = {}) {
125
+ const platform = options.platform ?? process.platform;
126
+ if (platform !== "win32") {
127
+ return { command, args };
128
+ }
129
+ const resolved = resolveWindowsCommandPath(command, options.envPath ?? getExtendedPath());
130
+ if (!resolved) {
131
+ return { command, args };
132
+ }
133
+ if (extname(resolved).toLowerCase() === ".ps1") {
134
+ return {
135
+ command: "powershell.exe",
136
+ args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", resolved, ...args],
137
+ };
138
+ }
139
+ return { command: resolved, args };
140
+ }
141
+ function resolveWindowsCommandPath(command, envPath) {
142
+ if (/[\\/]/.test(command)) {
143
+ return existsSync(command) ? command : null;
144
+ }
145
+ const hasExtension = extname(command) !== "";
146
+ const extensions = hasExtension ? [""] : ["", ".exe", ".ps1", ".cmd", ".bat"];
147
+ for (const dir of envPath.split(pathDelimiterFor("win32")).filter(Boolean)) {
148
+ for (const extension of extensions) {
149
+ const candidate = pathJoinFor("win32", dir, command + extension);
150
+ if (existsSync(candidate)) {
151
+ return candidate;
152
+ }
153
+ }
154
+ }
155
+ return null;
55
156
  }
56
157
  /** Registry of active detached process groups for shutdown cleanup. */
57
158
  const activeProcessGroups = new Set();
@@ -152,7 +253,10 @@ function killWindowsProcessTree(pid) {
152
253
  }
153
254
  export function spawnCliProcess(command, args, options) {
154
255
  const detached = shouldDetachProviderProcess();
155
- const proc = spawn(command, args, {
256
+ const resolved = resolveCommandForSpawn(command, args, {
257
+ envPath: pathValueFromEnv(options.env, process.platform),
258
+ });
259
+ const proc = spawn(resolved.command, resolved.args, {
156
260
  cwd: options.cwd,
157
261
  detached,
158
262
  windowsHide: true,
@@ -167,12 +271,13 @@ export function spawnCliProcess(command, args, options) {
167
271
  export async function executeCli(command, args, options = {}) {
168
272
  const { timeout, idleTimeout, cwd, env: extraEnv } = options;
169
273
  const extendedPath = getExtendedPath();
274
+ const baseEnv = envWithExtendedPath(process.env, extendedPath);
170
275
  const circuitBreaker = getCircuitBreaker(command);
171
276
  const runOnce = () => new Promise((resolve, reject) => {
172
277
  const proc = spawnCliProcess(command, args, {
173
278
  cwd,
174
279
  stdio: ["ignore", "pipe", "pipe"],
175
- env: { ...process.env, PATH: extendedPath, ...(extraEnv ?? {}) },
280
+ env: { ...baseEnv, ...(extraEnv ?? {}) },
176
281
  });
177
282
  let stdout = "";
178
283
  let stderr = "";
package/dist/index.d.ts CHANGED
@@ -160,6 +160,24 @@ export declare function prepareGeminiRequest(params: {
160
160
  adminPolicyFiles?: string[];
161
161
  attachments?: string[];
162
162
  }, runtime?: GatewayServerRuntime): CliRequestPrep | ExtendedToolResponse;
163
+ export declare function prepareMistralRequest(params: {
164
+ prompt: string;
165
+ model?: string;
166
+ outputFormat?: string;
167
+ permissionMode?: MistralAgentMode;
168
+ effort?: string;
169
+ reasoningEffort?: string;
170
+ allowedTools?: string[];
171
+ disallowedTools?: string[];
172
+ approvalStrategy: "legacy" | "mcp_managed";
173
+ approvalPolicy?: string;
174
+ mcpServers?: ClaudeMcpServerName[];
175
+ correlationId?: string;
176
+ optimizePrompt: boolean;
177
+ operation: string;
178
+ }, runtime?: GatewayServerRuntime): (CliRequestPrep & {
179
+ mistralEnv: Record<string, string>;
180
+ }) | ExtendedToolResponse;
163
181
  export interface GeminiRequestParams {
164
182
  prompt: string;
165
183
  model?: string;
package/dist/index.js CHANGED
@@ -16,7 +16,7 @@ import { PerformanceMetrics } from "./metrics.js";
16
16
  import { estimateTokens, optimizePrompt as optimizePromptText, optimizeResponse as optimizeResponseText, } from "./optimizer.js";
17
17
  import { loadConfig, loadPersistenceConfig } from "./config.js";
18
18
  import { checkHealth } from "./health.js";
19
- import { clearModelRegistryCache, getCliInfo, resolveModelAlias } from "./model-registry.js";
19
+ import { clearModelRegistryCache, getAvailableCliInfo, getCliInfo, resolveModelAlias, } from "./model-registry.js";
20
20
  import { AsyncJobManager } from "./async-job-manager.js";
21
21
  import { createJobStore } from "./job-store.js";
22
22
  import { ApprovalManager } from "./approval-manager.js";
@@ -1061,11 +1061,10 @@ function prepareGrokRequest(params, runtime = resolveGatewayServerRuntime()) {
1061
1061
  args,
1062
1062
  };
1063
1063
  }
1064
- function prepareMistralRequest(params, runtime = resolveGatewayServerRuntime()) {
1064
+ export function prepareMistralRequest(params, runtime = resolveGatewayServerRuntime()) {
1065
1065
  const corrId = params.correlationId || randomUUID();
1066
1066
  const cliInfo = getCliInfo();
1067
- const requestedModel = params.model ?? (cliInfo.mistral.defaultModel ? "default" : undefined);
1068
- const resolvedModel = resolveModelAlias("mistral", requestedModel, cliInfo);
1067
+ const resolvedModel = resolveModelAlias("mistral", params.model, cliInfo);
1069
1068
  const reviewIntegrity = checkReviewIntegrity({
1070
1069
  prompt: params.prompt,
1071
1070
  allowedTools: params.allowedTools,
@@ -3567,7 +3566,7 @@ export function createGatewayServer(deps = {}) {
3567
3566
  .preprocess(value => (value === "" || value === null ? undefined : value), z.enum(["claude", "codex", "gemini", "grok", "mistral"]).optional())
3568
3567
  .describe("CLI filter (claude|codex|gemini|grok|mistral)"),
3569
3568
  }, async ({ cli }) => {
3570
- const cliInfo = getCliInfo();
3569
+ const cliInfo = getAvailableCliInfo();
3571
3570
  const result = cli ? { [cli]: cliInfo[cli] } : cliInfo;
3572
3571
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
3573
3572
  });
@@ -10,6 +10,7 @@ export interface ModelMetadata {
10
10
  export interface CliInfo {
11
11
  description: string;
12
12
  models: Record<string, string>;
13
+ unverifiedModelHints?: Record<string, string>;
13
14
  defaultModel?: string;
14
15
  defaultModelSource?: string;
15
16
  modelOrder?: string[];
@@ -19,6 +20,7 @@ export interface CliInfo {
19
20
  }
20
21
  export type CliInfoMap = Record<CliType, CliInfo>;
21
22
  export declare function getCliInfo(forceRefresh?: boolean): CliInfoMap;
23
+ export declare function getAvailableCliInfo(forceRefresh?: boolean): CliInfoMap;
22
24
  export declare function clearModelRegistryCache(): void;
23
25
  export declare function resolveModelAlias(cli: CliType, model: string | undefined, info: CliInfoMap): string | undefined;
24
26
  export {};
@@ -75,6 +75,16 @@ export function getCliInfo(forceRefresh = false) {
75
75
  cachedInfo = { loadedAt: Date.now(), info };
76
76
  return info;
77
77
  }
78
+ export function getAvailableCliInfo(forceRefresh = false) {
79
+ const info = getCliInfo(forceRefresh);
80
+ return {
81
+ claude: filterUnverifiedModelHints(info.claude),
82
+ codex: filterUnverifiedModelHints(info.codex),
83
+ gemini: filterUnverifiedModelHints(info.gemini),
84
+ grok: filterUnverifiedModelHints(info.grok),
85
+ mistral: filterUnverifiedModelHints(info.mistral),
86
+ };
87
+ }
78
88
  export function clearModelRegistryCache() {
79
89
  cachedInfo = null;
80
90
  }
@@ -125,6 +135,9 @@ function cloneInfo(source) {
125
135
  const cloned = {
126
136
  description: source.description,
127
137
  models: { ...source.models },
138
+ unverifiedModelHints: source.unverifiedModelHints
139
+ ? { ...source.unverifiedModelHints }
140
+ : undefined,
128
141
  defaultModel: source.defaultModel,
129
142
  defaultModelSource: source.defaultModelSource,
130
143
  modelOrder: source.modelOrder ? [...source.modelOrder] : undefined,
@@ -141,6 +154,41 @@ function cloneInfo(source) {
141
154
  });
142
155
  return cloned;
143
156
  }
157
+ function filterUnverifiedModelHints(source) {
158
+ const filtered = cloneInfo(source);
159
+ const models = {};
160
+ const metadata = {};
161
+ const hints = {};
162
+ Object.entries(source.models).forEach(([model, description]) => {
163
+ const modelMetadata = source.modelMetadata?.[model];
164
+ if (modelMetadata?.source === "fallback") {
165
+ hints[model] = description;
166
+ return;
167
+ }
168
+ models[model] = description;
169
+ if (modelMetadata) {
170
+ metadata[model] = modelMetadata;
171
+ }
172
+ });
173
+ filtered.models = models;
174
+ filtered.modelMetadata = metadata;
175
+ filtered.unverifiedModelHints = Object.keys(hints).length > 0 ? hints : undefined;
176
+ filtered.modelOrder = source.modelOrder?.filter(model => model in models);
177
+ if (filtered.defaultModel && !(filtered.defaultModel in models)) {
178
+ filtered.defaultModel = undefined;
179
+ filtered.defaultModelSource = undefined;
180
+ }
181
+ if (filtered.aliases) {
182
+ filtered.aliases = Object.fromEntries(Object.entries(filtered.aliases).filter(([, target]) => target === "default" || target in models));
183
+ if (Object.keys(filtered.aliases).length === 0) {
184
+ filtered.aliases = undefined;
185
+ }
186
+ }
187
+ if (filtered.unverifiedModelHints) {
188
+ addWarning(filtered, "Bundled fallback model hints were omitted from models because they are not validated against the installed CLI. They are exposed as unverifiedModelHints only.");
189
+ }
190
+ return filtered;
191
+ }
144
192
  function addWarning(info, warning) {
145
193
  info.warnings = info.warnings ?? [];
146
194
  if (!info.warnings.includes(warning)) {
@@ -3,6 +3,7 @@ import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { getProviderLoginGuidance } from "./provider-login-guidance.js";
6
+ import { envWithExtendedPath, getExtendedPath, resolveCommandForSpawn } from "./executor.js";
6
7
  const PROVIDERS = ["claude", "codex", "gemini", "grok", "mistral"];
7
8
  const VERSION_ARGS = {
8
9
  claude: ["--version"],
@@ -96,8 +97,12 @@ export function getProviderRuntimeStatus(provider) {
96
97
  };
97
98
  }
98
99
  function runCommand(command, args, timeoutMs) {
99
- const result = spawnSync(command, args, {
100
+ const extendedPath = getExtendedPath();
101
+ const env = envWithExtendedPath(process.env, extendedPath);
102
+ const resolved = resolveCommandForSpawn(command, args, { envPath: extendedPath });
103
+ const result = spawnSync(resolved.command, resolved.args, {
100
104
  encoding: "utf8",
105
+ env,
101
106
  input: "",
102
107
  timeout: timeoutMs,
103
108
  windowsHide: true,
package/dist/resources.js CHANGED
@@ -1,4 +1,4 @@
1
- import { getCliInfo } from "./model-registry.js";
1
+ import { getAvailableCliInfo } from "./model-registry.js";
2
2
  export class ResourceProvider {
3
3
  sessionManager;
4
4
  performanceMetrics;
@@ -238,7 +238,7 @@ export class ResourceProvider {
238
238
  }
239
239
  // Model capability resources
240
240
  if (uri === "models://claude") {
241
- const cliInfo = getCliInfo();
241
+ const cliInfo = getAvailableCliInfo();
242
242
  return {
243
243
  uri,
244
244
  mimeType: "application/json",
@@ -246,7 +246,7 @@ export class ResourceProvider {
246
246
  };
247
247
  }
248
248
  if (uri === "models://codex") {
249
- const cliInfo = getCliInfo();
249
+ const cliInfo = getAvailableCliInfo();
250
250
  return {
251
251
  uri,
252
252
  mimeType: "application/json",
@@ -254,7 +254,7 @@ export class ResourceProvider {
254
254
  };
255
255
  }
256
256
  if (uri === "models://gemini") {
257
- const cliInfo = getCliInfo();
257
+ const cliInfo = getAvailableCliInfo();
258
258
  return {
259
259
  uri,
260
260
  mimeType: "application/json",
@@ -262,7 +262,7 @@ export class ResourceProvider {
262
262
  };
263
263
  }
264
264
  if (uri === "models://grok") {
265
- const cliInfo = getCliInfo();
265
+ const cliInfo = getAvailableCliInfo();
266
266
  return {
267
267
  uri,
268
268
  mimeType: "application/json",
@@ -270,7 +270,7 @@ export class ResourceProvider {
270
270
  };
271
271
  }
272
272
  if (uri === "models://mistral") {
273
- const cliInfo = getCliInfo();
273
+ const cliInfo = getAvailableCliInfo();
274
274
  return {
275
275
  uri,
276
276
  mimeType: "application/json",
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { getCliInfo } from "./model-registry.js";
2
+ import { getAvailableCliInfo } from "./model-registry.js";
3
3
  import { collectValidationJobResult, startJudgeSynthesis, startValidationRun, } from "./validation-orchestrator.js";
4
4
  const providerSchema = z.enum(["claude", "codex", "gemini", "grok", "mistral"]);
5
5
  const providerListSchema = z.array(providerSchema).min(1).default(["claude", "codex"]);
@@ -160,7 +160,7 @@ export function registerValidationTools(server, deps) {
160
160
  judgeProvider: judgeModel,
161
161
  }),
162
162
  }));
163
- server.tool("list_available_models", {}, async () => textResponse({ success: true, models: getCliInfo() }));
163
+ server.tool("list_available_models", {}, async () => textResponse({ success: true, models: getAvailableCliInfo() }));
164
164
  server.tool("job_status", {
165
165
  jobId: z.string().min(1).describe("Validation job ID."),
166
166
  }, async ({ jobId }) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-cli-gateway",
3
- "version": "1.5.18",
3
+ "version": "1.5.21",
4
4
  "mcpName": "io.github.verivus-oss/llm-cli-gateway",
5
5
  "description": "MCP server providing unified access to Claude Code, Codex, Gemini, Grok, and Mistral Vibe CLIs with session management, retry logic, async job orchestration, durable job results, and cross-LLM validation.",
6
6
  "license": "MIT",