@liustack/modlens 3.16.7 → 3.17.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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.17.0 - 2026-08-16
4
+
5
+ - **Breaking: a provider takes its settings from one place, whole ([#42](https://github.com/liustack/modlens/issues/42)).** `GEMINI_API_KEY`, `OPENAI_API_KEY`, `OPENAI_BASE_URL`, `ANTHROPIC_API_KEY` and `ANTHROPIC_BASE_URL` used to override `~/.modlens/config.json` field by field. The reported failure was a configured Kimi endpoint answering 401, and the reason is worth stating precisely: a baseUrl and an apiKey are one credential, so taking the endpoint from the file and the key from an ambient variable built a pairing that existed in neither place, and nothing in the error named the environment as the source. Merging the other way round keeps that shape, which is why this is not a change of precedence: name a provider in the config file and the file is its source, name nothing and the environment is. A container or CI job that only exports variables keeps working exactly as before, both halves still matching. What changes is the mixture. If the file configures a provider and you were completing it from the environment, put the rest in the file: a key variable becomes `modlens config set <provider>.apiKey` at the hidden prompt, an endpoint variable becomes `modlens config set <provider>.baseUrl "$THE_VARIABLE"` (`$env:THE_VARIABLE` in PowerShell), and until you do, a run that names such a provider refuses with that exact command rather than quietly using the vendor's own endpoint. Naming a provider is about the key existing, not what it holds: an entry emptied down to `{}` still takes that provider off its variables, since the alternative is a file you edited quietly handing the provider back to the environment. `modlens config show` and `modlens doctor` label each provider with the source actually in effect. `MODLENS_MODEL`, `MODLENS_HARNESS` and the `HTTPS_PROXY` family are untouched: none is a credential, and none can be split from a matching half. This also defuses the documented `ANTHROPIC_BASE_URL` trap, where a shell that routed Claude Code through a text-only gateway silently sent vision requests there too. Thanks to @StarChen-Cycler for a report that arrived with the mechanism, a deterministic repro in both directions, and a patch.
6
+ - **The openai route still requires `baseUrl`, deliberately.** Defaulting it to official OpenAI was the obvious convenience and the wrong call: anyone whose endpoint came from `OPENAI_BASE_URL` would have had their third-party key, and the image beside it, sent to OpenAI instead. That is the same fault this release removes, pointed the other way. The error now names the setting, so the fix is one `modlens config set openai.baseUrl <url>` away. The same trap existed for `anthropic`, which does have a default endpoint: a run that names `anthropic` while the file configures it without a `baseUrl` and `ANTHROPIC_BASE_URL` is still set now refuses and prints the exact command to keep the endpoint it was using, rather than delivering that gateway's key to Anthropic. All three conditions have to hold, so a setup configured entirely through those variables is not affected.
7
+ - **A `kimi-cli` provider rides a Kimi Code login ([#44](https://github.com/liustack/modlens/issues/44)).** Kimi Code subscribers read images without a key, the way `claude-cli` rides a Claude login: `modlens config set provider kimi-cli`, with `kimi-cli.model` taking one of kimi's own `<provider>/<model>` aliases. It runs only when you name it, with `-p kimi-cli` or `config set provider kimi-cli`: it spends a Kimi Code subscription, and having the CLI installed is not agreement to spend it. (`claude-cli` joins the chain automatically only because it predates that model.) It never serves a remote URL either, since the CLI reads local files. Requests that go over an API keep using the `openai` route: there is no bespoke Kimi API provider. Worth knowing if you ever debug it: modlens runs `kimi` with skill discovery pointed at a fresh empty directory, because otherwise kimi can find the modlens skill in the shared skill directories and read the image by running modlens, which is modlens calling itself. Thanks to @StarChen-Cycler for the request and the groundwork.
8
+
3
9
  ## 3.16.7 - 2026-08-15
4
10
 
5
11
  - **dsh: the vision engine has a settings card in the web UI ([#39](https://github.com/liustack/modlens/issues/39)).** Engine, API key, endpoint, model, and which local sign-ins a read may borrow, in Settings under Plugins, for the dsh users who never open a terminal. Expanding it probes the machine and lists the harnesses actually found, so auto mode is a choice between real options. The shared `~/.modlens/config.json` stays the one place those values live: the card reads and writes a loopback route that owns the file, so nothing is duplicated into a second store and every other harness sees the same edit. The host never sends a stored key to the browser, only whether one is stored, and a blank key field means leave the stored one alone. A cross-origin or non-loopback write is refused the way dsh fences its own API, since a page in the same browser could otherwise repoint someone's engine at an endpoint of its choosing. Not pinning an engine stays its own state rather than being collapsed into one, and a save carries only what it is about: toggling a grant never moves the pin or rewrites engine settings.
package/README.md CHANGED
@@ -34,7 +34,7 @@ Issues are welcome any time: [open one](https://github.com/liustack/modlens/issu
34
34
 
35
35
  ## Highlights
36
36
 
37
- **🥇 The first vision plugin for DeepSeek Harness (dsh):** one command, `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.16.7`, and the text-only DeepSeek model behind dsh reads images through a native `modlens_read_image` tool. Updating is the same command again. The version is named rather than `@latest` on purpose: pnpm 11 holds back releases published in the last 24 hours and resolves the tag against what survives, so `@latest` would install whatever shipped a day ago ([details](docs/harness-setup.md#keeping-it-up-to-date)).
37
+ **🥇 The first vision plugin for DeepSeek Harness (dsh):** one command, `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.17.0`, and the text-only DeepSeek model behind dsh reads images through a native `modlens_read_image` tool. Updating is the same command again. The version is named rather than `@latest` on purpose: pnpm 11 holds back releases published in the last 24 hours and resolves the tag against what survives, so `@latest` would install whatever shipped a day ago ([details](docs/harness-setup.md#keeping-it-up-to-date)).
38
38
 
39
39
  Pasting an image works two ways. **① Just paste.** On a text-only model the pasted image lands as a private temp file and its path enters the composer — the same interaction OpenCode and Pi ship — and the `modlens_read_image` tool takes it from there. **② Pick a `(modlens vision)` entry** in the model selector (it remembers your choice, so once is enough), then paste: the thumbnail stays visible in your message, closer to the Codex app feel, and the image is converted to structured evidence at request time, answered by the same underlying route. The plugin auto-discovers every provider route carrying text-only DeepSeek or GLM models and adds a wrapped entry per route (a stock install gets **`DeepSeek-V4-Flash (modlens vision)`** and **`DeepSeek-V4-Pro (modlens vision)`**; extra routes like opencode-go or zai get their own); the two families' own vision models are excluded automatically. Which paste route applies is the host's per-model call: only a model its metadata positively confirms text-only is taken over, anything unconfirmed is left alone, so vision models keep their native paste ([details](docs/harness-setup.md)).
40
40
 
@@ -66,9 +66,9 @@ The install also inventories vision reachable through your other local harness C
66
66
 
67
67
  Once installed, just chat. Paste an image or drop a path, ask anything, and the skill triggers on its own: the image goes to a vision engine and the answer comes back grounded in what it read.
68
68
 
69
- ## Vision engines: five built-in providers, four reusable CLIs, one failover chain
69
+ ## Vision engines: six built-in providers, four reusable CLIs, one failover chain
70
70
 
71
- ModLens does not depend on any single vision service. Nine sources of vision in total: five built-in providers, any one of which is enough, plus four local agent CLIs whose logins can be reused. The built-ins:
71
+ ModLens does not depend on any single vision service. Ten sources of vision in total: six built-in providers, any one of which is enough, plus four local agent CLIs whose logins can be reused. The built-ins:
72
72
 
73
73
  | Provider | What it needs | Speed per read | Good for |
74
74
  | :-- | :-- | :-- | :-- |
@@ -77,6 +77,7 @@ ModLens does not depend on any single vision service. Nine sources of vision in
77
77
  | `anthropic` | an Anthropic API key | 5-10s | machines already holding one |
78
78
  | `antigravity-cli` | the free `agy` CLI, one browser sign-in, no key | 15-45s | zero-signup starts |
79
79
  | `claude-cli` | a signed-in Claude Code | 20-45s | riding your existing Claude subscription |
80
+ | `kimi-cli` | a signed-in Kimi Code | 20-45s | riding your existing Kimi subscription, named explicitly |
80
81
 
81
82
  Without a pinned provider, every configured engine forms one failover chain: the fast API providers try first, the agent CLIs back them up, the first good result wins, and `meta.attempts` records every attempt so a fallback is never silent.
82
83
 
package/README.zh-CN.md CHANGED
@@ -34,7 +34,7 @@ DeepSeek 和 GLM 的主力对话模型是纯文本的,无法进行图片识别
34
34
 
35
35
  ## 亮点
36
36
 
37
- **🥇 全网第一个支持 DeepSeek Harness(dsh)的外挂视觉识别插件:**一条命令 `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.16.7`,dsh 背后的纯文本 DeepSeek 模型即可通过原生 `modlens_read_image` 工具读图。更新就是再跑一遍同一条命令。这里点名版本号而不用 `@latest` 是有意的:pnpm 11 会扣住最近 24 小时内发布的版本,dist-tag 只在剩下的里面解析,用 `@latest` 装到的会是一天前发布的那个([细节](docs/harness-setup.zh-CN.md#保持更新))。
37
+ **🥇 全网第一个支持 DeepSeek Harness(dsh)的外挂视觉识别插件:**一条命令 `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.17.0`,dsh 背后的纯文本 DeepSeek 模型即可通过原生 `modlens_read_image` 工具读图。更新就是再跑一遍同一条命令。这里点名版本号而不用 `@latest` 是有意的:pnpm 11 会扣住最近 24 小时内发布的版本,dist-tag 只在剩下的里面解析,用 `@latest` 装到的会是一天前发布的那个([细节](docs/harness-setup.zh-CN.md#保持更新))。
38
38
 
39
39
  DeepSeek Harness 粘贴识图有两种玩法。
40
40
 
@@ -69,7 +69,7 @@ agy # 浏览器完成
69
69
  **DeepSeek Harness(dsh)用户不走 skill 流程**,本包就是原生 dsh 插件:
70
70
 
71
71
  ```sh
72
- npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.16.7
72
+ npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.17.0
73
73
  ```
74
74
 
75
75
  装完即有 `modlens_read_image` 工具,选「(modlens vision)」模型变体即可直接粘贴识图。引擎配置同样在 `~/.modlens`,详见[宿主接入](docs/harness-setup.zh-CN.md)。
@@ -78,9 +78,9 @@ npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.16.7
78
78
 
79
79
  装好之后不需要记任何命令。正常聊天,粘贴图片或给出图片路径,提问即可,skill 自动触发:图片交给视觉引擎,答案基于读到的内容返回。
80
80
 
81
- ## 视觉引擎:五个内置 provider,四家可复用 CLI,一条故障转移链
81
+ ## 视觉引擎:六个内置 provider,四家可复用 CLI,一条故障转移链
82
82
 
83
- ModLens 不绑定任何单一视觉服务。视觉来源一共九个:五个内置 provider(配好任意一个就能用),加四家本机 agent CLI 的登录可以复用。先看内置的:
83
+ ModLens 不绑定任何单一视觉服务。视觉来源一共十个:六个内置 provider(配好任意一个就能用),加四家本机 agent CLI 的登录可以复用。先看内置的:
84
84
 
85
85
  | Provider | 需要什么 | 单次识别耗时 | 适合谁 |
86
86
  | :---------------- | :----------------------------------------------------------------------- | :----------- | :--------------------- |
@@ -89,6 +89,7 @@ ModLens 不绑定任何单一视觉服务。视觉来源一共九个:五个内
89
89
  | `anthropic` | Anthropic API key | 5-10 秒 | 手上已有 key 的机器 |
90
90
  | `antigravity-cli` | 免费的 `agy` CLI,浏览器登录一次,无需 key | 15-45 秒 | 完全免注册起步 |
91
91
  | `claude-cli` | 已登录的 Claude Code | 20-45 秒 | 复用现有 Claude 订阅 |
92
+ | `kimi-cli` | 已登录的 Kimi Code | 20-45 秒 | 复用现有 Kimi 订阅,需显式点名 |
92
93
 
93
94
  不钉死 provider 时,所有配好的引擎组成一条故障转移链:API 快车道先试,agent CLI 兜底,第一个可用结果胜出,`meta.attempts` 记录每次尝试,回退永远不是无声的。
94
95
 
package/dist/main.js CHANGED
@@ -890,10 +890,11 @@ const ANTHROPIC_DEFAULT_MODEL = "claude-haiku-4-5-20251001";
890
890
  const DEFAULT_BASE_URL$1 = "https://api.anthropic.com";
891
891
  const TOOL_NAME = "report_vision_evidence";
892
892
  async function executeAnthropicApi(options) {
893
+ assertNoRetiredEndpointBinding("anthropic", options.settings ?? {});
893
894
  const apiKey = options.settings?.apiKey;
894
895
  if (!apiKey) {
895
896
  throw new Error(
896
- "anthropic provider needs an API key. Set ANTHROPIC_API_KEY, or run: modlens config set anthropic.apiKey <key>"
897
+ "anthropic provider needs an API key. Run: modlens config set anthropic.apiKey and paste it at the hidden prompt"
897
898
  );
898
899
  }
899
900
  const model = options.model || options.settings?.model || ANTHROPIC_DEFAULT_MODEL;
@@ -1201,7 +1202,7 @@ async function executeGeminiApi(options) {
1201
1202
  const apiKey = options.settings?.apiKey;
1202
1203
  if (!apiKey) {
1203
1204
  throw new Error(
1204
- "gemini-api provider needs an API key. Set GEMINI_API_KEY, or run: modlens config set gemini-api.apiKey <key> (free key: https://aistudio.google.com)"
1205
+ "gemini-api provider needs an API key. Run: modlens config set gemini-api.apiKey and paste it at the hidden prompt (free key: https://aistudio.google.com)"
1205
1206
  );
1206
1207
  }
1207
1208
  const model = options.model || options.settings?.model || GEMINI_API_DEFAULT_MODEL;
@@ -1286,13 +1287,99 @@ const geminiApiProvider = {
1286
1287
  defaultModel: GEMINI_API_DEFAULT_MODEL,
1287
1288
  execute: executeGeminiApi
1288
1289
  };
1290
+ const KIMI_REENTRY_ENV = "MODLENS_INSIDE_KIMI_CLI";
1291
+ function freshEmptySkillsDir() {
1292
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "modlens-kimi-skills-"));
1293
+ process.once("exit", () => {
1294
+ try {
1295
+ fs.rmdirSync(dir);
1296
+ } catch {
1297
+ }
1298
+ });
1299
+ return dir;
1300
+ }
1301
+ function buildKimiCliInvocation(options) {
1302
+ if (process.env[KIMI_REENTRY_ENV] === "1") {
1303
+ throw new Error(
1304
+ "kimi-cli refused: this modlens run was started by kimi itself, so calling kimi again would loop. Pick another provider for the nested read, or let the outer read answer."
1305
+ );
1306
+ }
1307
+ if (options.imageKind === "remote") {
1308
+ throw new Error(
1309
+ "kimi-cli provider reads local files only. Download the image first, or use -p gemini-api for remote URLs."
1310
+ );
1311
+ }
1312
+ const prompt = `${buildVisionPrompt({
1313
+ imageSource: options.imageSource,
1314
+ imageKind: "local",
1315
+ extraPrompt: options.extraPrompt
1316
+ })}
1317
+
1318
+ ${JSON_TEMPLATE_INSTRUCTION}`;
1319
+ const model = options.model || options.settings?.model;
1320
+ const args = [
1321
+ "-p",
1322
+ prompt,
1323
+ "--output-format",
1324
+ "stream-json",
1325
+ // Not a preference: without it kimi may load the modlens skill and
1326
+ // read the image by running modlens, which is this process calling
1327
+ // itself. Observed, and intermittent, which is the bad kind.
1328
+ "--skills-dir",
1329
+ freshEmptySkillsDir(),
1330
+ ...model ? ["-m", model] : []
1331
+ ];
1332
+ return {
1333
+ command: options.providerBin || "kimi",
1334
+ args,
1335
+ cwd: path.resolve(options.workdir || path.dirname(options.imageSource)),
1336
+ env: { [KIMI_REENTRY_ENV]: "1" }
1337
+ };
1338
+ }
1339
+ function parseKimiCliOutput(stdout) {
1340
+ let answer = null;
1341
+ for (const line of stdout.split("\n")) {
1342
+ const trimmed = line.trim();
1343
+ if (trimmed === "") continue;
1344
+ let entry;
1345
+ try {
1346
+ entry = JSON.parse(trimmed);
1347
+ } catch {
1348
+ continue;
1349
+ }
1350
+ if (entry.role === "assistant" && typeof entry.content === "string" && entry.content) {
1351
+ answer = entry.content;
1352
+ }
1353
+ }
1354
+ if (answer === null) {
1355
+ throw new Error(
1356
+ `Kimi CLI produced no answer. Check that it is signed in (run \`kimi\` and /login) and that its model accepts image input. Got: ${truncate(stdout)}`
1357
+ );
1358
+ }
1359
+ const result = extractJson(answer);
1360
+ if (result === null) {
1361
+ throw new Error(`Kimi CLI returned non-JSON output: ${truncate(answer)}`);
1362
+ }
1363
+ return { result, meta: { conversationId: null, durationSeconds: null, usage: null } };
1364
+ }
1365
+ const KIMI_CLI_DEFAULT_MODEL = "";
1366
+ const kimiCliProvider = {
1367
+ name: "kimi-cli",
1368
+ defaultModel: KIMI_CLI_DEFAULT_MODEL,
1369
+ buildInvocation: buildKimiCliInvocation,
1370
+ parseOutput: parseKimiCliOutput,
1371
+ // Same reason claude-cli isolates: the agent runs with a real toolset, so
1372
+ // it gets a throwaway directory holding only the image.
1373
+ isolateWorkdir: true
1374
+ };
1289
1375
  async function executeOpenaiCompat(options) {
1376
+ assertNoRetiredEndpointBinding("openai", options.settings ?? {});
1290
1377
  const apiKey = options.settings?.apiKey;
1291
1378
  const baseUrl = options.settings?.baseUrl?.replace(/\/$/, "");
1292
1379
  const model = options.model || options.settings?.model;
1293
1380
  if (!apiKey || !baseUrl || !model) {
1294
1381
  throw new Error(
1295
- "openai provider needs baseUrl, apiKey, and model. Set OPENAI_BASE_URL and OPENAI_API_KEY, or run: modlens config set openai.baseUrl <url> / openai.apiKey <key> / openai.model <name>"
1382
+ "openai provider needs baseUrl, apiKey, and model: modlens config set openai.baseUrl <url>, modlens config set openai.apiKey (a hidden prompt), modlens config set openai.model <name>. There is no default endpoint (official OpenAI is https://api.openai.com/v1). OPENAI_BASE_URL and OPENAI_API_KEY still supply this provider on their own, but only while the config file names no openai entry, since 3.17.0 takes a provider whole from one source; no variable carries the model, so an environment-only setup passes -m <name>."
1296
1383
  );
1297
1384
  }
1298
1385
  const imageUrl = options.imageKind === "remote" ? options.imageSource : toDataUrl(readLocalImageBase64(options.imageSource));
@@ -1392,7 +1479,10 @@ const PROVIDERS = {
1392
1479
  anthropic: anthropicApiProvider,
1393
1480
  claude: anthropicApiProvider,
1394
1481
  "claude-cli": claudeCliProvider,
1395
- "claude-code": claudeCliProvider
1482
+ "claude-code": claudeCliProvider,
1483
+ "kimi-cli": kimiCliProvider,
1484
+ kimi: kimiCliProvider,
1485
+ "kimi-code": kimiCliProvider
1396
1486
  };
1397
1487
  function resolveProvider(providerName = "antigravity-cli") {
1398
1488
  const normalized = providerName.trim().toLowerCase();
@@ -1421,6 +1511,54 @@ const ENV_BINDINGS = {
1421
1511
  openai: { apiKey: "OPENAI_API_KEY", baseUrl: "OPENAI_BASE_URL" },
1422
1512
  anthropic: { apiKey: "ANTHROPIC_API_KEY", baseUrl: "ANTHROPIC_BASE_URL" }
1423
1513
  };
1514
+ function fileKeysFor(providerName, config2) {
1515
+ const aliases = providerAliases();
1516
+ return Object.keys(config2.providers ?? {}).filter(
1517
+ (key) => (aliases[key] ?? key) === providerName
1518
+ );
1519
+ }
1520
+ function fileSettingsFor(providerName, config2) {
1521
+ const keys = fileKeysFor(providerName, config2);
1522
+ const ordered = [
1523
+ ...keys.filter((key) => key !== providerName),
1524
+ ...keys.filter((key) => key === providerName)
1525
+ ];
1526
+ return Object.assign({}, ...ordered.map((key) => config2.providers?.[key] ?? {}));
1527
+ }
1528
+ function envSettingsFor(providerName, env) {
1529
+ const settings = {};
1530
+ for (const [field, variable] of Object.entries(ENV_BINDINGS[providerName] ?? {})) {
1531
+ const value = env[variable]?.trim();
1532
+ if (value) {
1533
+ settings[field] = value;
1534
+ }
1535
+ }
1536
+ return settings;
1537
+ }
1538
+ const ENDPOINT_BINDINGS = {
1539
+ // No default endpoint, so the run cannot misroute: it simply has nowhere
1540
+ // to go, and saying otherwise would invent a danger to justify the error.
1541
+ openai: {
1542
+ variable: "OPENAI_BASE_URL",
1543
+ consequence: "this run has no endpoint left to send the image to"
1544
+ },
1545
+ anthropic: {
1546
+ variable: "ANTHROPIC_BASE_URL",
1547
+ consequence: "this run would have sent your key and the image to Anthropic's own endpoint"
1548
+ }
1549
+ };
1550
+ function assertNoRetiredEndpointBinding(providerName, settings, env = process.env) {
1551
+ const binding = ENDPOINT_BINDINGS[providerName];
1552
+ const variable = binding?.variable;
1553
+ if (!variable || settings.baseUrl?.trim() || !env[variable]?.trim()) {
1554
+ return;
1555
+ }
1556
+ const shown = maskUrlCredentials(env[variable]?.trim() ?? "");
1557
+ const reference = process.platform === "win32" ? `$env:${variable}` : `"$${variable}"`;
1558
+ throw new Error(
1559
+ `${variable} is set (${shown}), but the config file configures ${providerName}, and since 3.17.0 a provider takes its settings from one place: the file, whole. ${providerName}.baseUrl is not in it, so ${binding.consequence}. To keep the endpoint you were using, run: modlens config set ${providerName}.baseUrl ${reference}`
1560
+ );
1561
+ }
1424
1562
  function loadConfigFile(configPath = CONFIG_PATH) {
1425
1563
  let raw;
1426
1564
  try {
@@ -1445,23 +1583,15 @@ function loadConfigFile(configPath = CONFIG_PATH) {
1445
1583
  );
1446
1584
  }
1447
1585
  }
1586
+ function providerConfiguredInFile(providerName, config2) {
1587
+ return fileKeysFor(providerName, config2).length > 0;
1588
+ }
1448
1589
  function resolveProviderSettings(providerName, config2, env = process.env) {
1449
- const aliasNames = Object.entries(providerAliases()).filter(([alias, canonical]) => canonical === providerName && alias !== providerName).map(([alias]) => alias);
1450
- const fromFile = {
1451
- ...Object.assign({}, ...aliasNames.map((alias) => config2.providers?.[alias] ?? {})),
1452
- ...config2.providers?.[providerName] ?? {}
1453
- };
1454
- const bindings = ENV_BINDINGS[providerName] ?? {};
1455
- const settings = { ...fromFile };
1590
+ const mentioned = providerConfiguredInFile(providerName, config2);
1591
+ const settings = mentioned ? { ...fileSettingsFor(providerName, config2) } : envSettingsFor(providerName, env);
1456
1592
  if (!settings.proxy && config2.proxy?.trim()) {
1457
1593
  settings.proxy = config2.proxy.trim();
1458
1594
  }
1459
- for (const [field, envName] of Object.entries(bindings)) {
1460
- const value = env[envName]?.trim();
1461
- if (value) {
1462
- settings[field] = value;
1463
- }
1464
- }
1465
1595
  return settings;
1466
1596
  }
1467
1597
  function setConfigValue(dottedKey, value, configPath = CONFIG_PATH) {
@@ -1605,23 +1735,25 @@ function initConfigFile(configPath = CONFIG_PATH, force = false) {
1605
1735
  }
1606
1736
  }
1607
1737
  function renderEffectiveConfig(config2, env = process.env) {
1608
- const providerNames = new Set(Object.keys(config2.providers ?? {}));
1738
+ const aliases = providerAliases();
1739
+ const providerNames = new Set(
1740
+ Object.keys(config2.providers ?? {}).map((key) => aliases[key] ?? key)
1741
+ );
1609
1742
  for (const [providerName, bindings] of Object.entries(ENV_BINDINGS)) {
1610
- if (Object.values(bindings).some((envName) => env[envName]?.trim())) {
1743
+ if (Object.values(bindings).some((variable) => env[variable]?.trim())) {
1611
1744
  providerNames.add(providerName);
1612
1745
  }
1613
1746
  }
1614
1747
  const providers = {};
1615
1748
  for (const name of [...providerNames].sort()) {
1616
- const fileSettings = config2.providers?.[name] ?? {};
1617
- const bindings = ENV_BINDINGS[name] ?? {};
1749
+ const fileSettings = fileSettingsFor(name, config2);
1750
+ const mentioned = providerConfiguredInFile(name, config2);
1751
+ const effective2 = mentioned ? fileSettings : envSettingsFor(name, env);
1752
+ const source = mentioned ? "file" : "env";
1618
1753
  const fields = {};
1619
1754
  for (const field of STRING_FIELDS) {
1620
- const envName = bindings[field];
1621
- const envValue = envName ? env[envName]?.trim() : void 0;
1622
- const value = envValue ?? fileSettings[field];
1623
- const source = envValue ? "env" : fileSettings[field] !== void 0 ? "file" : null;
1624
- if (value !== void 0 && source) {
1755
+ const value = effective2[field];
1756
+ if (value !== void 0) {
1625
1757
  const shown = field === "apiKey" ? maskKey(value) : field === "proxy" ? maskUrlCredentials(value) : value;
1626
1758
  fields[field] = `${shown} (${source})`;
1627
1759
  }
@@ -1632,7 +1764,7 @@ function renderEffectiveConfig(config2, env = process.env) {
1632
1764
  if (fileSettings.extraBody !== void 0) {
1633
1765
  fields.extraBody = `${JSON.stringify(fileSettings.extraBody)} (file)`;
1634
1766
  }
1635
- if (Object.keys(fields).length > 0) {
1767
+ if (Object.keys(fields).length > 0 || mentioned) {
1636
1768
  providers[name] = fields;
1637
1769
  }
1638
1770
  }
@@ -1689,30 +1821,32 @@ const PROVIDER_DESCRIPTORS = [
1689
1821
  {
1690
1822
  name: "gemini-api",
1691
1823
  kind: "api",
1692
- required: [{ field: "apiKey", env: "GEMINI_API_KEY" }],
1693
- fix: "modlens config set gemini-api.apiKey <key> # free key: https://aistudio.google.com"
1824
+ required: [{ field: "apiKey" }],
1825
+ fix: "modlens config set gemini-api.apiKey # hidden prompt; free key: https://aistudio.google.com"
1694
1826
  },
1695
1827
  {
1696
1828
  name: "openai",
1697
1829
  kind: "api",
1698
- required: [
1699
- { field: "baseUrl", env: "OPENAI_BASE_URL" },
1700
- { field: "apiKey", env: "OPENAI_API_KEY" },
1701
- { field: "model" }
1702
- ],
1703
- fix: "modlens config set openai.baseUrl <url> / openai.apiKey <key> / openai.model <name>"
1830
+ required: [{ field: "baseUrl" }, { field: "apiKey" }, { field: "model" }],
1831
+ fix: "modlens config set openai.baseUrl <url> / openai.apiKey (hidden prompt) / openai.model <name>"
1704
1832
  },
1705
1833
  {
1706
1834
  name: "anthropic",
1707
1835
  kind: "api",
1708
- required: [{ field: "apiKey", env: "ANTHROPIC_API_KEY" }],
1709
- fix: "modlens config set anthropic.apiKey <key>"
1836
+ required: [{ field: "apiKey" }],
1837
+ fix: "modlens config set anthropic.apiKey # hidden prompt"
1710
1838
  },
1711
1839
  {
1712
1840
  name: "claude-cli",
1713
1841
  kind: "subprocess",
1714
1842
  bin: "claude",
1715
1843
  install: "install the Claude Code CLI, then run `claude` once to sign in"
1844
+ },
1845
+ {
1846
+ name: "kimi-cli",
1847
+ kind: "subprocess",
1848
+ bin: "kimi",
1849
+ install: "https://moonshotai.github.io/kimi-code/ (then run `kimi` and /login)"
1716
1850
  }
1717
1851
  ];
1718
1852
  function findOnPath(bin, env) {
@@ -1749,6 +1883,7 @@ const LOCAL_FAILOVER_ORDER = [
1749
1883
  "antigravity-cli",
1750
1884
  "claude-cli"
1751
1885
  ];
1886
+ const PIN_ONLY_PROVIDERS = ["kimi-cli"];
1752
1887
  const REMOTE_FAILOVER_ORDER = ["gemini-api", "openai", "anthropic", "antigravity-cli"];
1753
1888
  function providerChain(kind, config2, env = process.env) {
1754
1889
  let names = [...kind === "remote" ? REMOTE_FAILOVER_ORDER : LOCAL_FAILOVER_ORDER];
@@ -1770,6 +1905,10 @@ function providerChain(kind, config2, env = process.env) {
1770
1905
  names.splice(index, 1);
1771
1906
  names.unshift(canonical);
1772
1907
  }
1908
+ } else if (index === -1 && canonical && PIN_ONLY_PROVIDERS.includes(canonical)) {
1909
+ if (kind === "local") {
1910
+ names.unshift(canonical);
1911
+ }
1773
1912
  }
1774
1913
  }
1775
1914
  return names.filter((name) => providerAvailable(name, config2, env)).map((name) => resolveProvider(name));
@@ -2668,6 +2807,17 @@ async function analyzeImage(options) {
2668
2807
  }
2669
2808
  const config2 = options.config ?? loadConfigFile();
2670
2809
  const chain = options.provider ? [resolveProvider(options.provider)] : options.providerBin ? [resolveProvider("antigravity-cli")] : composeChain(resolvedInput.kind, config2, options.autoOptions);
2810
+ const named = options.provider ?? config2.provider?.trim();
2811
+ if (named) {
2812
+ try {
2813
+ const canonical = resolveProvider(named).name;
2814
+ assertNoRetiredEndpointBinding(canonical, resolveProviderSettings(canonical, config2));
2815
+ } catch (error) {
2816
+ if (error instanceof Error && error.message.includes("takes its settings from one place")) {
2817
+ throw error;
2818
+ }
2819
+ }
2820
+ }
2671
2821
  if (chain.length === 0) {
2672
2822
  throw new Error(
2673
2823
  "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)
@@ -2715,7 +2865,10 @@ async function analyzeImage(options) {
2715
2865
  result: parsed.result,
2716
2866
  meta: {
2717
2867
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
2718
- model,
2868
+ // Empty means the provider ran whatever it was already
2869
+ // configured with and never told us which (kimi-cli), so
2870
+ // the field says unknown rather than naming nothing.
2871
+ model: model === "" ? null : model,
2719
2872
  conversationId: parsed.meta.conversationId,
2720
2873
  durationSeconds: parsed.meta.durationSeconds,
2721
2874
  usage: parsed.meta.usage,
@@ -2918,7 +3071,10 @@ function runCommand(providerName, invocation, timeoutMs, describeFailure) {
2918
3071
  const plan = resolveSpawnPlan(invocation.command, invocation.args);
2919
3072
  const child = spawn(plan.command, plan.args, {
2920
3073
  cwd: invocation.cwd,
2921
- stdio: ["ignore", "pipe", "pipe"]
3074
+ stdio: ["ignore", "pipe", "pipe"],
3075
+ // A provider may mark its own child, which is how kimi-cli tells a
3076
+ // modlens started by kimi that running kimi again would loop.
3077
+ ...invocation.env ? { env: { ...process.env, ...invocation.env } } : {}
2922
3078
  });
2923
3079
  const outDecoder = new TextDecoder("utf-8");
2924
3080
  const errDecoder = new TextDecoder("utf-8");
@@ -3760,11 +3916,14 @@ function inspectProvider(descriptor, config2, env) {
3760
3916
  };
3761
3917
  }
3762
3918
  const settings = resolveProviderSettings(descriptor.name, config2, env);
3919
+ const settingsSource = providerConfiguredInFile(descriptor.name, config2) ? "file" : "env";
3763
3920
  const statuses = (descriptor.required ?? []).map((req) => {
3764
- const envValue = req.env ? env[req.env]?.trim() : void 0;
3765
3921
  const value = settings[req.field]?.trim();
3766
- const source = envValue ? "env" : value ? "file" : "missing";
3767
- return { field: req.field, present: Boolean(value), source, env: req.env };
3922
+ return {
3923
+ field: req.field,
3924
+ present: Boolean(value),
3925
+ source: value ? settingsSource : "missing"
3926
+ };
3768
3927
  });
3769
3928
  const missing = statuses.filter((s) => !s.present).map((s) => s.field);
3770
3929
  const ready = missing.length === 0;
@@ -4207,7 +4366,7 @@ function parsePositiveInt(raw, flag) {
4207
4366
  }
4208
4367
  return Number.parseInt(raw, 10);
4209
4368
  }
4210
- program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.16.7");
4369
+ program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.17.0");
4211
4370
  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(
4212
4371
  "--extra-body <json>",
4213
4372
  `JSON merged into the API request body, e.g. '{"thinking":{"type":"disabled"}}'`
@@ -4317,7 +4476,7 @@ program.command("doctor").description(
4317
4476
  configPath: CONFIG_PATH,
4318
4477
  // Lets doctor name an installed skill copy that is older than
4319
4478
  // the CLI reporting on it (issue #33).
4320
- version: "3.16.7"
4479
+ version: "3.17.0"
4321
4480
  });
4322
4481
  const output = options.json ? JSON.stringify(report, null, 2) : renderDoctorReport(report);
4323
4482
  process.stdout.write(`${output}
package/docs/cli.md CHANGED
@@ -48,7 +48,7 @@ Output is a fixed JSON shape:
48
48
  }
49
49
  ```
50
50
 
51
- `meta` records how the result was produced: when (`generatedAt`), which `model`, the provider's `conversationId` when it has one, wall-clock `durationSeconds`, and the raw `usage` the provider reported (shape varies by provider, `null` when none). `attempts` lists every provider the failover chain tried, in order, with failure reasons; `warnings` carries routing notices (failovers, ignored extraBody, whose quota an auto-mode read spent).
51
+ `meta` records how the result was produced: when (`generatedAt`), which `model` (`null` when the provider ran one it never named, as `kimi-cli` does with kimi's own default), the provider's `conversationId` when it has one, wall-clock `durationSeconds`, and the raw `usage` the provider reported (shape varies by provider, `null` when none). `attempts` lists every provider the failover chain tried, in order, with failure reasons; `warnings` carries routing notices (failovers, ignored extraBody, whose quota an auto-mode read spent).
52
52
 
53
53
  ## Flags
54
54
 
@@ -79,6 +79,7 @@ The default `-m` model depends on the provider:
79
79
  | `gemini-api` | `gemini-3.6-flash` |
80
80
  | `anthropic` | `claude-haiku-4-5-20251001` |
81
81
  | `claude-cli` | `haiku` |
82
+ | `kimi-cli` | whatever kimi is configured with |
82
83
  | `openai` | none, `-m` is required |
83
84
 
84
85
  `modlens recover-paste`:
@@ -92,7 +93,7 @@ The default `-m` model depends on the provider:
92
93
  | `--harness <name>` | Force storage scope: `claude-code`, `pi`, `opencode`, `none` | auto-detect |
93
94
  | `--cwd <path>` | Project directory the image was pasted in | current directory |
94
95
 
95
- Five providers: `antigravity-cli` (no key), `gemini-api` (fastest free route), `openai` (any OpenAI-compatible multimodal endpoint), `anthropic`, and `claude-cli` (uses your existing Claude subscription). Without `-p`, a run tries every provider that is set up, inline API providers first (5-10s), then the agents; the first good result wins and `meta.attempts` records the rest. Harnesses granted via `reuse.<harness>` contribute reused engines to the same regions (pi credentials inline, agent CLIs behind), with no priority over the user's own; details and the `guards` deny/allow lists are in [Configuration](../skills/modlens/references/configure.md).
96
+ Six providers: `antigravity-cli` (no key), `gemini-api` (fastest free route), `openai` (any OpenAI-compatible multimodal endpoint), `anthropic`, `claude-cli` (uses your existing Claude subscription), and `kimi-cli` (uses your existing Kimi Code subscription, and runs only when named, never as a failover peer). Without `-p`, a run tries every provider that is set up, inline API providers first (5-10s), then the agents; the first good result wins and `meta.attempts` records the rest. Harnesses granted via `reuse.<harness>` contribute reused engines to the same regions (pi credentials inline, agent CLIs behind), with no priority over the user's own; details and the `guards` deny/allow lists are in [Configuration](../skills/modlens/references/configure.md).
96
97
 
97
98
  Other subcommands:
98
99
 
package/docs/cli.zh-CN.md CHANGED
@@ -48,7 +48,7 @@ modlens recover-paste # pull a pasted image into a fil
48
48
  }
49
49
  ```
50
50
 
51
- `meta` 记录结果是怎么产生的:什么时间(`generatedAt`)、用了哪个 `model`、provider 有会话时的 `conversationId`、实际耗时 `durationSeconds`,以及 provider 上报的原始 `usage`(结构因 provider 而异,没有时为 `null`)。`attempts` 按顺序列出故障转移链尝试过的每个 provider 和失败原因。`warnings` 携带路由通知(故障转移、被忽略的 extraBody、自动模式下这次识别花了谁的额度)。
51
+ `meta` 记录结果是怎么产生的:什么时间(`generatedAt`)、用了哪个 `model`(provider 跑的是它自己配好、又没告诉我们型号的模型时为 `null`,`kimi-cli` 用 kimi 默认模型就是这种情况)、provider 有会话时的 `conversationId`、实际耗时 `durationSeconds`,以及 provider 上报的原始 `usage`(结构因 provider 而异,没有时为 `null`)。`attempts` 按顺序列出故障转移链尝试过的每个 provider 和失败原因。`warnings` 携带路由通知(故障转移、被忽略的 extraBody、自动模式下这次识别花了谁的额度)。
52
52
 
53
53
  ## 参数
54
54
 
@@ -76,6 +76,7 @@ modlens recover-paste # pull a pasted image into a fil
76
76
  | `gemini-api` | `gemini-3.6-flash` |
77
77
  | `anthropic` | `claude-haiku-4-5-20251001` |
78
78
  | `claude-cli` | `haiku` |
79
+ | `kimi-cli` | 用 kimi 自己配好的模型 |
79
80
  | `openai` | 无,必须传 `-m` |
80
81
 
81
82
  `modlens recover-paste`:
@@ -89,7 +90,7 @@ modlens recover-paste # pull a pasted image into a fil
89
90
  | `--harness <name>` | 强制指定存储范围:`claude-code`、`pi`、`opencode`、`none` | 自动检测 |
90
91
  | `--cwd <path>` | 粘贴图片时所在的项目目录 | 当前目录 |
91
92
 
92
- 共五个 provider:`antigravity-cli`(免 key)、`gemini-api`(最快的免费通道)、`openai`(任意 OpenAI 兼容的多模态端点)、`anthropic`,以及 `claude-cli`(复用你现有的 Claude 订阅)。不带 `-p` 时,一次运行会依次尝试每个已配好的 provider:API 快车道(inline API provider,不启动 agent、直接调 API 的引擎)先试(5-10 秒),agent 类兜底,第一个可用结果胜出,其余尝试记录在 `meta.attempts` 里。通过 `reuse.<harness>` 授权的 harness 会把复用来的引擎补进相同的区段(pi 的凭据算快车道,agent CLI 排在后面),不会插到你自己引擎的前面。细节和 `guards` 的 deny/allow 名单见[配置手册](../skills/modlens/references/configure.zh-CN.md)。
93
+ 共六个 provider:`antigravity-cli`(免 key)、`gemini-api`(最快的免费通道)、`openai`(任意 OpenAI 兼容的多模态端点)、`anthropic`,`claude-cli`(复用你现有的 Claude 订阅),以及 `kimi-cli`(复用你现有的 Kimi Code 订阅,只在被点名时运行,绝不作为故障转移备选)。不带 `-p` 时,一次运行会依次尝试每个已配好的 provider:API 快车道(inline API provider,不启动 agent、直接调 API 的引擎)先试(5-10 秒),agent 类兜底,第一个可用结果胜出,其余尝试记录在 `meta.attempts` 里。通过 `reuse.<harness>` 授权的 harness 会把复用来的引擎补进相同的区段(pi 的凭据算快车道,agent CLI 排在后面),不会插到你自己引擎的前面。细节和 `guards` 的 deny/allow 名单见[配置手册](../skills/modlens/references/configure.zh-CN.md)。
93
94
 
94
95
  其他子命令:
95
96
 
@@ -55,7 +55,7 @@ OpenCode with DeepSeek: `opencode auth login`, pick DeepSeek and paste the key (
55
55
  dsh is different from the other harnesses: modlens plugs in as a native tool, not a prompt-triggered skill. The package itself is a dsh bundle, so one command installs it into a profile:
56
56
 
57
57
  ```sh
58
- npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.16.7
58
+ npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.17.0
59
59
  ```
60
60
 
61
61
  This registers a `modlens_read_image` tool whose schema reaches the model on every request (no trigger heuristics), runs the modlens CLI shipped inside the same package, and returns the structured evidence as the tool's canonical JSON output. Engines, reuse grants, and guard rules stay in `~/.modlens/config.json`, shared with every other harness. dsh is in developer preview and its plugin surface may change; the plugin keeps its touch small (raw tool registration, the llm adapter surface for the vision variants, the attachment reader, and one agent pre-step hook) and degrades loudly if any of them moves.
@@ -81,7 +81,7 @@ modlens ships often, and both install shapes freeze at whatever version they
81
81
  got. On dsh, re-run the install with the version named:
82
82
 
83
83
  ```sh
84
- npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.16.7
84
+ npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.17.0
85
85
  ```
86
86
 
87
87
  `npm view @liustack/modlens version` prints the current one, and this page is
@@ -55,7 +55,7 @@ OpenCode 接 DeepSeek:执行 `opencode auth login`,选择 DeepSeek 并粘贴
55
55
  dsh 与其他 harness 不同:modlens 以原生工具的形式接入,而不是靠提示词触发的 skill。本包自身就是一个 dsh bundle,一条命令即可装进某个 profile:
56
56
 
57
57
  ```sh
58
- npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.16.7
58
+ npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.17.0
59
59
  ```
60
60
 
61
61
  这会注册一个 `modlens_read_image` 工具,它的 schema 随每次请求抵达模型(不靠触发启发式),运行同一个包里自带的 modlens CLI,并把结构化证据作为工具的标准 JSON 输出返回。引擎、复用授权和 guard 规则仍在 `~/.modlens/config.json` 里,与其他所有 harness 共享。dsh 还在开发者预览阶段,插件接口可能变化。这个插件刻意保持很小的接触面(原生工具注册、视觉变体所用的 llm 适配层、附件读取器,以及一个 agent 执行前钩子),其中任何一处变动,它都会大声报错而不是无声退化。
@@ -71,7 +71,7 @@ dsh 的网页用户面前没有终端,所以引擎设置有一张卡片,在*
71
71
  modlens 发布很频繁,而两种安装形态都会冻结在装进来的那个版本上。dsh 上重跑一遍安装即可,版本号要点名:
72
72
 
73
73
  ```sh
74
- npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.16.7
74
+ npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.17.0
75
75
  ```
76
76
 
77
77
  `npm view @liustack/modlens version` 可以查到当前版本号,本页的版本号则由发布流程自动写入。
package/docs/security.md CHANGED
@@ -20,9 +20,9 @@ Recovery is also scoped to one project: the working directory recorded inside th
20
20
 
21
21
  ModLens invokes `agy` with `--dangerously-skip-permissions` because prompt mode fails in some environments without it. The prompt restricts the agent to reading the one image it was given, and instructs it to treat image content strictly as data.
22
22
 
23
- The `claude-cli` provider runs with `--allowedTools Read` only, so it can read local files and nothing else.
23
+ The `claude-cli` provider runs with `--allowedTools Read` only, so it can read local files and nothing else. The `kimi-cli` provider cannot be narrowed that way (its CLI has no equivalent flag), so it is treated differently in two respects: it runs only when named, never as a failover peer, since it spends a subscription; and it runs with skill discovery pointed at an empty directory, because otherwise kimi can find the modlens skill and read the image by running modlens, which is modlens calling itself. Its child also carries a marker that makes a nested modlens refuse to spawn kimi again.
24
24
 
25
- Both subprocess providers also run in a throwaway directory created fresh per call and removed afterward. For a local image it holds a private copy of that one image and nothing else, and it is a real copy, never a hardlink, so a provider writing to its temp path cannot touch the original. For a remote image the directory is empty and the agent downloads into it. Without this, text inside an image could steer a broadly-permissioned agent into reading files next to the original, or whatever project the caller happened to be in. Passing `--workdir` opts out and runs where you point it.
25
+ The subprocess providers all run in a throwaway directory created fresh per call and removed afterward. For a local image it holds a private copy of that one image and nothing else, and it is a real copy, never a hardlink, so a provider writing to its temp path cannot touch the original. For a remote image the directory is empty and the agent downloads into it. Without this, text inside an image could steer a broadly-permissioned agent into reading files next to the original, or whatever project the caller happened to be in. Passing `--workdir` opts out and runs where you point it.
26
26
 
27
27
  This is exposure reduction, not an OS sandbox: the agent can still read absolute paths, reach the network, and spawn processes. Treat it as a narrower default, not a security boundary. For images you do not trust, prefer `-p gemini-api`, which downloads the bytes itself and runs no local agent. Remote URLs already prefer the inline region: the failover chain for a remote URL tries the inline API providers first and the agents last. Who actually fetches a remote URL differs per provider, and only a local download can be guarded locally:
28
28
 
@@ -20,9 +20,9 @@ read_when:
20
20
 
21
21
  ModLens 调用 `agy` 时带上 `--dangerously-skip-permissions`,因为某些环境下 prompt 模式不带它就会失败。prompt 把 agent 限制为只读交给它的那一张图片,并指示它把图片内容严格当作数据。
22
22
 
23
- `claude-cli` provider 只带 `--allowedTools Read` 运行,因此它能读本地文件,别的什么都做不了。
23
+ `claude-cli` provider 只带 `--allowedTools Read` 运行,因此它能读本地文件,别的什么都做不了。`kimi-cli` 没法这样收窄(它的 CLI 没有对应参数),所以在两处区别对待:它只在被点名时运行,绝不作为故障转移的备选,因为它花的是订阅;运行时把 skill 发现指向空目录,否则 kimi 会找到 modlens skill、通过运行 modlens 来读图,也就是 modlens 自己调自己。它的子进程还带一个标记,让嵌套的 modlens 拒绝再次启动 kimi。
24
24
 
25
- 两个子进程 provider 还都运行在一个用完即弃的目录里,每次调用新建,结束后删除。本地图片时,目录里只有那张图片的一份私有副本,别无他物,而且是真实拷贝,绝不用硬链接,provider 往自己的临时路径写东西也碰不到原文件。远程图片时目录是空的,agent 把文件下载进去。没有这层隔离,图片里的文字就可能引导一个权限宽泛的 agent 去读原图旁边的文件,或读调用者恰好所在的项目。传 `--workdir` 即放弃这层隔离,在你指定的位置运行。
25
+ 这些子进程 provider 还都运行在一个用完即弃的目录里,每次调用新建,结束后删除。本地图片时,目录里只有那张图片的一份私有副本,别无他物,而且是真实拷贝,绝不用硬链接,provider 往自己的临时路径写东西也碰不到原文件。远程图片时目录是空的,agent 把文件下载进去。没有这层隔离,图片里的文字就可能引导一个权限宽泛的 agent 去读原图旁边的文件,或读调用者恰好所在的项目。传 `--workdir` 即放弃这层隔离,在你指定的位置运行。
26
26
 
27
27
  这是在缩小暴露面,不是操作系统沙箱:agent 仍然能读绝对路径、访问网络、启动进程。把它当作更收紧的默认值,而不是安全边界。不信任的图片优先用 `-p gemini-api`,它自己下载字节,不运行任何本地 agent。远程 URL 本来就优先走内联这一侧:远程 URL 的故障转移链先试内联的 API provider,agent 排在最后。远程 URL 实际由谁抓取因 provider 而异,而只有本地下载能被本地防护覆盖:
28
28
 
@@ -163,7 +163,7 @@ simply lands on an older one. Name the exact version instead, which pnpm treats
163
163
  as a deliberate request rather than a resolution:
164
164
 
165
165
  ```sh
166
- npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.16.7
166
+ npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.17.0
167
167
  ```
168
168
 
169
169
  `npm view @liustack/modlens version` prints the current one. pnpm 11 installs a named
@@ -178,7 +178,7 @@ file:
178
178
 
179
179
  ```yaml
180
180
  minimumReleaseAgeExclude:
181
- - '@liustack/modlens@3.16.7'
181
+ - '@liustack/modlens@3.17.0'
182
182
  ```
183
183
 
184
184
  Or lift the gate for a single command, which lifts it for everything that
@@ -144,7 +144,7 @@ dsh profile 装到的是旧版 modlens。`dsh.bundle` 声明从 3.9.0 起才存
144
144
  `@latest` 绕不开这一层,本页早先的说法是错的。冷静期先把候选版本过滤掉,dist-tag 才在剩下的里面解析,于是它直接落到了更旧的那个上。改成写死精确版本号,pnpm 会把它当作一次明确的指定,而不是一次解析:
145
145
 
146
146
  ```sh
147
- npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.16.7
147
+ npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.17.0
148
148
  ```
149
149
 
150
150
  `npm view @liustack/modlens version` 可以查到当前版本号。pnpm 11 会装上被点名的版本,11.1.3 起还会把它作为一条已批准的例外写进该 profile 的 `pnpm-workspace.yaml`,其余所有包和 modlens 以后的版本仍然留在窗口后面。
@@ -153,7 +153,7 @@ npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.16.7
153
153
 
154
154
  ```yaml
155
155
  minimumReleaseAgeExclude:
156
- - '@liustack/modlens@3.16.7'
156
+ - '@liustack/modlens@3.17.0'
157
157
  ```
158
158
 
159
159
  或者只为这一条命令解除冷静期,注意它解除的是这条命令解析到的所有包,不只 modlens:
package/dsh/client.js CHANGED
@@ -208,6 +208,8 @@ window.__ModuleLoader__.load({
208
208
  found: 'found',
209
209
  notLoggedIn: 'found, not signed in',
210
210
  notFound: 'not on this machine',
211
+ envSourced:
212
+ 'These come from environment variables. Saving copies them into the config file, which then becomes this engine’s only source.',
211
213
  },
212
214
  zh: {
213
215
  title: '视觉引擎(ModLens)',
@@ -233,6 +235,7 @@ window.__ModuleLoader__.load({
233
235
  found: '已找到',
234
236
  notLoggedIn: '已找到,未登录',
235
237
  notFound: '本机没有',
238
+ envSourced: '这些值来自环境变量。保存会把它们写进配置文件,此后该引擎只认配置文件。',
236
239
  },
237
240
  }
238
241
 
@@ -531,6 +534,25 @@ window.__ModuleLoader__.load({
531
534
  : textField(t.apiKey, 'apiKey', 'password', current.hasKey ? t.stored : t.unset),
532
535
  draft.provider === '' || keyless ? null : textField(t.baseUrl, 'baseUrl', 'text', t.fallback),
533
536
  draft.provider === '' ? null : textField(t.model, 'model', 'text', t.fallback),
537
+ // Where these values are coming from, said once, because the
538
+ // first save moves them: an engine the file names takes its
539
+ // settings from the file alone.
540
+ draft.provider === '' || current.source !== 'env'
541
+ ? null
542
+ : fieldRow(
543
+ '',
544
+ h(
545
+ 'div',
546
+ {
547
+ style: {
548
+ fontSize: '13px',
549
+ color: 'var(--dsw-alias-label-tertiary, rgba(127,127,127,0.8))',
550
+ },
551
+ },
552
+ t.envSourced,
553
+ ),
554
+ 'envsourced',
555
+ ),
534
556
  fieldRow(
535
557
  h(
536
558
  'span',
package/dsh/index.js CHANGED
@@ -905,6 +905,34 @@ function settingsKeysFor(engine) {
905
905
  // absent counts as granted, since claude-cli predates the grant model.
906
906
  const REUSE_HARNESSES = ['claude', 'codex', 'opencode', 'pi', 'grok']
907
907
 
908
+ // The variables that supply an engine while the file names no entry for it,
909
+ // mirroring ENV_BINDINGS in src/config.ts. An engine takes its settings from
910
+ // one source whole, so the card has to read the same two places a read does or
911
+ // it shows an empty form for an engine that works.
912
+ const ENGINE_ENV_BINDINGS = {
913
+ 'gemini-api': { apiKey: 'GEMINI_API_KEY' },
914
+ openai: { apiKey: 'OPENAI_API_KEY', baseUrl: 'OPENAI_BASE_URL' },
915
+ anthropic: { apiKey: 'ANTHROPIC_API_KEY', baseUrl: 'ANTHROPIC_BASE_URL' },
916
+ }
917
+
918
+ function engineEnvSettings(engine, env = process.env) {
919
+ const settings = {}
920
+ for (const [field, variable] of Object.entries(ENGINE_ENV_BINDINGS[engine] ?? {})) {
921
+ const value = typeof env[variable] === 'string' ? env[variable].trim() : ''
922
+ if (value !== '') settings[field] = value
923
+ }
924
+ return settings
925
+ }
926
+
927
+ /**
928
+ * Whether the file names this engine. The key existing is what counts, not
929
+ * what it holds: an entry emptied down to `{}` still takes the engine off its
930
+ * variables, the same rule fileKeysFor applies in src/config.ts.
931
+ */
932
+ function engineConfiguredInFile(engine, config) {
933
+ return settingsKeysFor(engine).some((key) => config.providers?.[key] !== undefined)
934
+ }
935
+
908
936
  /** ~/.modlens/config.json, the one file every harness shares. */
909
937
  function modlensConfigPath() {
910
938
  return join(homedir(), '.modlens', 'config.json')
@@ -944,13 +972,23 @@ function readModlensConfig() {
944
972
  function engineSummary(config = readModlensConfig()) {
945
973
  const engines = {}
946
974
  for (const name of ENGINES) {
975
+ // One source, whole. The file when it names the engine, its variables
976
+ // otherwise: reading only the file showed an empty form for a container
977
+ // that exports its key, and the first save then wrote a partial entry
978
+ // that took the working variables away.
979
+ const inFile = engineConfiguredInFile(name, config)
947
980
  // Alias first, canonical last: the canonical key wins on conflict, the
948
981
  // same order resolveProviderSettings uses.
949
- const settings = Object.assign({}, ...settingsKeysFor(name).map((key) => config.providers?.[key] ?? {}))
982
+ const settings = inFile
983
+ ? Object.assign({}, ...settingsKeysFor(name).map((key) => config.providers?.[key] ?? {}))
984
+ : engineEnvSettings(name)
950
985
  engines[name] = {
951
986
  baseUrl: typeof settings.baseUrl === 'string' ? settings.baseUrl : '',
952
987
  model: typeof settings.model === 'string' ? settings.model : '',
953
988
  hasKey: typeof settings.apiKey === 'string' && settings.apiKey !== '',
989
+ // '' means neither source holds anything, which is not the same as the
990
+ // file holding an empty entry: that one is already off its variables.
991
+ source: inFile ? 'file' : Object.keys(settings).length > 0 ? 'env' : '',
954
992
  }
955
993
  }
956
994
  const reuse = {}
@@ -1011,7 +1049,13 @@ function applyEngineSettings(patch) {
1011
1049
  // then `config set gemini-api.apiKey` leaves exactly that.
1012
1050
  const holders = settingsKeysFor(engine).filter((key) => config.providers[key] !== undefined)
1013
1051
  const target = holders.length > 0 ? holders[holders.length - 1] : engine
1014
- const settings = { ...config.providers[target] }
1052
+ // The first file entry for an engine the variables are supplying takes it
1053
+ // off them whole, so their values move into the entry with it. Otherwise
1054
+ // saving a model on a working environment-only engine deleted its key and
1055
+ // endpoint from the run. Seeded here rather than in the browser because
1056
+ // the key must never travel there.
1057
+ const seed = holders.length > 0 ? {} : engineEnvSettings(engine)
1058
+ const settings = { ...seed, ...config.providers[target] }
1015
1059
  for (const field of ['baseUrl', 'model']) {
1016
1060
  const value = typeof patch[field] === 'string' ? patch[field].trim() : ''
1017
1061
  if (value === '') {
@@ -1218,3 +1262,9 @@ function registerConfigRoute(ctx) {
1218
1262
  },
1219
1263
  })
1220
1264
  }
1265
+
1266
+ // The two halves of the settings card that live on this side of the socket,
1267
+ // reachable from the test suite the way client.js exposes `__card`. They read
1268
+ // and write a real file and a real environment, so they are tested against
1269
+ // both rather than through the HTTP route.
1270
+ export const __config = { engineSummary, applyEngineSettings, modlensConfigPath }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liustack/modlens",
3
- "version": "3.16.7",
3
+ "version": "3.17.0",
4
4
  "description": "Plug-in vision for text-only LLMs, powered by the free Antigravity CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,11 +20,11 @@ powershell -ExecutionPolicy Bypass -File <skill-dir>\scripts\run.ps1 <args>
20
20
 
21
21
  It resolves a working runtime (PATH `modlens`, then `npx`, then `bunx`) and forwards your arguments unchanged. Exit 78 means no runtime: relay the `nextSteps` from its stderr JSON instead of retrying.
22
22
 
23
- If your harness forbids running scripts, reason through the same order by hand and run the first line that works (the pinned version is 3.16.7):
23
+ If your harness forbids running scripts, reason through the same order by hand and run the first line that works (the pinned version is 3.17.0):
24
24
 
25
- 1. A `modlens` on `PATH` whose major version is 3 and is at least 3.16.7: `modlens <args>`.
26
- 2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.16.7 modlens <args>`.
27
- 3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.16.7 <args>`.
25
+ 1. A `modlens` on `PATH` whose major version is 3 and is at least 3.17.0: `modlens <args>`.
26
+ 2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.17.0 modlens <args>`.
27
+ 3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.17.0 <args>`.
28
28
  4. Otherwise tell the user no JavaScript runtime was found and that installing Node 22.19+ (https://nodejs.org) or Bun (https://bun.sh) is the next step. Do not claim modlens itself failed.
29
29
 
30
30
  `references/runtime.md` documents the pin and the diagnostic fields.
@@ -6,7 +6,7 @@ Read this when the user asks how to set up, configure, or switch ModLens provide
6
6
 
7
7
  ## Where config lives
8
8
 
9
- `~/.modlens/config.json`, managed by the CLI. Precedence: CLI flags > environment variables > config file > built-in defaults. With no `provider` set, runs walk the failover chain in order (an available `gemini-api` key is tried before the agent CLIs); a machine with nothing configured at all ends up on `antigravity-cli`.
9
+ `~/.modlens/config.json`, managed by the CLI. Precedence: CLI flags > this file > built-in defaults. A provider's settings come from one source, whole: since 3.17.0 the file is that source whenever it mentions the provider, and the bound environment variables are when it does not. With no `provider` set, runs walk the failover chain in order (an available `gemini-api` key is tried before the agent CLIs); a machine with nothing configured at all ends up on `antigravity-cli`.
10
10
 
11
11
  ```bash
12
12
  modlens config init # write a starter config (refuses to overwrite; --force to redo)
@@ -58,16 +58,16 @@ Everything lives under five top-level keys, all optional. This example shows eve
58
58
 
59
59
  Field semantics:
60
60
 
61
- - `provider`: which provider runs when `-p` is not given. Canonical names or aliases both work (`agy`/`antigravity` for `antigravity-cli`, `gemini` for `gemini-api`, `openai-compat` for `openai`, `claude` for `anthropic`, `claude-code` for `claude-cli`). Empty or absent pins nothing: the failover chain decides, trying configured API providers before the agent CLIs.
61
+ - `provider`: which provider runs when `-p` is not given. Canonical names or aliases both work (`agy`/`antigravity` for `antigravity-cli`, `gemini` for `gemini-api`, `openai-compat` for `openai`, `claude` for `anthropic`, `kimi`/`kimi-code` for `kimi-cli`, `claude-code` for `claude-cli`). Empty or absent pins nothing: the failover chain decides, trying configured API providers before the agent CLIs.
62
62
  - `providers.<name>.<field>`: six fields exist, `apiKey`, `baseUrl`, `model`, `proxy`, `extraBody`, and `structuredOutput` (the openai route only). Every provider entry is optional, and every field inside it is optional. Alias keys are read too (settings saved under `gemini` are found when `gemini-api` resolves), with the canonical key winning on conflict.
63
- - `providers.<name>.extraBody`: a JSON object merged into the request body of the API providers (`gemini-api`, `openai`, `anthropic`), for whatever knobs that vendor has and modlens has no flag for. Turning thinking off is the usual reason, see the section below. Nested objects merge key by key, so adding one knob leaves the rest of that block alone. The fields carrying the image, the prompt, and each route's own enforcement machinery are refused with an error naming the field. `response_format` on the `openai` route is not one of them: setting it there deliberately replaces the schema modlens would otherwise send. The two CLI providers take no request body, so a run on `antigravity-cli` or `claude-cli` ignores it and says so in `meta.warnings`.
63
+ - `providers.<name>.extraBody`: a JSON object merged into the request body of the API providers (`gemini-api`, `openai`, `anthropic`), for whatever knobs that vendor has and modlens has no flag for. Turning thinking off is the usual reason, see the section below. Nested objects merge key by key, so adding one knob leaves the rest of that block alone. The fields carrying the image, the prompt, and each route's own enforcement machinery are refused with an error naming the field. `response_format` on the `openai` route is not one of them: setting it there deliberately replaces the schema modlens would otherwise send. The three CLI providers take no request body, so a run on `antigravity-cli`, `claude-cli` or `kimi-cli` ignores it and says so in `meta.warnings`.
64
64
  - `providers.openai.structuredOutput`: `true` asks an OpenAI-compatible gateway to enforce the vision contract itself, as `response_format: json_schema` in the strict form those endpoints require. Off by default, since a gateway without structured-output support answers 400 for the field. A `response_format` you set in `extraBody` wins over it.
65
65
  - `guards`: the invocation guard, for people who run both text-only and vision-capable models through the same client. Both lists hold glob patterns (`*` and `?`, case-insensitive, matched against the model name and `provider/model`), set with `modlens config set guards.denyModels '["gemini-3*"]'` or `guards.allowModels` (a JSON array or a comma-separated list, empty clears). Two ways to express the same intent, pick the shorter list:
66
66
  - `denyModels` alone: everything runs the engine except the listed vision models. Right when text-only models are the majority of what you plug in.
67
67
  - `allowModels` non-empty (allowlist mode): only the listed models run the engine, every other identified model is denied. Right for the actual 2026 landscape, where text-only models are the short list. A deny pattern still wins over an allow match, so a broad allow can have its vision variants carved out, as in the example above: `glm-5.*` allows the text line while `glm-*v*` catches `glm-5v-turbo`. Anchor allow patterns tightly (`deepseek-v4-*`, not `deepseek*`) so a vendor's next multimodal generation falls off the list and steps aside until you have checked it.
68
68
  - List a model by what actually reaches it, not by what it could see: a multimodal model behind a gateway that strips images still needs modlens, and your session transcript records the model name the gateway reports. `modlens doctor`'s Guard section shows the rules and a live verdict for checking the result.
69
69
  - `denyWhenUnknown` (default `false`) decides what happens when no signal identifies the active model, in either mode: `false` proceeds, `true` denies. The active model is detected from, strongest first: the `MODLENS_MODEL` env var (`none` means "treat as unknown"), the harness's session storage, the `--model` self-report.
70
- - Environment variables override the file for these bindings: `GEMINI_API_KEY`, `OPENAI_API_KEY`, `OPENAI_BASE_URL`, `ANTHROPIC_API_KEY`, `ANTHROPIC_BASE_URL`. Beyond those, modlens reads `MODLENS_HARNESS` (paste-recovery and guard scope), `MODLENS_MODEL` (guard override, see `guards`), and the fingerprints harnesses inject themselves, which pin the guard's storage lookup to the current session: `CLAUDE_CODE_SESSION_ID`, `CODEX_THREAD_ID`, plus the presence markers harness detection relies on (`CLAUDECODE`, `PI_CODING_AGENT`, `CODEX_SANDBOX`).
70
+ - `GEMINI_API_KEY`, `OPENAI_API_KEY`, `OPENAI_BASE_URL`, `ANTHROPIC_API_KEY` and `ANTHROPIC_BASE_URL` configure a provider this file says nothing about, and are ignored entirely for one it does. They used to merge field by field, which built pairings that existed nowhere: a baseUrl and an apiKey are one credential. modlens still reads `MODLENS_HARNESS` (paste-recovery and guard scope), `MODLENS_MODEL` (guard override, see `guards`), and the fingerprints harnesses inject themselves, which pin the guard's storage lookup to the current session: `CLAUDE_CODE_SESSION_ID`, `CODEX_THREAD_ID`, plus the presence markers harness detection relies on (`CLAUDECODE`, `PI_CODING_AGENT`, `CODEX_SANDBOX`).
71
71
  - `reuse.<claude|codex|opencode|pi|grok>`: per-harness grants for spending other local logins, written by the onboarding conversation (`references/onboard.md`). `true` lets reads reuse that harness (pi credentials join the inline region with every guard intact; a signed-in Codex, an OpenCode vision model, or pi driven directly join the agent region before `claude-cli`), `false` records a refusal so the user is never re-asked, absent means never asked and nothing runs. `claude` absent counts as granted: `claude-cli` predates this model as a built-in provider, and `reuse.claude false` removes it from the chain (`-p claude-cli` still pins). Reused engines get no priority over the user's own: regions order by speed class only. Every reused answer adds a `meta.warnings` line naming whose quota it spent, and `modlens doctor`'s Reuse section shows each harness's decision plus what discovery found (probe results cache for 6 hours in `~/.modlens/auto-cache.json`; doctor always re-probes). Set with `modlens config set reuse.codex true` (empty clears back to never-asked).
72
72
  - Unknown top-level keys and unknown provider names are ignored rather than rejected, so a typo fails quiet: run `modlens doctor` after hand-editing, it shows which file and env values are actually in effect.
73
73
 
@@ -95,7 +95,6 @@ Any free Google account works; no Google AI Pro needed. Sign-in cannot be automa
95
95
  modlens config set gemini-api.apiKey <key>
96
96
  # value omitted: a hidden prompt, so the key skips argv, shell history, and this chat
97
97
  modlens config set gemini-api.apiKey
98
- # or environment: export GEMINI_API_KEY=<key>
99
98
  ```
100
99
 
101
100
  Offer the hidden prompt first when the user is at their own terminal. Most users paste the key into the chat because it is convenient, and that works too: take it and store it. The prompt is for the ones who would rather not.
@@ -112,7 +111,7 @@ modlens config set openai.apiKey <sk-key>
112
111
  modlens config set openai.model qwen3.6-27b
113
112
  ```
114
113
 
115
- For official OpenAI: baseUrl `https://api.openai.com/v1`, a vision-capable model. Environment equivalents: `OPENAI_BASE_URL`, `OPENAI_API_KEY`. The model must be multimodal; text-only models will fail or hallucinate.
114
+ `baseUrl` is required, official OpenAI included (`https://api.openai.com/v1`): this route serves any compatible endpoint, and guessing one would send a key meant for another vendor, and the image beside it, somewhere the user never named. The model must be multimodal; text-only models will fail or hallucinate.
116
115
 
117
116
  This route enforces nothing server-side by default, so a weaker model can answer with half the contract and the run fails with an explicit error. If that happens, ask the gateway to enforce it:
118
117
 
@@ -126,12 +125,35 @@ The contract goes out as `response_format: json_schema` in strict form, derived
126
125
 
127
126
  ```bash
128
127
  modlens config set anthropic.apiKey <sk-ant-key>
129
- # or: export ANTHROPIC_API_KEY=<key>
130
128
  ```
131
129
 
132
130
  Default model is Claude Haiku (`claude-haiku-4-5-20251001`). Schema is enforced through a forced tool call.
133
131
 
134
- **`ANTHROPIC_BASE_URL` trap.** modlens binds `ANTHROPIC_BASE_URL` to `anthropic.baseUrl`, so it inherits whatever that variable points at. If the user set it in their shell to route Claude Code through a text-only gateway (a common way to run a non-Claude model behind the Claude Code UI), then `-p anthropic` silently sends the vision request to that gateway too, where it fails or comes back blind, with no hint that the endpoint was swapped. Check `echo $ANTHROPIC_BASE_URL` when anthropic vision misbehaves. Fixes: unset it for the modlens call, pin the real endpoint with `modlens config set anthropic.baseUrl https://api.anthropic.com`, or use `-p gemini-api` instead.
132
+ **The `ANTHROPIC_BASE_URL` trap is defused.** modlens used to bind that variable to `anthropic.baseUrl` field by field, so a shell that routed Claude Code through a text-only gateway silently sent vision requests there too, even beside a key set in the config file. The moment the file names `anthropic`, the file is this route's whole source and that variable no longer reaches it: set `anthropic.baseUrl` when you do want a different endpoint. `ANTHROPIC_API_KEY` and `ANTHROPIC_BASE_URL` still configure this route on their own while the file says nothing about `anthropic`, both halves coming from the same place. A run caught between the two, with the variable set and the file naming `anthropic` without a `baseUrl`, refuses and prints the command that keeps the endpoint you were using.
133
+
134
+ ### kimi-cli (Kimi Code login, no key)
135
+
136
+ Rides an existing `kimi` sign-in, so it spends the user's Kimi Code subscription
137
+ rather than a key. Install from https://moonshotai.github.io/kimi-code/, run
138
+ `kimi` once and `/login`, then:
139
+
140
+ ```bash
141
+ modlens config set provider kimi-cli
142
+ modlens config set kimi-cli.model <alias> # optional; kimi's own default otherwise
143
+ ```
144
+
145
+ Naming it is what turns it on. Unlike the other CLI routes it never joins the failover chain on its own, because it spends a subscription and installing the CLI is not agreement to spend it.
146
+
147
+ The model alias is kimi's, in `<provider>/<model>` form as `kimi provider list`
148
+ shows it, and it has to accept image input. This route enforces no schema (the
149
+ CLI has no `--json-schema`), so the contract travels as a filled-in JSON
150
+ template and a weaker model can answer with half of it; `-p gemini-api` is the
151
+ fallback when that happens.
152
+
153
+ One implementation note worth knowing if you debug it: modlens runs `kimi` with
154
+ skill discovery pointed at an empty directory. Otherwise kimi can find the
155
+ modlens skill in the shared skill directories and read the image by calling
156
+ modlens, which is modlens calling itself.
135
157
 
136
158
  ### claude-cli (Claude Code login, no key)
137
159
 
@@ -6,7 +6,7 @@
6
6
 
7
7
  ## 配置放在哪
8
8
 
9
- `~/.modlens/config.json`,由 CLI 管理。优先级:CLI 参数 > 环境变量 > 配置文件 > 内置默认值。不设 `provider` 时按失败切换链依次尝试(有 `gemini-api` key 会先于 agent CLI 被试到),机器上什么都没配才会落在 `antigravity-cli`。
9
+ `~/.modlens/config.json`,由 CLI 管理。优先级:CLI 参数 > 本文件 > 内置默认值。一个 provider 的设置整份来自单一来源:自 3.17.0 起,本文件提到过它就以本文件为准,只字未提才用绑定的环境变量。不设 `provider` 时按失败切换链依次尝试(有 `gemini-api` key 会先于 agent CLI 被试到),机器上什么都没配才会落在 `antigravity-cli`。
10
10
 
11
11
  ```bash
12
12
  modlens config init # 写入一份起步配置(已存在则拒绝,--force 重写)
@@ -58,16 +58,16 @@ modlens config set <provider>.<field> <value> # 字段:apiKey、baseUrl、mo
58
58
 
59
59
  字段含义:
60
60
 
61
- - `provider`:不传 `-p` 时由哪个 provider 执行。标准名和别名都行(`agy`/`antigravity` 对应 `antigravity-cli`,`gemini` 对应 `gemini-api`,`openai-compat` 对应 `openai`,`claude` 对应 `anthropic`,`claude-code` 对应 `claude-cli`)。留空或缺失表示不钉任何一个:由失败切换链决定,已配置的 API provider 先于 agent CLI 被尝试。
61
+ - `provider`:不传 `-p` 时由哪个 provider 执行。标准名和别名都行(`agy`/`antigravity` 对应 `antigravity-cli`,`gemini` 对应 `gemini-api`,`openai-compat` 对应 `openai`,`claude` 对应 `anthropic`,`kimi`/`kimi-code` 对应 `kimi-cli`,`claude-code` 对应 `claude-cli`)。留空或缺失表示不钉任何一个:由失败切换链决定,已配置的 API provider 先于 agent CLI 被尝试。
62
62
  - `providers.<name>.<field>`:共六个字段,`apiKey`、`baseUrl`、`model`、`proxy`、`extraBody`、`structuredOutput`(仅 openai 路线)。每个 provider 条目都可选,条目里的每个字段也都可选。别名键同样会被读取(存在 `gemini` 下的设置在解析到 `gemini-api` 时也能找到),冲突时标准键胜出。
63
- - `providers.<name>.extraBody`:一个 JSON 对象,合并进 API provider(`gemini-api`、`openai`、`anthropic`)的请求体,用来传厂商有而 modlens 没有对应参数的开关。最常见的用途是关掉思考,见下文小节。嵌套对象逐键合并,所以加一个开关不会动到该块里的其他内容。承载图片、提示词和各路线自身强制机制的字段会被拒绝,报错会点名该字段。`openai` 路线上的 `response_format` 不在此列:在那里设置它就是有意替换掉 modlens 本来会发的那份 schema。两个 CLI provider 不发请求体,所以在 `antigravity-cli` 或 `claude-cli` 上运行时它会被忽略,并在 `meta.warnings` 里说明。
63
+ - `providers.<name>.extraBody`:一个 JSON 对象,合并进 API provider(`gemini-api`、`openai`、`anthropic`)的请求体,用来传厂商有而 modlens 没有对应参数的开关。最常见的用途是关掉思考,见下文小节。嵌套对象逐键合并,所以加一个开关不会动到该块里的其他内容。承载图片、提示词和各路线自身强制机制的字段会被拒绝,报错会点名该字段。`openai` 路线上的 `response_format` 不在此列:在那里设置它就是有意替换掉 modlens 本来会发的那份 schema。三个 CLI provider 不发请求体,所以在 `antigravity-cli`、`claude-cli` 或 `kimi-cli` 上运行时它会被忽略,并在 `meta.warnings` 里说明。
64
64
  - `providers.openai.structuredOutput`:设为 `true` 时,让 OpenAI 兼容网关自己强制执行视觉契约,以 `response_format: json_schema` 的严格形式发出。默认关闭,因为不支持结构化输出的网关会对这个字段返回 400。你在 `extraBody` 里设的 `response_format` 优先级更高。
65
65
  - `guards`:调用 guard,给在同一个客户端里既跑纯文本模型又跑视觉模型的人用。两个列表都放 glob 模式(支持 `*` 和 `?`,不区分大小写,同时匹配模型名和 `provider/model`),用 `modlens config set guards.denyModels '["gemini-3*"]'` 或 `guards.allowModels` 设置(JSON 数组或逗号分隔的列表都行,传空则清除)。两种写法表达同一个意图,选列表更短的那种:
66
66
  - 只用 `denyModels`:除了列出的视觉模型,其余全部运行引擎。适合你接入的模型大多是纯文本的情况。
67
67
  - `allowModels` 非空(白名单模式):只有列出的模型运行引擎,其他所有已识别的模型一律拒绝。适合 2026 年的实际格局,纯文本模型才是那份短名单。deny 模式仍然优先于 allow 匹配,所以宽泛的 allow 可以把视觉变体剔出去,正如上面的示例:`glm-5.*` 放行文本系列,`glm-*v*` 抓住 `glm-5v-turbo`。allow 模式要锚定得紧一些(写 `deepseek-v4-*` 而不是 `deepseek*`),这样厂商下一代多模态型号会自动掉出名单,等你检查过再上场。
68
68
  - 按真正抵达模型的内容来列名单,而不是按它本来能看到什么:多模态模型如果躲在一个剥离图片的网关后面,照样需要 modlens,而你的会话记录里存的是网关上报的模型名。`modlens doctor` 的 Guard 一节会显示规则和一条实时判定,方便核对结果。
69
69
  - `denyWhenUnknown`(默认 `false`)决定在两种模式下,当没有任何信号能识别当前模型时怎么办:`false` 放行,`true` 拒绝。当前模型的检测来源从强到弱依次是:`MODLENS_MODEL` 环境变量(`none` 表示「按未知处理」)、harness 的会话存储、`--model` 自报。
70
- - 以下绑定上,环境变量会覆盖配置文件:`GEMINI_API_KEY`、`OPENAI_API_KEY`、`OPENAI_BASE_URL`、`ANTHROPIC_API_KEY`、`ANTHROPIC_BASE_URL`。除此之外,modlens 还读取 `MODLENS_HARNESS`(粘贴恢复和 guard 的作用范围)、`MODLENS_MODEL`(guard 覆盖,见 `guards`),以及各 harness 自己注入的指纹,它们把 guard 的存储查询钉在当前 session 上:`CLAUDE_CODE_SESSION_ID`、`CODEX_THREAD_ID`,加上 harness 检测依赖的存在性标记(`CLAUDECODE`、`PI_CODING_AGENT`、`CODEX_SANDBOX`)。
70
+ - `GEMINI_API_KEY`、`OPENAI_API_KEY`、`OPENAI_BASE_URL`、`ANTHROPIC_API_KEY`、`ANTHROPIC_BASE_URL` 用来配置本文件只字未提的 provider;本文件提到过的,它们完全不生效。过去它们逐字段覆盖,拼出的组合在哪儿都不存在:地址和密钥本是一副凭据。modlens 仍然读取 `MODLENS_HARNESS`(粘贴恢复和 guard 的作用范围)、`MODLENS_MODEL`(guard 覆盖,见 `guards`),以及各 harness 自己注入的指纹,它们把 guard 的存储查询钉在当前 session 上:`CLAUDE_CODE_SESSION_ID`、`CODEX_THREAD_ID`,加上 harness 检测依赖的存在性标记(`CLAUDECODE`、`PI_CODING_AGENT`、`CODEX_SANDBOX`)。
71
71
  - `reuse.<claude|codex|opencode|pi|grok>`:按 harness 记录的授权,决定能否花费本机其他登录态,由引导对话(`references/onboard.md`)写入。`true` 允许读图时复用该 harness(pi 的凭据加入 inline 区且所有 guard 照常生效,已登录的 Codex、OpenCode 的视觉模型或直接驱动的 pi 加入 agent 区,排在 `claude-cli` 之前),`false` 记下一次拒绝,用户不会被再次询问,缺失表示从未问过,什么都不会运行。`claude` 缺失视为已授权:`claude-cli` 作为内置 provider 早于这套模型存在,`reuse.claude false` 会把它移出链条(`-p claude-cli` 仍可钉死)。复用来的引擎不比用户自己的优先:分区只按速度档次排序。每个复用得来的答案都会在 `meta.warnings` 里加一行,说明花的是谁的额度,`modlens doctor` 的 Reuse 一节会显示每个 harness 的决定和探测发现的结果(探测结果在 `~/.modlens/auto-cache.json` 里缓存 6 小时,doctor 每次都重新探测)。用 `modlens config set reuse.codex true` 设置(传空恢复为从未问过)。
72
72
  - 未知的顶层键和未知的 provider 名会被忽略而不是报错,所以敲错字会无声失败:手工编辑后跑一下 `modlens doctor`,它会显示哪些文件值和环境变量真正生效。
73
73
 
@@ -95,7 +95,6 @@ agy # 用户需自己在浏览器完成登录,然后退出
95
95
  modlens config set gemini-api.apiKey <key>
96
96
  # 省略值:进入隐藏输入,密钥不进 argv、不进 shell 历史,也不进这段对话
97
97
  modlens config set gemini-api.apiKey
98
- # 或走环境变量:export GEMINI_API_KEY=<key>
99
98
  ```
100
99
 
101
100
  用户就在自己终端前时,先给隐藏输入这条。大多数人图方便还是会把 key 直接贴进对话,那也没问题:照收照存。隐藏输入是留给在乎的人的。
@@ -112,7 +111,7 @@ modlens config set openai.apiKey <sk-key>
112
111
  modlens config set openai.model qwen3.6-27b
113
112
  ```
114
113
 
115
- 官方 OpenAI 的写法:baseUrl 用 `https://api.openai.com/v1`,配一个具备视觉能力的模型。对应的环境变量:`OPENAI_BASE_URL`、`OPENAI_API_KEY`。模型必须是多模态的,纯文本模型会失败或产生幻觉。
114
+ `baseUrl` 必填,用官方 OpenAI 也要写(`https://api.openai.com/v1`):这条路线服务任意兼容端点,替用户猜一个,就等于把本该发给别家的密钥连同图片一起送到用户从没指定过的地方。模型必须是多模态的,纯文本模型会失败或产生幻觉。
116
115
 
117
116
  这条路线默认在服务端不做任何约束,能力弱一些的模型可能只答出契约的一半,运行就会以明确报错失败。真遇到就让网关自己强制执行:
118
117
 
@@ -126,12 +125,26 @@ modlens config set openai.structuredOutput true
126
125
 
127
126
  ```bash
128
127
  modlens config set anthropic.apiKey <sk-ant-key>
129
- # 或:export ANTHROPIC_API_KEY=<key>
130
128
  ```
131
129
 
132
130
  默认模型是 Claude Haiku(`claude-haiku-4-5-20251001`)。schema 通过强制工具调用来约束。
133
131
 
134
- **`ANTHROPIC_BASE_URL` 陷阱。**modlens 把 `ANTHROPIC_BASE_URL` 绑定到 `anthropic.baseUrl`,所以这个变量指向哪它就继承哪。如果用户在 shell 里设过它,用来把 Claude Code 路由到某个纯文本网关(在 Claude Code 界面下跑非 Claude 模型的常见做法),那么 `-p anthropic` 也会把视觉请求无声地发到那个网关,要么失败,要么返回的结果像没看过图,而且没有任何端点被换掉的提示。anthropic 的视觉表现异常时,先 `echo $ANTHROPIC_BASE_URL` 查一下。解法:给 modlens 调用临时取消这个变量,或用 `modlens config set anthropic.baseUrl https://api.anthropic.com` 钉死真实端点,或改用 `-p gemini-api`。
132
+ **`ANTHROPIC_BASE_URL` 陷阱已经拆掉了。**modlens 过去把这个变量按字段绑到 `anthropic.baseUrl`,于是一个为了把 Claude Code 路由到纯文本网关而设的变量,会让视觉请求也无声地发到那里,哪怕密钥是在配置文件里设的。现在只要文件里出现 `anthropic`,文件就是这条路线的全部来源,那个变量再也够不着它,确实想换端点就设 `anthropic.baseUrl`。而在文件对 `anthropic` 只字未提时,`ANTHROPIC_API_KEY` 和 `ANTHROPIC_BASE_URL` 仍然能独立配好这条路线,两半来自同一处。卡在中间的情况(变量设着、文件里有 `anthropic` 却没有 `baseUrl`)会直接报错,并给出保留原端点的那条命令。
133
+
134
+ ### kimi-cli(复用 Kimi Code 登录,无需密钥)
135
+
136
+ 搭在已有的 `kimi` 登录上,花的是用户的 Kimi Code 订阅而不是密钥。先从 https://moonshotai.github.io/kimi-code/ 安装,跑一次 `kimi` 并 `/login`,然后:
137
+
138
+ ```bash
139
+ modlens config set provider kimi-cli
140
+ modlens config set kimi-cli.model <alias> # 可选,不设就用 kimi 自己的默认模型
141
+ ```
142
+
143
+ 点名它才会启用。和其他 CLI 路线不同,它不会自己加入故障转移链:它花的是订阅,而装了 CLI 不等于同意花它。
144
+
145
+ 模型别名用 kimi 自己的那套,形如 `<provider>/<model>`,`kimi provider list` 能看到,而且必须支持图片输入。这条路线没有服务端 schema 约束(该 CLI 没有 `--json-schema`),契约是以填好的 JSON 模板随提示词发过去的,能力弱的模型可能只答出一半,遇到就用 `-p gemini-api` 兜底。
146
+
147
+ 有一个实现细节,调试时值得知道:modlens 运行 `kimi` 时把 skill 发现指向了一个空目录。否则 kimi 可能在共享的 skill 目录里找到 modlens skill,然后通过调用 modlens 来读图,也就是 modlens 自己调自己。
135
148
 
136
149
  ### claude-cli(Claude Code 登录态,无需 key)
137
150
 
@@ -8,7 +8,7 @@ shell syntax.
8
8
 
9
9
  ## Pinned version
10
10
 
11
- - Pinned CLI version: 3.16.7
11
+ - Pinned CLI version: 3.17.0
12
12
  - npm package: `@liustack/modlens`
13
13
  - CLI binary name: `modlens`
14
14
 
@@ -24,7 +24,7 @@ $ErrorActionPreference = 'Stop'
24
24
  # package.json version, and the release script rewrites it on every bump.
25
25
  $Package = '@liustack/modlens'
26
26
  $Bin = 'modlens'
27
- $Pinned = '3.16.7'
27
+ $Pinned = '3.17.0'
28
28
  # -------------------------------------------------------------------------------
29
29
 
30
30
  $NativeNote = 'no native artifact is published for this tool yet; phase A ships npm launch paths only'
@@ -22,7 +22,7 @@ set -eu
22
22
  # package.json version, and the release script rewrites it on every bump.
23
23
  PKG="@liustack/modlens"
24
24
  BIN="modlens"
25
- PINNED="3.16.7"
25
+ PINNED="3.17.0"
26
26
  # -------------------------------------------------------------------------------
27
27
 
28
28
  NATIVE_NOTE="no native artifact is published for this tool yet; phase A ships npm launch paths only"