@liustack/modlens 3.7.0 → 3.9.0

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/dist/main.js CHANGED
@@ -3,7 +3,7 @@ import { Command } from "commander";
3
3
  import * as fs from "fs";
4
4
  import * as path from "path";
5
5
  import * as childProcess from "child_process";
6
- import { spawn, execFileSync } from "child_process";
6
+ import { execFileSync, spawn } from "child_process";
7
7
  import * as os from "os";
8
8
  import { fileURLToPath } from "url";
9
9
  import require$$0$1 from "node:assert";
@@ -33,6 +33,82 @@ import * as dns$1 from "dns/promises";
33
33
  import { isIP } from "net";
34
34
  import { createRequire } from "module";
35
35
  import * as crypto from "crypto";
36
+ function denyPatterns(guards) {
37
+ return stringPatterns(guards?.denyModels);
38
+ }
39
+ function allowPatterns(guards) {
40
+ return stringPatterns(guards?.allowModels);
41
+ }
42
+ function stringPatterns(raw) {
43
+ if (!Array.isArray(raw)) {
44
+ return [];
45
+ }
46
+ return raw.filter((pattern) => typeof pattern === "string");
47
+ }
48
+ function globMatch(pattern, value) {
49
+ const regex = pattern.split(/([*?])/).map((part) => {
50
+ if (part === "*") {
51
+ return ".*";
52
+ }
53
+ if (part === "?") {
54
+ return ".";
55
+ }
56
+ return part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
57
+ }).join("");
58
+ return new RegExp(`^${regex}$`, "i").test(value);
59
+ }
60
+ function evaluateGuard(guards, detection) {
61
+ const deny = denyPatterns(guards);
62
+ const allow = allowPatterns(guards);
63
+ if (!detection.model) {
64
+ if (guards?.denyWhenUnknown === true) {
65
+ return {
66
+ ...detection,
67
+ guard: "deny",
68
+ reason: "model unknown and denyWhenUnknown is set"
69
+ };
70
+ }
71
+ return {
72
+ ...detection,
73
+ guard: "allow",
74
+ reason: deny.length === 0 && allow.length === 0 ? "no deny rules configured" : "model unknown, failing open"
75
+ };
76
+ }
77
+ if (deny.length === 0 && allow.length === 0) {
78
+ return { ...detection, guard: "allow", reason: "no deny rules configured" };
79
+ }
80
+ const candidates = [detection.model];
81
+ if (detection.provider) {
82
+ candidates.push(`${detection.provider}/${detection.model}`);
83
+ }
84
+ const firstMatch = (patterns) => patterns.find((pattern) => candidates.some((candidate) => globMatch(pattern, candidate)));
85
+ const denied = firstMatch(deny);
86
+ if (denied) {
87
+ return {
88
+ ...detection,
89
+ guard: "deny",
90
+ matched: denied,
91
+ reason: "model has native vision per guards.denyModels"
92
+ };
93
+ }
94
+ if (allow.length > 0) {
95
+ const allowed = firstMatch(allow);
96
+ if (allowed) {
97
+ return {
98
+ ...detection,
99
+ guard: "allow",
100
+ matched: allowed,
101
+ reason: "model is on guards.allowModels"
102
+ };
103
+ }
104
+ return {
105
+ ...detection,
106
+ guard: "deny",
107
+ reason: "not on guards.allowModels: only listed models run the engine"
108
+ };
109
+ }
110
+ return { ...detection, guard: "allow", reason: "not on the deny list" };
111
+ }
36
112
  var undici = { exports: {} };
37
113
  var symbols;
38
114
  var hasRequiredSymbols;
@@ -27335,6 +27411,8 @@ async function readCapped(response2, url) {
27335
27411
  }
27336
27412
  return Buffer.concat(chunks);
27337
27413
  }
27414
+ const JSON_TEMPLATE_INSTRUCTION = `Respond with ONE JSON object only, no markdown fences, no commentary. Fill this exact structure with your findings from the image (do not repeat this template literally, replace every value):
27415
+ {"summary":"one paragraph describing the image","ocr":{"full_text":"all visible text","lines":[{"text":"one line","language":"en"}]},"layout":{"regions":[{"type":"title|subtitle|paragraph|list|table|chart|form|code|image|icon|other","reading_order":1,"text":"region text"}]},"semantics":{"scene":"what kind of scene","intent":"what the image is for","entities":[{"name":"entity","type":"kind","evidence":"where seen"}],"relations":[{"subject":"a","predicate":"relates to","object":"b"}]},"visual":{"dominant_colors":["color"],"style":"visual style","notes":["notable visual detail"]},"uncertainty":["anything unreadable or ambiguous"]}`;
27338
27416
  function buildVisionPrompt(options) {
27339
27417
  const readInstruction = options.imageKind === "inline" ? "Analyze the image attached to this message." : options.imageKind === "remote" ? `Fetch the image at this URL and analyze it: ${options.imageSource}` : `Read the image file at this path and analyze it: ${options.imageSource}`;
27340
27418
  const basePrompt = `${readInstruction}
@@ -28000,8 +28078,7 @@ async function executeOpenaiCompat(options) {
28000
28078
  extraPrompt: options.extraPrompt
28001
28079
  })}
28002
28080
 
28003
- Respond with ONE JSON object only, no markdown fences, no commentary. Fill this exact structure with your findings from the image (do not repeat this template literally, replace every value):
28004
- {"summary":"one paragraph describing the image","ocr":{"full_text":"all visible text","lines":[{"text":"one line","language":"en"}]},"layout":{"regions":[{"type":"title|subtitle|paragraph|list|table|chart|form|code|image|icon|other","reading_order":1,"text":"region text"}]},"semantics":{"scene":"what kind of scene","intent":"what the image is for","entities":[{"name":"entity","type":"kind","evidence":"where seen"}],"relations":[{"subject":"a","predicate":"relates to","object":"b"}]},"visual":{"dominant_colors":["color"],"style":"visual style","notes":["notable visual detail"]},"uncertainty":["anything unreadable or ambiguous"]}`;
28081
+ ${JSON_TEMPLATE_INSTRUCTION}`;
28005
28082
  const startedAt = Date.now();
28006
28083
  const response2 = await fetch(`${baseUrl}/chat/completions`, {
28007
28084
  method: "POST",
@@ -28098,6 +28175,7 @@ function listProviders() {
28098
28175
  return [...new Set(Object.values(PROVIDERS).map((provider) => provider.name))];
28099
28176
  }
28100
28177
  const STRING_FIELDS = ["apiKey", "baseUrl", "model"];
28178
+ const REUSE_HARNESSES = ["claude", "codex", "opencode", "pi", "grok"];
28101
28179
  const CONFIG_DIR = path.join(os.homedir(), ".modlens");
28102
28180
  const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
28103
28181
  const ENV_BINDINGS = {
@@ -28149,19 +28227,33 @@ function setConfigValue(dottedKey, value, configPath = CONFIG_PATH) {
28149
28227
  const config2 = loadConfigFile(configPath);
28150
28228
  if (dottedKey === "provider") {
28151
28229
  config2.provider = value;
28152
- } else if (dottedKey === "auto") {
28230
+ } else if (dottedKey.startsWith("reuse.")) {
28231
+ const harness = dottedKey.slice("reuse.".length);
28232
+ if (!REUSE_HARNESSES.includes(harness)) {
28233
+ throw new Error(
28234
+ `Unknown reuse harness: ${harness}. Use ${REUSE_HARNESSES.join(", ")}.`
28235
+ );
28236
+ }
28237
+ const key = harness;
28153
28238
  const normalized = value.trim().toLowerCase();
28154
- if (normalized !== "true" && normalized !== "false") {
28155
- throw new Error("auto must be true or false.");
28239
+ if (normalized === "") {
28240
+ delete config2.reuse?.[key];
28241
+ if (config2.reuse && Object.keys(config2.reuse).length === 0) {
28242
+ delete config2.reuse;
28243
+ }
28244
+ } else if (normalized !== "true" && normalized !== "false") {
28245
+ throw new Error(`reuse.${harness} must be true or false (empty clears).`);
28246
+ } else {
28247
+ config2.reuse ??= {};
28248
+ config2.reuse[key] = normalized === "true";
28156
28249
  }
28157
- config2.auto = normalized === "true";
28158
28250
  } else if (dottedKey.startsWith("guards.")) {
28159
28251
  setGuardsValue(config2, dottedKey.slice("guards.".length), value);
28160
28252
  } else {
28161
28253
  const dot = dottedKey.indexOf(".");
28162
28254
  if (dot <= 0 || dot === dottedKey.length - 1) {
28163
28255
  throw new Error(
28164
- `Invalid config key: ${dottedKey}. Use "provider", "auto", "guards.<denyModels|allowModels|denyWhenUnknown>", or "<provider>.<apiKey|baseUrl|model|extraBody>".`
28256
+ `Invalid config key: ${dottedKey}. Use "provider", "reuse.<claude|codex|opencode|pi|grok>", "guards.<denyModels|allowModels|denyWhenUnknown>", or "<provider>.<apiKey|baseUrl|model|extraBody>".`
28165
28257
  );
28166
28258
  }
28167
28259
  const providerName = dottedKey.slice(0, dot);
@@ -28286,6 +28378,9 @@ function renderEffectiveConfig(config2, env = process.env) {
28286
28378
  if (config2.guards.denyModels !== void 0) {
28287
28379
  guards.denyModels = `${JSON.stringify(config2.guards.denyModels)} (file)`;
28288
28380
  }
28381
+ if (config2.guards.allowModels !== void 0) {
28382
+ guards.allowModels = `${JSON.stringify(config2.guards.allowModels)} (file)`;
28383
+ }
28289
28384
  if (config2.guards.denyWhenUnknown !== void 0) {
28290
28385
  guards.denyWhenUnknown = `${config2.guards.denyWhenUnknown} (file)`;
28291
28386
  }
@@ -28293,6 +28388,14 @@ function renderEffectiveConfig(config2, env = process.env) {
28293
28388
  effective.guards = guards;
28294
28389
  }
28295
28390
  }
28391
+ if (config2.reuse && Object.keys(config2.reuse).length > 0) {
28392
+ effective.reuse = Object.fromEntries(
28393
+ Object.entries(config2.reuse).map(([harness, granted]) => [
28394
+ harness,
28395
+ `${granted} (file)`
28396
+ ])
28397
+ );
28398
+ }
28296
28399
  return JSON.stringify(effective, null, 2);
28297
28400
  }
28298
28401
  function maskKey(key) {
@@ -28362,15 +28465,18 @@ function providerAvailable(name, config2, env = process.env) {
28362
28465
  return (descriptor.required ?? []).every((req) => Boolean(settings[req.field]?.trim()));
28363
28466
  }
28364
28467
  const LOCAL_FAILOVER_ORDER = [
28365
- "antigravity-cli",
28366
28468
  "gemini-api",
28367
28469
  "openai",
28368
28470
  "anthropic",
28471
+ "antigravity-cli",
28369
28472
  "claude-cli"
28370
28473
  ];
28371
28474
  const REMOTE_FAILOVER_ORDER = ["gemini-api", "openai", "anthropic", "antigravity-cli"];
28372
28475
  function providerChain(kind, config2, env = process.env) {
28373
- const names = [...kind === "remote" ? REMOTE_FAILOVER_ORDER : LOCAL_FAILOVER_ORDER];
28476
+ let names = [...kind === "remote" ? REMOTE_FAILOVER_ORDER : LOCAL_FAILOVER_ORDER];
28477
+ if (config2.reuse?.claude === false) {
28478
+ names = names.filter((name) => name !== "claude-cli");
28479
+ }
28374
28480
  const preferred = config2.provider?.trim();
28375
28481
  if (preferred) {
28376
28482
  let canonical = null;
@@ -28390,453 +28496,155 @@ function providerChain(kind, config2, env = process.env) {
28390
28496
  }
28391
28497
  return names.filter((name) => providerAvailable(name, config2, env)).map((name) => resolveProvider(name));
28392
28498
  }
28393
- const DEFAULT_TIMEOUT_MS = 18e4;
28394
- const KILL_GRACE_MS = 3e4;
28395
- const DRAIN_GRACE_MS = 500;
28396
- const SIGKILL_GRACE_MS = 2e3;
28397
- async function analyzeImage(options) {
28398
- const resolvedInput = resolveInput(options.input);
28399
- if (resolvedInput.kind === "local") {
28400
- validateInputFile(resolvedInput.source);
28401
- }
28402
- const config2 = options.config ?? loadConfigFile();
28403
- const chain = options.provider ? [resolveProvider(options.provider)] : options.providerBin ? [resolveProvider("antigravity-cli")] : providerChain(resolvedInput.kind, config2);
28404
- if (chain.length === 0) {
28405
- throw new Error(
28406
- "No vision provider is set up on this machine. Install Antigravity CLI (curl -fsSL https://antigravity.google/cli/install.sh | bash, then run agy once to sign in), or configure a key: modlens config set gemini-api.apiKey <key>. Run modlens doctor for the full picture."
28407
- );
28408
- }
28409
- const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
28410
- const attempts = [];
28411
- const warnings = [];
28412
- let lastError;
28413
- for (const provider of chain) {
28414
- const startedAt = Date.now();
28415
- const model = (attempts.length === 0 ? options.model : void 0) || resolveProviderSettings(provider.name, config2).model || provider.defaultModel;
28499
+ const VISION_MODEL_PATTERNS = [
28500
+ "claude-*",
28501
+ "gpt-4o*",
28502
+ "gpt-4.1*",
28503
+ "gpt-5*",
28504
+ "o3*",
28505
+ "o4*",
28506
+ "gemini-*",
28507
+ "glm-*v*",
28508
+ "qwen*-vl*",
28509
+ "qwen3.5-plus*",
28510
+ "qwen3.6-plus*",
28511
+ "kimi-k2.5*",
28512
+ "kimi-k2.6*",
28513
+ "kimi-k2.7*",
28514
+ "kimi-k3*",
28515
+ "moonshot-v1-*vision*",
28516
+ "minimax-vl*",
28517
+ "minimax-m3*",
28518
+ "deepseek-vl*",
28519
+ "deepseek-ocr*",
28520
+ "janus*",
28521
+ "pixtral*",
28522
+ "llama-4*",
28523
+ "llama-3.2-*vision*",
28524
+ "grok-4*",
28525
+ "grok-2-vision*",
28526
+ "internvl*"
28527
+ ];
28528
+ function isVisionModel(modelId) {
28529
+ const bare = modelId.includes("/") ? modelId.slice(modelId.lastIndexOf("/") + 1) : modelId;
28530
+ return VISION_MODEL_PATTERNS.some((pattern) => globMatch(pattern, bare));
28531
+ }
28532
+ const DEFAULT_TTL_MS = 6 * 60 * 60 * 1e3;
28533
+ const CLI_TIMEOUT_MS = 1e4;
28534
+ function defaultRunCli(bin, args, timeoutMs) {
28535
+ return execFileSync(bin, args, { encoding: "utf-8", timeout: timeoutMs, stdio: "pipe" });
28536
+ }
28537
+ function timed(run) {
28538
+ const start = Date.now();
28539
+ const probe = run();
28540
+ return { ...probe, elapsedMs: Date.now() - start };
28541
+ }
28542
+ function readJson(filePath) {
28543
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
28544
+ }
28545
+ function probeClaude(env) {
28546
+ return timed(() => {
28547
+ const cliPath = findOnPath("claude", env);
28548
+ if (!cliPath) {
28549
+ return {
28550
+ harness: "claude-code",
28551
+ cliFound: false,
28552
+ visionModels: [],
28553
+ source: "none"
28554
+ };
28555
+ }
28556
+ return {
28557
+ harness: "claude-code",
28558
+ cliFound: true,
28559
+ cliPath,
28560
+ visionModels: ["anthropic/* (all current models)"],
28561
+ source: "builtin-table"
28562
+ };
28563
+ });
28564
+ }
28565
+ function probeCodex(env, home) {
28566
+ return timed(() => {
28567
+ const cliPath = findOnPath("codex", env);
28568
+ const base = { harness: "codex", cliFound: cliPath !== null };
28569
+ if (!cliPath) {
28570
+ return { ...base, visionModels: [], source: "none" };
28571
+ }
28572
+ const codexHome = path.join(home, ".codex");
28573
+ const loggedIn = fs.existsSync(path.join(codexHome, "auth.json"));
28574
+ if (!fs.existsSync(path.join(codexHome, "config.toml"))) {
28575
+ return {
28576
+ ...base,
28577
+ cliPath,
28578
+ loggedIn,
28579
+ visionModels: ["default"],
28580
+ source: "builtin-table"
28581
+ };
28582
+ }
28416
28583
  try {
28417
- const parsed = await runProvider(
28418
- provider,
28419
- model,
28420
- options,
28421
- resolvedInput,
28422
- timeoutMs,
28423
- config2,
28424
- warnings
28425
- );
28426
- attempts.push({
28427
- provider: provider.name,
28428
- ok: true,
28429
- durationSeconds: (Date.now() - startedAt) / 1e3
28430
- });
28431
- if (attempts.length > 1) {
28432
- const failed = attempts.slice(0, -1);
28433
- warnings.push(
28434
- `Failed over to ${provider.name} after: ${failed.map((attempt) => `${attempt.provider} (${attempt.error})`).join("; ")}.`
28435
- );
28436
- if (options.model) {
28437
- warnings.push(
28438
- `The explicit model applied to ${failed[0].provider} only; ${provider.name} ran its own default.`
28439
- );
28440
- }
28584
+ const toml = fs.readFileSync(path.join(codexHome, "config.toml"), "utf-8");
28585
+ const catalogPath = toml.match(/^model_catalog_json\s*=\s*"([^"]+)"/m)?.[1];
28586
+ const thirdParty = /^model_provider\s*=/m.test(toml);
28587
+ if (!catalogPath) {
28588
+ return thirdParty ? { ...base, cliPath, loggedIn, visionModels: [], source: "none" } : {
28589
+ ...base,
28590
+ cliPath,
28591
+ loggedIn,
28592
+ visionModels: ["default"],
28593
+ source: "builtin-table"
28594
+ };
28441
28595
  }
28596
+ const catalog = readJson(catalogPath);
28597
+ const vision = (catalog.models ?? []).filter((m) => m.slug && (m.input_modalities ?? []).includes("image")).map((m) => m.slug);
28442
28598
  return {
28443
- image: resolvedInput.source,
28444
- provider: provider.name,
28445
- result: parsed.result,
28446
- meta: {
28447
- generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
28448
- model,
28449
- conversationId: parsed.meta.conversationId,
28450
- durationSeconds: parsed.meta.durationSeconds,
28451
- usage: parsed.meta.usage,
28452
- attempts,
28453
- warnings
28454
- }
28599
+ ...base,
28600
+ cliPath,
28601
+ loggedIn,
28602
+ visionModels: vision,
28603
+ source: "metadata"
28455
28604
  };
28456
28605
  } catch (error) {
28457
- lastError = error;
28458
- const message = error instanceof Error ? error.message : String(error);
28459
- attempts.push({
28460
- provider: provider.name,
28461
- ok: false,
28462
- durationSeconds: (Date.now() - startedAt) / 1e3,
28463
- error: message.slice(0, 300)
28464
- });
28606
+ return {
28607
+ ...base,
28608
+ cliPath,
28609
+ loggedIn,
28610
+ visionModels: [],
28611
+ source: "none",
28612
+ error: error instanceof Error ? error.message : String(error)
28613
+ };
28465
28614
  }
28466
- }
28467
- if (chain.length === 1) {
28468
- throw lastError;
28469
- }
28470
- throw new Error(
28471
- `Every configured vision provider failed for this image. ${attempts.map((attempt) => `${attempt.provider}: ${attempt.error}`).join(" | ")}`
28472
- );
28473
- }
28474
- async function runProvider(provider, model, options, resolvedInput, timeoutMs, config2, warnings) {
28475
- const configured = resolveProviderSettings(provider.name, config2);
28476
- const settings = options.extraBody ? { ...configured, extraBody: options.extraBody } : configured;
28477
- if (settings.extraBody && !provider.execute) {
28478
- warnings.push(
28479
- `${provider.name} is a CLI provider and takes no request body, so extraBody was ignored for this run.`
28480
- );
28481
- }
28482
- const providerOptions = {
28483
- imageSource: resolvedInput.source,
28484
- imageKind: resolvedInput.kind,
28485
- model,
28486
- extraPrompt: options.prompt,
28487
- providerBin: options.providerBin,
28488
- workdir: options.workdir,
28489
- timeoutMs,
28490
- settings
28491
- };
28492
- let parsed;
28493
- if (provider.execute) {
28494
- parsed = await provider.execute(providerOptions);
28495
- } else if (provider.buildInvocation && provider.parseOutput) {
28496
- const buildInvocation = provider.buildInvocation;
28497
- const parseOutput = provider.parseOutput;
28498
- const isolation = !options.workdir && provider.isolateWorkdir ? resolvedInput.kind === "local" ? isolateImage(resolvedInput.source) : emptyWorkdir() : null;
28499
- try {
28500
- const invocation = buildInvocation({
28501
- ...providerOptions,
28502
- imageSource: isolation?.imageSource ?? providerOptions.imageSource,
28503
- workdir: isolation?.workdir ?? providerOptions.workdir
28504
- });
28505
- const backstop = provider.hasInternalTimeout ? timeoutMs + KILL_GRACE_MS : timeoutMs;
28506
- const commandResult = await runCommand(
28507
- provider.name,
28508
- invocation,
28509
- backstop,
28510
- provider.describeFailure
28511
- );
28512
- parsed = parseOutput(commandResult.stdout);
28513
- } finally {
28514
- isolation?.cleanup();
28515
- }
28516
- } else {
28517
- throw new Error(
28518
- `Provider ${provider.name} implements neither execute nor buildInvocation.`
28519
- );
28520
- }
28521
- const missing = missingSchemaFields(parsed.result);
28522
- if (missing.length > 0) {
28523
- throw new Error(
28524
- `${provider.name} returned a result that does not match the vision schema (missing: ${missing.join(", ")}).`
28525
- );
28526
- }
28527
- return parsed;
28528
- }
28529
- function resolveInput(input) {
28530
- const trimmed = input.trim();
28531
- if (!trimmed) {
28532
- throw new Error("Input path is required.");
28533
- }
28534
- if (isRemoteSource(trimmed)) {
28535
- return { source: trimmed, kind: "remote" };
28536
- }
28537
- if (/^file:\/\//i.test(trimmed)) {
28538
- return { source: path.resolve(fileURLToPath(trimmed)), kind: "local" };
28539
- }
28540
- return { source: path.resolve(trimmed), kind: "local" };
28541
- }
28542
- function isRemoteSource(value) {
28543
- return /^https?:\/\//i.test(value.trim());
28544
- }
28545
- function validateInputFile(filePath) {
28546
- if (!fs.existsSync(filePath)) {
28547
- throw new Error(`Input image not found: ${filePath}`);
28548
- }
28549
- const stat = fs.statSync(filePath);
28550
- if (!stat.isFile()) {
28551
- throw new Error(`Input is not a file: ${filePath}`);
28552
- }
28553
- }
28554
- function isolateImage(source) {
28555
- const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "modlens-work-"));
28556
- const imageSource = path.join(workdir, path.basename(source));
28557
- fs.copyFileSync(source, imageSource);
28558
- fs.chmodSync(imageSource, 384);
28559
- return {
28560
- imageSource,
28561
- workdir,
28562
- cleanup: () => fs.rmSync(workdir, { recursive: true, force: true })
28563
- };
28564
- }
28565
- function emptyWorkdir() {
28566
- const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "modlens-work-"));
28567
- return {
28568
- workdir,
28569
- cleanup: () => fs.rmSync(workdir, { recursive: true, force: true })
28570
- };
28571
- }
28572
- function runCommand(providerName, invocation, timeoutMs, describeFailure) {
28573
- const runStartedAt = Date.now();
28574
- return new Promise((resolve, reject) => {
28575
- const child = spawn(invocation.command, invocation.args, {
28576
- cwd: invocation.cwd,
28577
- stdio: ["ignore", "pipe", "pipe"]
28578
- });
28579
- const outDecoder = new TextDecoder("utf-8");
28580
- const errDecoder = new TextDecoder("utf-8");
28581
- let stdout = "";
28582
- let stderr = "";
28583
- let timedOut = false;
28584
- let settled = false;
28585
- let drainTimer;
28586
- const timer = setTimeout(() => {
28587
- timedOut = true;
28588
- child.kill("SIGTERM");
28589
- settle(null);
28590
- setTimeout(() => {
28591
- if (!exited) {
28592
- child.kill("SIGKILL");
28593
- }
28594
- }, SIGKILL_GRACE_MS).unref();
28595
- }, timeoutMs);
28596
- const settle = (code) => {
28597
- if (settled) {
28598
- return;
28599
- }
28600
- settled = true;
28601
- clearTimeout(timer);
28602
- clearTimeout(drainTimer);
28603
- stdout += outDecoder.decode();
28604
- stderr += errDecoder.decode();
28605
- child.stdout?.destroy();
28606
- child.stderr?.destroy();
28607
- child.unref();
28608
- if (timedOut) {
28609
- reject(new Error(`${providerName} provider timed out after ${timeoutMs} ms.`));
28610
- return;
28611
- }
28612
- if (code !== 0) {
28613
- const explained = describeFailure?.({ stdout, stderr, code, startedAt: runStartedAt }) ?? null;
28614
- reject(
28615
- new Error(
28616
- explained ?? `${providerName} provider failed with code ${code}.${stderr ? ` stderr: ${stderr.trim()}` : ""}`
28617
- )
28618
- );
28619
- return;
28620
- }
28621
- resolve({ stdout, stderr });
28622
- };
28623
- let exitCode = null;
28624
- let exited = false;
28625
- const restartDrain = () => {
28626
- if (!exited || settled) {
28627
- return;
28628
- }
28629
- clearTimeout(drainTimer);
28630
- drainTimer = setTimeout(() => settle(exitCode), DRAIN_GRACE_MS);
28631
- };
28632
- child.stdout.on("data", (chunk) => {
28633
- stdout += outDecoder.decode(chunk, { stream: true });
28634
- restartDrain();
28635
- });
28636
- child.stderr.on("data", (chunk) => {
28637
- stderr += errDecoder.decode(chunk, { stream: true });
28638
- restartDrain();
28639
- });
28640
- child.on("error", (error) => {
28641
- if (settled) {
28642
- return;
28643
- }
28644
- settled = true;
28645
- clearTimeout(timer);
28646
- clearTimeout(drainTimer);
28647
- if (error.code === "ENOENT") {
28648
- const missingCwd = !fs.existsSync(invocation.cwd);
28649
- reject(
28650
- new Error(
28651
- missingCwd ? `Working directory does not exist: ${invocation.cwd}` : `Provider CLI not found: ${invocation.command}. Install it and sign in first.`
28652
- )
28653
- );
28654
- return;
28655
- }
28656
- reject(error);
28657
- });
28658
- child.on("exit", (code) => {
28659
- exitCode = code;
28660
- exited = true;
28661
- restartDrain();
28662
- });
28663
- child.on("close", (code) => settle(code));
28664
28615
  });
28665
28616
  }
28666
- function denyPatterns(guards) {
28667
- return stringPatterns(guards?.denyModels);
28668
- }
28669
- function allowPatterns(guards) {
28670
- return stringPatterns(guards?.allowModels);
28671
- }
28672
- function stringPatterns(raw) {
28673
- if (!Array.isArray(raw)) {
28674
- return [];
28675
- }
28676
- return raw.filter((pattern) => typeof pattern === "string");
28677
- }
28678
- function globMatch(pattern, value) {
28679
- const regex = pattern.split(/([*?])/).map((part) => {
28680
- if (part === "*") {
28681
- return ".*";
28682
- }
28683
- if (part === "?") {
28684
- return ".";
28685
- }
28686
- return part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
28687
- }).join("");
28688
- return new RegExp(`^${regex}$`, "i").test(value);
28689
- }
28690
- function evaluateGuard(guards, detection) {
28691
- const deny = denyPatterns(guards);
28692
- const allow = allowPatterns(guards);
28693
- if (!detection.model) {
28694
- if (guards?.denyWhenUnknown === true) {
28695
- return {
28696
- ...detection,
28697
- guard: "deny",
28698
- reason: "model unknown and denyWhenUnknown is set"
28699
- };
28700
- }
28701
- return {
28702
- ...detection,
28703
- guard: "allow",
28704
- reason: deny.length === 0 && allow.length === 0 ? "no deny rules configured" : "model unknown, failing open"
28705
- };
28706
- }
28707
- if (deny.length === 0 && allow.length === 0) {
28708
- return { ...detection, guard: "allow", reason: "no deny rules configured" };
28709
- }
28710
- const candidates = [detection.model];
28711
- if (detection.provider) {
28712
- candidates.push(`${detection.provider}/${detection.model}`);
28713
- }
28714
- const firstMatch = (patterns) => patterns.find((pattern) => candidates.some((candidate) => globMatch(pattern, candidate)));
28715
- const denied = firstMatch(deny);
28716
- if (denied) {
28717
- return {
28718
- ...detection,
28719
- guard: "deny",
28720
- matched: denied,
28721
- reason: "model has native vision per guards.denyModels"
28722
- };
28723
- }
28724
- if (allow.length > 0) {
28725
- const allowed = firstMatch(allow);
28726
- if (allowed) {
28727
- return {
28728
- ...detection,
28729
- guard: "allow",
28730
- matched: allowed,
28731
- reason: "model is on guards.allowModels"
28732
- };
28733
- }
28734
- return {
28735
- ...detection,
28736
- guard: "deny",
28737
- reason: "not on guards.allowModels: only listed models run the engine"
28738
- };
28739
- }
28740
- return { ...detection, guard: "allow", reason: "not on the deny list" };
28741
- }
28742
- const VISION_MODEL_PATTERNS = [
28743
- "claude-*",
28744
- "gpt-4o*",
28745
- "gpt-4.1*",
28746
- "gpt-5*",
28747
- "o3*",
28748
- "o4*",
28749
- "gemini-*",
28750
- "glm-*v*",
28751
- "qwen*-vl*",
28752
- "qwen3.5-plus*",
28753
- "qwen3.6-plus*",
28754
- "kimi-k2.5*",
28755
- "kimi-k2.6*",
28756
- "kimi-k2.7*",
28757
- "kimi-k3*",
28758
- "moonshot-v1-*vision*",
28759
- "minimax-vl*",
28760
- "minimax-m3*",
28761
- "deepseek-vl*",
28762
- "deepseek-ocr*",
28763
- "janus*",
28764
- "pixtral*",
28765
- "llama-4*",
28766
- "llama-3.2-*vision*",
28767
- "grok-4*",
28768
- "grok-2-vision*",
28769
- "internvl*"
28770
- ];
28771
- function isVisionModel(modelId) {
28772
- const bare = modelId.includes("/") ? modelId.slice(modelId.lastIndexOf("/") + 1) : modelId;
28773
- return VISION_MODEL_PATTERNS.some((pattern) => globMatch(pattern, bare));
28774
- }
28775
- const DEFAULT_TTL_MS = 6 * 60 * 60 * 1e3;
28776
- const CLI_TIMEOUT_MS = 1e4;
28777
- function defaultRunCli(bin, args, timeoutMs) {
28778
- return execFileSync(bin, args, { encoding: "utf-8", timeout: timeoutMs, stdio: "pipe" });
28779
- }
28780
- function timed(run) {
28781
- const start = Date.now();
28782
- const probe = run();
28783
- return { ...probe, elapsedMs: Date.now() - start };
28784
- }
28785
- function readJson(filePath) {
28786
- return JSON.parse(fs.readFileSync(filePath, "utf-8"));
28787
- }
28788
- function probeClaude(env) {
28789
- return timed(() => {
28790
- const cliPath = findOnPath("claude", env);
28791
- if (!cliPath) {
28792
- return {
28793
- harness: "claude-code",
28794
- cliFound: false,
28795
- visionModels: [],
28796
- source: "none"
28797
- };
28798
- }
28799
- return {
28800
- harness: "claude-code",
28801
- cliFound: true,
28802
- cliPath,
28803
- visionModels: ["anthropic/* (all current models)"],
28804
- source: "builtin-table"
28805
- };
28806
- });
28807
- }
28808
- function probeCodex(env, home) {
28617
+ function probeGrok(env, home) {
28809
28618
  return timed(() => {
28810
- const cliPath = findOnPath("codex", env);
28811
- const base = { harness: "codex", cliFound: cliPath !== null };
28619
+ const cliPath = findOnPath("grok", env);
28620
+ const base = { harness: "grok", cliFound: cliPath !== null };
28812
28621
  if (!cliPath) {
28813
28622
  return { ...base, visionModels: [], source: "none" };
28814
28623
  }
28815
- const codexHome = path.join(home, ".codex");
28816
- const loggedIn = fs.existsSync(path.join(codexHome, "auth.json"));
28624
+ const grokHome = path.join(home, ".grok");
28625
+ let loggedIn = false;
28817
28626
  try {
28818
- const toml = fs.readFileSync(path.join(codexHome, "config.toml"), "utf-8");
28819
- const catalogPath = toml.match(/^model_catalog_json\s*=\s*"([^"]+)"/m)?.[1];
28820
- if (!catalogPath) {
28821
- return { ...base, cliPath, loggedIn, visionModels: [], source: "none" };
28822
- }
28823
- const catalog = readJson(catalogPath);
28824
- const vision = (catalog.models ?? []).filter((m) => m.slug && (m.input_modalities ?? []).includes("image")).map((m) => m.slug);
28627
+ const auth = readJson(path.join(grokHome, "auth.json"));
28628
+ loggedIn = Object.keys(auth).length > 0;
28629
+ } catch {
28630
+ }
28631
+ try {
28632
+ const cache2 = readJson(path.join(grokHome, "models_cache.json"));
28633
+ const vision = Object.keys(cache2.models ?? {}).filter((id) => isVisionModel(id));
28825
28634
  return {
28826
28635
  ...base,
28827
28636
  cliPath,
28828
28637
  loggedIn,
28829
- visionModels: vision,
28830
- source: "metadata"
28638
+ visionModels: vision.length > 0 ? vision : ["default"],
28639
+ source: "builtin-table"
28831
28640
  };
28832
- } catch (error) {
28641
+ } catch {
28833
28642
  return {
28834
28643
  ...base,
28835
28644
  cliPath,
28836
28645
  loggedIn,
28837
- visionModels: [],
28838
- source: "none",
28839
- error: error instanceof Error ? error.message : String(error)
28646
+ visionModels: ["default"],
28647
+ source: "builtin-table"
28840
28648
  };
28841
28649
  }
28842
28650
  });
@@ -28887,61 +28695,804 @@ function probeOpencode(env, runCli) {
28887
28695
  return { ...base, visionModels: [], source: "none" };
28888
28696
  }
28889
28697
  try {
28890
- const listing = runCli(cliPath, ["models"], CLI_TIMEOUT_MS);
28891
- const vision = listing.split("\n").map((line) => line.trim()).filter((line) => line.length > 0 && isVisionModel(line));
28892
- return { ...base, cliPath, visionModels: vision, source: "builtin-table" };
28893
- } catch (error) {
28894
- return {
28895
- ...base,
28896
- cliPath,
28897
- visionModels: [],
28898
- source: "none",
28899
- error: error instanceof Error ? error.message : String(error)
28900
- };
28698
+ const listing = runCli(cliPath, ["models"], CLI_TIMEOUT_MS);
28699
+ const vision = listing.split("\n").map((line) => line.trim()).filter((line) => line.length > 0 && isVisionModel(line));
28700
+ return { ...base, cliPath, visionModels: vision, source: "builtin-table" };
28701
+ } catch (error) {
28702
+ return {
28703
+ ...base,
28704
+ cliPath,
28705
+ visionModels: [],
28706
+ source: "none",
28707
+ error: error instanceof Error ? error.message : String(error)
28708
+ };
28709
+ }
28710
+ });
28711
+ }
28712
+ function readCache(cachePath, ttlMs) {
28713
+ try {
28714
+ const cached = readJson(cachePath);
28715
+ if (!cached.cachedAt || !Array.isArray(cached.probes)) {
28716
+ return null;
28717
+ }
28718
+ const cachedAtMs = Date.parse(cached.cachedAt);
28719
+ if (!Number.isFinite(cachedAtMs) || Date.now() - cachedAtMs > ttlMs) {
28720
+ return null;
28721
+ }
28722
+ return cached;
28723
+ } catch {
28724
+ return null;
28725
+ }
28726
+ }
28727
+ function discoverAuto(options = {}) {
28728
+ const env = options.env ?? process.env;
28729
+ const home = options.home ?? os.homedir();
28730
+ const cachePath = options.cachePath ?? path.join(home, ".modlens", "auto-cache.json");
28731
+ const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
28732
+ if (!options.fresh) {
28733
+ const cached = readCache(cachePath, ttlMs);
28734
+ if (cached) {
28735
+ return { probes: cached.probes, cachedAt: cached.cachedAt, fromCache: true };
28736
+ }
28737
+ }
28738
+ const runCli = options.runCli ?? defaultRunCli;
28739
+ const probes = [
28740
+ probeClaude(env),
28741
+ probeCodex(env, home),
28742
+ probeOpencode(env, runCli),
28743
+ probePi(env, home),
28744
+ probeGrok(env, home)
28745
+ ];
28746
+ const cachedAt = (/* @__PURE__ */ new Date()).toISOString();
28747
+ try {
28748
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true });
28749
+ fs.writeFileSync(cachePath, JSON.stringify({ cachedAt, probes }, null, 2), {
28750
+ mode: 384
28751
+ });
28752
+ } catch {
28753
+ }
28754
+ return { probes, cachedAt, fromCache: false };
28755
+ }
28756
+ const KEY_FETCH_TIMEOUT_MS = 1e4;
28757
+ function codexCliRoute(visionModel) {
28758
+ return {
28759
+ name: "codex-cli",
28760
+ defaultModel: visionModel,
28761
+ isolateWorkdir: true,
28762
+ reuseNote: "this read reused the local Codex CLI login and spent that account's quota.",
28763
+ buildInvocation: (options) => {
28764
+ if (options.imageKind === "remote") {
28765
+ throw new Error(
28766
+ "codex-cli route reads local files only. Remote URLs stay on the inline providers."
28767
+ );
28768
+ }
28769
+ const prompt = `${buildVisionPrompt({
28770
+ imageSource: options.imageSource,
28771
+ imageKind: "inline",
28772
+ extraPrompt: options.extraPrompt
28773
+ })}
28774
+
28775
+ ${JSON_TEMPLATE_INSTRUCTION}`;
28776
+ const model = options.model || visionModel;
28777
+ const args = [
28778
+ "exec",
28779
+ "--skip-git-repo-check",
28780
+ "--ephemeral",
28781
+ "-s",
28782
+ "read-only",
28783
+ "--json",
28784
+ "-i",
28785
+ options.imageSource
28786
+ ];
28787
+ if (model && model !== "default") {
28788
+ args.push("-m", model);
28789
+ }
28790
+ args.push("--", prompt);
28791
+ return {
28792
+ command: options.providerBin || "codex",
28793
+ args,
28794
+ cwd: path.resolve(options.workdir || path.dirname(options.imageSource))
28795
+ };
28796
+ },
28797
+ parseOutput: (stdout) => {
28798
+ let threadId = null;
28799
+ let usage = null;
28800
+ let lastMessage = null;
28801
+ for (const line of stdout.split("\n")) {
28802
+ const event = tryParseJson(line.trim());
28803
+ if (!event) {
28804
+ continue;
28805
+ }
28806
+ if (event.type === "thread.started" && event.thread_id) {
28807
+ threadId = event.thread_id;
28808
+ }
28809
+ if (event.type === "turn.completed" && event.usage !== void 0) {
28810
+ usage = event.usage;
28811
+ }
28812
+ if (event.type === "item.completed" && event.item?.type === "agent_message" && typeof event.item.text === "string") {
28813
+ lastMessage = event.item.text;
28814
+ }
28815
+ }
28816
+ if (!lastMessage) {
28817
+ throw new Error(
28818
+ "codex exec produced no agent message. Check the Codex login (run: codex)."
28819
+ );
28820
+ }
28821
+ const result = extractJson(lastMessage);
28822
+ if (result === null) {
28823
+ throw new Error(`codex returned a non-JSON answer: ${truncate(lastMessage)}`);
28824
+ }
28825
+ return {
28826
+ result,
28827
+ meta: { conversationId: threadId, durationSeconds: null, usage }
28828
+ };
28829
+ }
28830
+ };
28831
+ }
28832
+ function opencodeCliRoute(modelId) {
28833
+ return {
28834
+ name: "opencode-cli",
28835
+ defaultModel: modelId,
28836
+ isolateWorkdir: true,
28837
+ reuseNote: `this read reused OpenCode's ${modelId} and spent that account's quota.`,
28838
+ buildInvocation: (options) => {
28839
+ if (options.imageKind === "remote") {
28840
+ throw new Error(
28841
+ "opencode-cli route reads local files only. Remote URLs stay on the inline providers."
28842
+ );
28843
+ }
28844
+ const prompt = `${buildVisionPrompt({
28845
+ imageSource: options.imageSource,
28846
+ imageKind: "inline",
28847
+ extraPrompt: options.extraPrompt
28848
+ })}
28849
+
28850
+ ${JSON_TEMPLATE_INSTRUCTION}`;
28851
+ return {
28852
+ command: options.providerBin || "opencode",
28853
+ args: [
28854
+ "run",
28855
+ prompt,
28856
+ "-m",
28857
+ options.model || modelId,
28858
+ "--format",
28859
+ "json",
28860
+ "-f",
28861
+ options.imageSource
28862
+ ],
28863
+ cwd: path.resolve(options.workdir || path.dirname(options.imageSource))
28864
+ };
28865
+ },
28866
+ parseOutput: (stdout) => {
28867
+ let sessionId = null;
28868
+ let usage = null;
28869
+ const texts = [];
28870
+ for (const line of stdout.split("\n")) {
28871
+ const event = tryParseJson(line.trim());
28872
+ if (!event) {
28873
+ continue;
28874
+ }
28875
+ sessionId ??= event.sessionID ?? null;
28876
+ if (event.type === "text" && typeof event.part?.text === "string") {
28877
+ texts.push(event.part.text);
28878
+ }
28879
+ if (event.type === "step_finish" && event.part?.tokens !== void 0) {
28880
+ usage = event.part.tokens;
28881
+ }
28882
+ }
28883
+ const answer = texts.join("").trim();
28884
+ if (!answer) {
28885
+ throw new Error("opencode run produced no text answer.");
28886
+ }
28887
+ const result = extractJson(answer);
28888
+ if (result === null) {
28889
+ throw new Error(`opencode returned a non-JSON answer: ${truncate(answer)}`);
28890
+ }
28891
+ return {
28892
+ result,
28893
+ meta: { conversationId: sessionId, durationSeconds: null, usage }
28894
+ };
28895
+ }
28896
+ };
28897
+ }
28898
+ function grokCliRoute(modelId) {
28899
+ return {
28900
+ name: "grok-cli",
28901
+ defaultModel: modelId,
28902
+ isolateWorkdir: true,
28903
+ reuseNote: "this read reused the local Grok CLI login and spent that account's quota.",
28904
+ buildInvocation: (options) => {
28905
+ if (options.imageKind === "remote") {
28906
+ throw new Error(
28907
+ "grok-cli route reads local files only. Remote URLs stay on the inline providers."
28908
+ );
28909
+ }
28910
+ const prompt = buildVisionPrompt({
28911
+ imageSource: options.imageSource,
28912
+ imageKind: "local",
28913
+ extraPrompt: options.extraPrompt
28914
+ });
28915
+ const args = [
28916
+ "-p",
28917
+ prompt,
28918
+ "--output-format",
28919
+ "json",
28920
+ "--json-schema",
28921
+ visionResultSchemaJson(),
28922
+ "--allow",
28923
+ "Read"
28924
+ ];
28925
+ const model = options.model || modelId;
28926
+ if (model && model !== "default") {
28927
+ args.push("-m", model);
28928
+ }
28929
+ return {
28930
+ command: options.providerBin || "grok",
28931
+ args,
28932
+ cwd: path.resolve(options.workdir || path.dirname(options.imageSource))
28933
+ };
28934
+ },
28935
+ parseOutput: (stdout) => {
28936
+ const envelope = parseJsonLoose(stdout);
28937
+ if (!envelope || typeof envelope !== "object") {
28938
+ throw new Error(
28939
+ "grok produced no JSON envelope. Check the Grok login (run: grok)."
28940
+ );
28941
+ }
28942
+ const result = envelope.structuredOutput ?? (typeof envelope.text === "string" ? extractJson(envelope.text) : null);
28943
+ if (result === null || result === void 0) {
28944
+ throw new Error(
28945
+ `grok returned no structured output: ${truncate(envelope.text ?? "")}`
28946
+ );
28947
+ }
28948
+ return {
28949
+ result,
28950
+ meta: {
28951
+ conversationId: envelope.sessionId ?? null,
28952
+ durationSeconds: null,
28953
+ usage: envelope.usage ?? null
28954
+ }
28955
+ };
28956
+ }
28957
+ };
28958
+ }
28959
+ const PI_API_TARGETS = {
28960
+ "openai-completions": "openai",
28961
+ "anthropic-messages": "anthropic"
28962
+ };
28963
+ const DEFAULT_TARGETS = {
28964
+ openai: openaiCompatProvider,
28965
+ anthropic: anthropicApiProvider,
28966
+ "gemini-api": geminiApiProvider
28967
+ };
28968
+ function piCliRoute(providerName, modelId) {
28969
+ return {
28970
+ name: "pi-cli",
28971
+ defaultModel: modelId,
28972
+ isolateWorkdir: true,
28973
+ reuseNote: `this read reused pi's ${providerName}/${modelId} and spent that account's quota.`,
28974
+ buildInvocation: (options) => {
28975
+ if (options.imageKind === "remote") {
28976
+ throw new Error(
28977
+ "pi-cli route reads local files only. Remote URLs stay on the inline providers."
28978
+ );
28979
+ }
28980
+ const prompt = `${buildVisionPrompt({
28981
+ imageSource: options.imageSource,
28982
+ imageKind: "inline",
28983
+ extraPrompt: options.extraPrompt
28984
+ })}
28985
+
28986
+ ${JSON_TEMPLATE_INSTRUCTION}`;
28987
+ return {
28988
+ command: options.providerBin || "pi",
28989
+ args: [
28990
+ "-p",
28991
+ "--no-session",
28992
+ "--no-tools",
28993
+ "--mode",
28994
+ "json",
28995
+ "--provider",
28996
+ providerName,
28997
+ "--model",
28998
+ options.model || modelId,
28999
+ `@${options.imageSource}`,
29000
+ prompt
29001
+ ],
29002
+ cwd: path.resolve(options.workdir || path.dirname(options.imageSource))
29003
+ };
29004
+ },
29005
+ parseOutput: (stdout) => {
29006
+ let final = null;
29007
+ for (const line of stdout.split("\n")) {
29008
+ const event = tryParseJson(line.trim());
29009
+ if (event?.type === "message_end" && event.message) {
29010
+ final = event.message;
29011
+ }
29012
+ }
29013
+ const answer = (final?.content ?? []).filter((part) => part.type === "text" && typeof part.text === "string").map((part) => part.text).join("").trim();
29014
+ if (!answer) {
29015
+ throw new Error("pi produced no text answer. Check pi and its credentials.");
29016
+ }
29017
+ const result = extractJson(answer);
29018
+ if (result === null) {
29019
+ throw new Error(`pi returned a non-JSON answer: ${truncate(answer)}`);
29020
+ }
29021
+ return {
29022
+ result,
29023
+ meta: {
29024
+ conversationId: final?.responseId ?? null,
29025
+ durationSeconds: null,
29026
+ usage: final?.usage ?? null
29027
+ }
29028
+ };
29029
+ }
29030
+ };
29031
+ }
29032
+ function piRoutes(home, env, targets = DEFAULT_TARGETS) {
29033
+ const empty = { inline: [], agents: [] };
29034
+ const piPath = findOnPath("pi", env);
29035
+ if (!piPath) {
29036
+ return empty;
29037
+ }
29038
+ const agentDir = path.join(home, ".pi", "agent");
29039
+ let auth;
29040
+ let store;
29041
+ try {
29042
+ auth = JSON.parse(fs.readFileSync(path.join(agentDir, "auth.json"), "utf-8"));
29043
+ store = JSON.parse(fs.readFileSync(path.join(agentDir, "models-store.json"), "utf-8"));
29044
+ } catch {
29045
+ return empty;
29046
+ }
29047
+ const routes = [];
29048
+ const agents = [];
29049
+ const usedTargets = /* @__PURE__ */ new Set();
29050
+ for (const entry of Object.values(store)) {
29051
+ for (const model of entry?.models ?? []) {
29052
+ if (!model.id || !model.provider || !model.baseUrl || !(model.input ?? []).includes("image") || !(model.provider in auth)) {
29053
+ continue;
29054
+ }
29055
+ const credential = auth[model.provider];
29056
+ const targetName = PI_API_TARGETS[model.api ?? ""];
29057
+ const target = targetName ? targets[targetName] : void 0;
29058
+ const targetExecute = target?.execute;
29059
+ if (!target || !targetExecute || credential?.type !== "api_key") {
29060
+ if (agents.length < 2) {
29061
+ agents.push(piCliRoute(model.provider, model.id));
29062
+ }
29063
+ continue;
29064
+ }
29065
+ if (usedTargets.has(target.name)) {
29066
+ continue;
29067
+ }
29068
+ usedTargets.add(target.name);
29069
+ const { id, provider, baseUrl } = model;
29070
+ routes.push({
29071
+ name: `pi:${target.name}`,
29072
+ defaultModel: id,
29073
+ reuseNote: `this read reused pi's ${provider} credentials for ${id} and spent that account's quota.`,
29074
+ execute: async (options) => {
29075
+ const apiKey = fetchPiKey(
29076
+ piPath,
29077
+ id,
29078
+ provider,
29079
+ Math.min(KEY_FETCH_TIMEOUT_MS, options.timeoutMs || KEY_FETCH_TIMEOUT_MS)
29080
+ );
29081
+ return targetExecute({
29082
+ ...options,
29083
+ settings: {
29084
+ ...options.settings ?? {},
29085
+ apiKey,
29086
+ baseUrl,
29087
+ model: options.model || id
29088
+ }
29089
+ });
29090
+ }
29091
+ });
29092
+ }
29093
+ }
29094
+ return { inline: routes.slice(0, 2), agents };
29095
+ }
29096
+ function fetchPiKey(piPath, modelId, provider, timeoutMs) {
29097
+ try {
29098
+ const key = execFileSync(
29099
+ piPath,
29100
+ ["auth", "print-api-key", "--model", modelId, "--provider", provider],
29101
+ { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], timeout: timeoutMs }
29102
+ ).trim();
29103
+ if (!key) {
29104
+ throw new Error("empty");
29105
+ }
29106
+ return key;
29107
+ } catch {
29108
+ throw new Error(
29109
+ `pi could not print an API key for ${provider}/${modelId}. Run \`pi auth\` to check that credential.`
29110
+ );
29111
+ }
29112
+ }
29113
+ function reuseProviders(kind, config2, options = {}) {
29114
+ const env = options.env ?? process.env;
29115
+ const home = options.home ?? os.homedir();
29116
+ const grants = config2.reuse ?? {};
29117
+ const inline = [];
29118
+ const agents = [];
29119
+ let piAgents = [];
29120
+ if (grants.pi === true) {
29121
+ try {
29122
+ const pi = piRoutes(home, env, options.targets);
29123
+ inline.push(...pi.inline);
29124
+ piAgents = pi.agents;
29125
+ } catch {
29126
+ }
29127
+ }
29128
+ if (kind === "local" && (grants.codex === true || grants.opencode === true || grants.grok === true)) {
29129
+ try {
29130
+ const discovery = options.discovery ?? discoverAuto({ env, home });
29131
+ const codex = discovery.probes.find((probe) => probe.harness === "codex");
29132
+ if (grants.codex === true && codex?.cliFound && codex.loggedIn !== false && codex.visionModels[0]) {
29133
+ agents.push(codexCliRoute(codex.visionModels[0]));
29134
+ }
29135
+ const opencode = discovery.probes.find((probe) => probe.harness === "opencode");
29136
+ if (grants.opencode === true && opencode?.cliFound && opencode.visionModels[0]) {
29137
+ agents.push(opencodeCliRoute(opencode.visionModels[0]));
29138
+ }
29139
+ const grok = discovery.probes.find((probe) => probe.harness === "grok");
29140
+ if (grants.grok === true && grok?.cliFound && grok.loggedIn !== false && grok.visionModels[0]) {
29141
+ agents.push(grokCliRoute(grok.visionModels[0]));
29142
+ }
29143
+ } catch {
29144
+ }
29145
+ }
29146
+ if (kind === "local") {
29147
+ agents.push(...piAgents);
29148
+ }
29149
+ return { inline, agents };
29150
+ }
29151
+ const DEFAULT_TIMEOUT_MS = 18e4;
29152
+ const KILL_GRACE_MS = 3e4;
29153
+ const DRAIN_GRACE_MS = 500;
29154
+ const SIGKILL_GRACE_MS = 2e3;
29155
+ async function analyzeImage(options) {
29156
+ const resolvedInput = resolveInput(options.input);
29157
+ if (resolvedInput.kind === "local") {
29158
+ validateInputFile(resolvedInput.source);
29159
+ }
29160
+ const config2 = options.config ?? loadConfigFile();
29161
+ const chain = options.provider ? [resolveProvider(options.provider)] : options.providerBin ? [resolveProvider("antigravity-cli")] : composeChain(resolvedInput.kind, config2, options.autoOptions);
29162
+ if (chain.length === 0) {
29163
+ throw new Error(
29164
+ "No vision provider is set up on this machine. Install Antigravity CLI (curl -fsSL https://antigravity.google/cli/install.sh | bash, then run agy once to sign in), or configure a key: modlens config set gemini-api.apiKey <key>. Run modlens doctor for the full picture." + reuseHint(config2, options.autoOptions)
29165
+ );
29166
+ }
29167
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
29168
+ const attempts = [];
29169
+ const warnings = [];
29170
+ let lastError;
29171
+ for (const provider of chain) {
29172
+ const startedAt = Date.now();
29173
+ const model = (attempts.length === 0 ? options.model : void 0) || resolveProviderSettings(provider.name, config2).model || provider.defaultModel;
29174
+ try {
29175
+ const parsed = await runProvider(
29176
+ provider,
29177
+ model,
29178
+ options,
29179
+ resolvedInput,
29180
+ timeoutMs,
29181
+ config2,
29182
+ warnings
29183
+ );
29184
+ attempts.push({
29185
+ provider: provider.name,
29186
+ ok: true,
29187
+ durationSeconds: (Date.now() - startedAt) / 1e3
29188
+ });
29189
+ if (provider.reuseNote) {
29190
+ warnings.push(provider.reuseNote);
29191
+ }
29192
+ if (attempts.length > 1) {
29193
+ const failed = attempts.slice(0, -1);
29194
+ warnings.push(
29195
+ `Failed over to ${provider.name} after: ${failed.map((attempt) => `${attempt.provider} (${attempt.error})`).join("; ")}.`
29196
+ );
29197
+ if (options.model) {
29198
+ warnings.push(
29199
+ `The explicit model applied to ${failed[0].provider} only; ${provider.name} ran its own default.`
29200
+ );
29201
+ }
29202
+ }
29203
+ return {
29204
+ image: resolvedInput.source,
29205
+ provider: provider.name,
29206
+ result: parsed.result,
29207
+ meta: {
29208
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
29209
+ model,
29210
+ conversationId: parsed.meta.conversationId,
29211
+ durationSeconds: parsed.meta.durationSeconds,
29212
+ usage: parsed.meta.usage,
29213
+ attempts,
29214
+ warnings
29215
+ }
29216
+ };
29217
+ } catch (error) {
29218
+ lastError = error;
29219
+ const message = error instanceof Error ? error.message : String(error);
29220
+ attempts.push({
29221
+ provider: provider.name,
29222
+ ok: false,
29223
+ durationSeconds: (Date.now() - startedAt) / 1e3,
29224
+ error: message.slice(0, 300)
29225
+ });
29226
+ }
29227
+ }
29228
+ if (chain.length === 1) {
29229
+ if (!options.provider && !options.providerBin && lastError instanceof Error) {
29230
+ const hint = reuseHint(config2, options.autoOptions);
29231
+ if (hint) {
29232
+ lastError.message += hint;
29233
+ }
29234
+ }
29235
+ throw lastError;
29236
+ }
29237
+ throw new Error(
29238
+ `Every configured vision provider failed for this image. ${attempts.map((attempt) => `${attempt.provider}: ${attempt.error}`).join(" | ")}${reuseHint(config2, options.autoOptions)}`
29239
+ );
29240
+ }
29241
+ const INLINE_REGION = /* @__PURE__ */ new Set(["gemini-api", "openai", "anthropic"]);
29242
+ function composeChain(kind, config2, autoOptions) {
29243
+ const chain = [...providerChain(kind, config2, autoOptions?.env ?? process.env)];
29244
+ const borrowed = reuseProviders(kind, config2, autoOptions);
29245
+ let preferredName = null;
29246
+ if (config2.provider?.trim()) {
29247
+ try {
29248
+ preferredName = resolveProvider(config2.provider.trim()).name;
29249
+ } catch {
29250
+ preferredName = null;
28901
29251
  }
28902
- });
29252
+ }
29253
+ if (borrowed.inline.length > 0) {
29254
+ const lastInline = chain.map((p) => INLINE_REGION.has(p.name)).lastIndexOf(true);
29255
+ const insertAt = lastInline >= 0 ? lastInline + 1 : kind === "local" && preferredName === chain[0]?.name ? 1 : 0;
29256
+ chain.splice(insertAt, 0, ...borrowed.inline);
29257
+ }
29258
+ if (borrowed.agents.length > 0) {
29259
+ const last = chain[chain.length - 1];
29260
+ const beforeClaude = last?.name === "claude-cli" && preferredName !== "claude-cli";
29261
+ chain.splice(beforeClaude ? chain.length - 1 : chain.length, 0, ...borrowed.agents);
29262
+ }
29263
+ return chain;
28903
29264
  }
28904
- function readCache(cachePath, ttlMs) {
29265
+ const REUSE_KEY_BY_HARNESS = {
29266
+ codex: "codex",
29267
+ opencode: "opencode",
29268
+ pi: "pi",
29269
+ grok: "grok"
29270
+ };
29271
+ function reuseHint(config2, autoOptions) {
28905
29272
  try {
28906
- const cached = readJson(cachePath);
28907
- if (!cached.cachedAt || !Array.isArray(cached.probes)) {
28908
- return null;
29273
+ const grants = config2.reuse ?? {};
29274
+ const discovery = autoOptions?.discovery ?? discoverAuto({ env: autoOptions?.env, home: autoOptions?.home });
29275
+ const unasked = [];
29276
+ const dead = [];
29277
+ for (const probe of discovery.probes) {
29278
+ const key = REUSE_KEY_BY_HARNESS[probe.harness];
29279
+ if (key === void 0) {
29280
+ continue;
29281
+ }
29282
+ const usable = probe.cliFound && probe.visionModels.length > 0 && probe.loggedIn !== false;
29283
+ if (grants[key] === void 0 && usable) {
29284
+ unasked.push(probe.harness);
29285
+ } else if (grants[key] === true && !usable) {
29286
+ dead.push(probe.harness);
29287
+ }
28909
29288
  }
28910
- if (Date.now() - Date.parse(cached.cachedAt) > ttlMs) {
28911
- return null;
29289
+ const parts = [];
29290
+ if (unasked.length > 0) {
29291
+ parts.push(
29292
+ ` Hint: this machine has vision reachable through ${unasked.join(", ")}, which modlens is not yet allowed to reuse. Ask the user, then: modlens config set reuse.<harness> true.`
29293
+ );
28912
29294
  }
28913
- return cached;
29295
+ if (dead.length > 0) {
29296
+ parts.push(
29297
+ ` Note: reuse is granted for ${dead.join(", ")} but it is currently unusable (signed out, uninstalled, or no vision model); check that CLI's login.`
29298
+ );
29299
+ }
29300
+ return parts.join("");
28914
29301
  } catch {
28915
- return null;
29302
+ return "";
28916
29303
  }
28917
29304
  }
28918
- function discoverAuto(options = {}) {
28919
- const env = options.env ?? process.env;
28920
- const home = options.home ?? os.homedir();
28921
- const cachePath = options.cachePath ?? path.join(home, ".modlens", "auto-cache.json");
28922
- const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
28923
- if (!options.fresh) {
28924
- const cached = readCache(cachePath, ttlMs);
28925
- if (cached) {
28926
- return { probes: cached.probes, cachedAt: cached.cachedAt, fromCache: true };
29305
+ async function runProvider(provider, model, options, resolvedInput, timeoutMs, config2, warnings) {
29306
+ const configured = resolveProviderSettings(provider.name, config2);
29307
+ const settings = options.extraBody ? { ...configured, extraBody: options.extraBody } : configured;
29308
+ if (settings.extraBody && !provider.execute) {
29309
+ warnings.push(
29310
+ `${provider.name} is a CLI provider and takes no request body, so extraBody was ignored for this run.`
29311
+ );
29312
+ }
29313
+ const providerOptions = {
29314
+ imageSource: resolvedInput.source,
29315
+ imageKind: resolvedInput.kind,
29316
+ model,
29317
+ extraPrompt: options.prompt,
29318
+ providerBin: options.providerBin,
29319
+ workdir: options.workdir,
29320
+ timeoutMs,
29321
+ settings
29322
+ };
29323
+ let parsed;
29324
+ if (provider.execute) {
29325
+ parsed = await provider.execute(providerOptions);
29326
+ } else if (provider.buildInvocation && provider.parseOutput) {
29327
+ const buildInvocation = provider.buildInvocation;
29328
+ const parseOutput = provider.parseOutput;
29329
+ const isolation = !options.workdir && provider.isolateWorkdir ? resolvedInput.kind === "local" ? isolateImage(resolvedInput.source) : emptyWorkdir() : null;
29330
+ try {
29331
+ const invocation = buildInvocation({
29332
+ ...providerOptions,
29333
+ imageSource: isolation?.imageSource ?? providerOptions.imageSource,
29334
+ workdir: isolation?.workdir ?? providerOptions.workdir
29335
+ });
29336
+ const backstop = provider.hasInternalTimeout ? timeoutMs + KILL_GRACE_MS : timeoutMs;
29337
+ const commandResult = await runCommand(
29338
+ provider.name,
29339
+ invocation,
29340
+ backstop,
29341
+ provider.describeFailure
29342
+ );
29343
+ parsed = parseOutput(commandResult.stdout);
29344
+ } finally {
29345
+ isolation?.cleanup();
28927
29346
  }
29347
+ } else {
29348
+ throw new Error(
29349
+ `Provider ${provider.name} implements neither execute nor buildInvocation.`
29350
+ );
28928
29351
  }
28929
- const runCli = options.runCli ?? defaultRunCli;
28930
- const probes = [
28931
- probeClaude(env),
28932
- probeCodex(env, home),
28933
- probeOpencode(env, runCli),
28934
- probePi(env, home)
28935
- ];
28936
- const cachedAt = (/* @__PURE__ */ new Date()).toISOString();
28937
- try {
28938
- fs.mkdirSync(path.dirname(cachePath), { recursive: true });
28939
- fs.writeFileSync(cachePath, JSON.stringify({ cachedAt, probes }, null, 2), {
28940
- mode: 384
28941
- });
28942
- } catch {
29352
+ const missing = missingSchemaFields(parsed.result);
29353
+ if (missing.length > 0) {
29354
+ throw new Error(
29355
+ `${provider.name} returned a result that does not match the vision schema (missing: ${missing.join(", ")}).`
29356
+ );
28943
29357
  }
28944
- return { probes, cachedAt, fromCache: false };
29358
+ return parsed;
29359
+ }
29360
+ function resolveInput(input) {
29361
+ const trimmed = input.trim();
29362
+ if (!trimmed) {
29363
+ throw new Error("Input path is required.");
29364
+ }
29365
+ if (isRemoteSource(trimmed)) {
29366
+ return { source: trimmed, kind: "remote" };
29367
+ }
29368
+ if (/^file:\/\//i.test(trimmed)) {
29369
+ return { source: path.resolve(fileURLToPath(trimmed)), kind: "local" };
29370
+ }
29371
+ return { source: path.resolve(trimmed), kind: "local" };
29372
+ }
29373
+ function isRemoteSource(value) {
29374
+ return /^https?:\/\//i.test(value.trim());
29375
+ }
29376
+ function validateInputFile(filePath) {
29377
+ if (!fs.existsSync(filePath)) {
29378
+ throw new Error(`Input image not found: ${filePath}`);
29379
+ }
29380
+ const stat = fs.statSync(filePath);
29381
+ if (!stat.isFile()) {
29382
+ throw new Error(`Input is not a file: ${filePath}`);
29383
+ }
29384
+ }
29385
+ function isolateImage(source) {
29386
+ const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "modlens-work-"));
29387
+ const imageSource = path.join(workdir, path.basename(source));
29388
+ fs.copyFileSync(source, imageSource);
29389
+ fs.chmodSync(imageSource, 384);
29390
+ return {
29391
+ imageSource,
29392
+ workdir,
29393
+ cleanup: () => fs.rmSync(workdir, { recursive: true, force: true })
29394
+ };
29395
+ }
29396
+ function emptyWorkdir() {
29397
+ const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "modlens-work-"));
29398
+ return {
29399
+ workdir,
29400
+ cleanup: () => fs.rmSync(workdir, { recursive: true, force: true })
29401
+ };
29402
+ }
29403
+ function runCommand(providerName, invocation, timeoutMs, describeFailure) {
29404
+ const runStartedAt = Date.now();
29405
+ return new Promise((resolve, reject) => {
29406
+ const child = spawn(invocation.command, invocation.args, {
29407
+ cwd: invocation.cwd,
29408
+ stdio: ["ignore", "pipe", "pipe"]
29409
+ });
29410
+ const outDecoder = new TextDecoder("utf-8");
29411
+ const errDecoder = new TextDecoder("utf-8");
29412
+ let stdout = "";
29413
+ let stderr = "";
29414
+ let timedOut = false;
29415
+ let settled = false;
29416
+ let drainTimer;
29417
+ const timer = setTimeout(() => {
29418
+ timedOut = true;
29419
+ child.kill("SIGTERM");
29420
+ settle(null);
29421
+ setTimeout(() => {
29422
+ if (!exited) {
29423
+ child.kill("SIGKILL");
29424
+ }
29425
+ }, SIGKILL_GRACE_MS).unref();
29426
+ }, timeoutMs);
29427
+ const settle = (code) => {
29428
+ if (settled) {
29429
+ return;
29430
+ }
29431
+ settled = true;
29432
+ clearTimeout(timer);
29433
+ clearTimeout(drainTimer);
29434
+ stdout += outDecoder.decode();
29435
+ stderr += errDecoder.decode();
29436
+ child.stdout?.destroy();
29437
+ child.stderr?.destroy();
29438
+ child.unref();
29439
+ if (timedOut) {
29440
+ reject(new Error(`${providerName} provider timed out after ${timeoutMs} ms.`));
29441
+ return;
29442
+ }
29443
+ if (code !== 0) {
29444
+ const explained = describeFailure?.({ stdout, stderr, code, startedAt: runStartedAt }) ?? null;
29445
+ reject(
29446
+ new Error(
29447
+ explained ?? `${providerName} provider failed with code ${code}.${stderr ? ` stderr: ${stderr.trim()}` : ""}`
29448
+ )
29449
+ );
29450
+ return;
29451
+ }
29452
+ resolve({ stdout, stderr });
29453
+ };
29454
+ let exitCode = null;
29455
+ let exited = false;
29456
+ const restartDrain = () => {
29457
+ if (!exited || settled) {
29458
+ return;
29459
+ }
29460
+ clearTimeout(drainTimer);
29461
+ drainTimer = setTimeout(() => settle(exitCode), DRAIN_GRACE_MS);
29462
+ };
29463
+ child.stdout.on("data", (chunk) => {
29464
+ stdout += outDecoder.decode(chunk, { stream: true });
29465
+ restartDrain();
29466
+ });
29467
+ child.stderr.on("data", (chunk) => {
29468
+ stderr += errDecoder.decode(chunk, { stream: true });
29469
+ restartDrain();
29470
+ });
29471
+ child.on("error", (error) => {
29472
+ if (settled) {
29473
+ return;
29474
+ }
29475
+ settled = true;
29476
+ clearTimeout(timer);
29477
+ clearTimeout(drainTimer);
29478
+ if (error.code === "ENOENT") {
29479
+ const missingCwd = !fs.existsSync(invocation.cwd);
29480
+ reject(
29481
+ new Error(
29482
+ missingCwd ? `Working directory does not exist: ${invocation.cwd}` : `Provider CLI not found: ${invocation.command}. Install it and sign in first.`
29483
+ )
29484
+ );
29485
+ return;
29486
+ }
29487
+ reject(error);
29488
+ });
29489
+ child.on("exit", (code) => {
29490
+ exitCode = code;
29491
+ exited = true;
29492
+ restartDrain();
29493
+ });
29494
+ child.on("close", (code) => settle(code));
29495
+ });
28945
29496
  }
28946
29497
  const HARNESS_BY_BASENAME = {
28947
29498
  claude: "claude-code",
@@ -29587,6 +30138,9 @@ function versionParts(version) {
29587
30138
  }
29588
30139
  return [Number(match[1]), Number(match[2])];
29589
30140
  }
30141
+ function chainEntryName(provider) {
30142
+ return provider.reuseNote ? `${provider.name} (reused)` : provider.name;
30143
+ }
29590
30144
  function meetsMinimum(version, minimum) {
29591
30145
  const [major, minor] = versionParts(version);
29592
30146
  const [minMajor, minMinor] = versionParts(minimum);
@@ -29699,6 +30253,8 @@ function buildDoctorReport(input) {
29699
30253
  harness: harnessDetection.harness
29700
30254
  });
29701
30255
  const guardVerdict = evaluateGuard(input.config.guards, guardDetection);
30256
+ const reuseDiscovery = discoverAuto({ env, fresh: true, ...input.auto });
30257
+ const reuseOptions = { env, ...input.auto, discovery: reuseDiscovery };
29702
30258
  return {
29703
30259
  node: {
29704
30260
  version: process.version,
@@ -29708,9 +30264,12 @@ function buildDoctorReport(input) {
29708
30264
  nodeSqlite: checkNodeSqlite(),
29709
30265
  providers: PROVIDER_DESCRIPTORS.map((d) => inspectProvider(d, input.config, env)),
29710
30266
  selection: resolveSelection(input.config, input.providerFlag),
30267
+ // The chains a run would actually use, reused routes included and
30268
+ // labeled, so a machine living entirely on granted logins does not
30269
+ // read as "no engine" right next to a granted Reuse section.
29711
30270
  chains: {
29712
- local: providerChain("local", input.config, env).map((p) => p.name),
29713
- remote: providerChain("remote", input.config, env).map((p) => p.name)
30271
+ local: composeChain("local", input.config, reuseOptions).map(chainEntryName),
30272
+ remote: composeChain("remote", input.config, reuseOptions).map(chainEntryName)
29714
30273
  },
29715
30274
  harness: { detected: harnessDetection.harness, source: harnessDetection.source },
29716
30275
  guard: {
@@ -29724,11 +30283,18 @@ function buildDoctorReport(input) {
29724
30283
  reason: guardVerdict.reason
29725
30284
  },
29726
30285
  config: inspectConfigFile(configPath),
29727
- // doctor is the "what would auto find" view, so it always probes fresh
29728
- // (and rewrites the cache); regular runs will read the cache instead.
29729
- auto: {
29730
- enabled: input.config.auto === true,
29731
- probes: discoverAuto({ env, fresh: true, ...input.auto }).probes
30286
+ reuse: {
30287
+ decisions: Object.fromEntries(
30288
+ REUSE_HARNESSES.map((harness) => {
30289
+ const decision = input.config.reuse?.[harness];
30290
+ const fallback = harness === "claude" ? "granted" : "not asked";
30291
+ return [
30292
+ harness,
30293
+ decision === true ? "granted" : decision === false ? "refused" : fallback
30294
+ ];
30295
+ })
30296
+ ),
30297
+ probes: reuseDiscovery.probes
29732
30298
  }
29733
30299
  };
29734
30300
  }
@@ -29778,11 +30344,11 @@ function renderDoctorReport(report) {
29778
30344
  ` verdict: ${report.guard.verdict}${report.guard.matched ? ` (matched "${report.guard.matched}")` : ""}, ${report.guard.reason}`
29779
30345
  );
29780
30346
  lines.push("");
29781
- lines.push("Auto (borrowable local harness vision; off by default)");
30347
+ lines.push('Reuse (may modlens reuse other local logins? config "reuse.<harness>")');
29782
30348
  lines.push(
29783
- ` enabled: ${report.auto.enabled}${report.auto.enabled ? "" : " (turn on: modlens config set auto true)"}`
30349
+ ` decisions: ${Object.entries(report.reuse.decisions).map(([harness, decision]) => `${harness} ${decision}`).join(", ")}`
29784
30350
  );
29785
- for (const probe of report.auto.probes) {
30351
+ for (const probe of report.reuse.probes) {
29786
30352
  if (!probe.cliFound) {
29787
30353
  lines.push(` ${probe.harness}: cli not found`);
29788
30354
  continue;
@@ -29994,7 +30560,7 @@ function recoverPastedImages(options = {}) {
29994
30560
  return result;
29995
30561
  }
29996
30562
  const program = new Command();
29997
- program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.7.0");
30563
+ program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.9.0");
29998
30564
  program.command("analyze", { isDefault: true }).description("Analyze an image into structured JSON evidence (default command)").requiredOption("-i, --input <path|url>", "Input image path or https URL").option("-o, --output <path>", "Write result JSON to a file").option("-m, --model <name>", "Provider model name").option("-p, --provider <name>", `Vision provider (${listProviders().join(", ")})`).option("--prompt <text>", "Extra focus for this image").option("--timeout <ms>", "Provider timeout in milliseconds", "180000").option("--provider-bin <path>", "Provider binary path (default: agy)").option("--workdir <path>", "Working directory for the provider").option(
29999
30565
  "--extra-body <json>",
30000
30566
  `JSON merged into the API request body, e.g. '{"thinking":{"type":"disabled"}}'`