@liustack/modlens 3.16.6 → 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,16 @@
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
+
9
+ ## 3.16.7 - 2026-08-15
10
+
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.
12
+ - **A key can be entered without it touching argv, shell history, or the chat.** `modlens config set gemini-api.apiKey` with the value omitted prompts for it with the echo hidden, and also accepts one piped line. The conversational path is not blocked, because it cannot be: most users hand their key to whatever agent is in front of them, and the onboarding still takes exactly the key they hand over. This is the clean path for the ones who would rather not, and the docs say which boundary each form actually holds.
13
+
3
14
  ## 3.16.6 - 2026-08-15
4
15
 
5
16
  - **A `null` where the contract asks for nothing no longer fails the read ([#37](https://github.com/liustack/modlens/issues/37)).** The reported failure named `visual.notes`, which the validator only reports when the field is present in a shape it does not accept: leaving it out was always fine. `null` is the shape a model reaches for when it has nothing to say, and it is what the reporter's own workaround had to legalize, so that is what this fixes. An optional field holding `null` is now dropped before the check rather than passing through it, which keeps the read alive and keeps `null` out of the fields this contract declares: each one is absent or holds its declared type, as the schema always promised. A key a gateway adds on its own is dropped the same way when it is null. On a required field `null` is still a violation. The error also stopped calling everything missing, since reading `missing: visual.notes` about a field that was right there sends you looking in the wrong place.
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.6`, 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.6`,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.6
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.6
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
@@ -11,6 +11,7 @@ import * as dns from "dns/promises";
11
11
  import { isIP } from "net";
12
12
  import { createRequire } from "module";
13
13
  import * as crypto from "crypto";
14
+ import * as readline from "readline";
14
15
  function denyPatterns(guards) {
15
16
  return stringPatterns(guards?.denyModels);
16
17
  }
@@ -889,10 +890,11 @@ const ANTHROPIC_DEFAULT_MODEL = "claude-haiku-4-5-20251001";
889
890
  const DEFAULT_BASE_URL$1 = "https://api.anthropic.com";
890
891
  const TOOL_NAME = "report_vision_evidence";
891
892
  async function executeAnthropicApi(options) {
893
+ assertNoRetiredEndpointBinding("anthropic", options.settings ?? {});
892
894
  const apiKey = options.settings?.apiKey;
893
895
  if (!apiKey) {
894
896
  throw new Error(
895
- "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"
896
898
  );
897
899
  }
898
900
  const model = options.model || options.settings?.model || ANTHROPIC_DEFAULT_MODEL;
@@ -1200,7 +1202,7 @@ async function executeGeminiApi(options) {
1200
1202
  const apiKey = options.settings?.apiKey;
1201
1203
  if (!apiKey) {
1202
1204
  throw new Error(
1203
- "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)"
1204
1206
  );
1205
1207
  }
1206
1208
  const model = options.model || options.settings?.model || GEMINI_API_DEFAULT_MODEL;
@@ -1285,13 +1287,99 @@ const geminiApiProvider = {
1285
1287
  defaultModel: GEMINI_API_DEFAULT_MODEL,
1286
1288
  execute: executeGeminiApi
1287
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
+ };
1288
1375
  async function executeOpenaiCompat(options) {
1376
+ assertNoRetiredEndpointBinding("openai", options.settings ?? {});
1289
1377
  const apiKey = options.settings?.apiKey;
1290
1378
  const baseUrl = options.settings?.baseUrl?.replace(/\/$/, "");
1291
1379
  const model = options.model || options.settings?.model;
1292
1380
  if (!apiKey || !baseUrl || !model) {
1293
1381
  throw new Error(
1294
- "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>."
1295
1383
  );
1296
1384
  }
1297
1385
  const imageUrl = options.imageKind === "remote" ? options.imageSource : toDataUrl(readLocalImageBase64(options.imageSource));
@@ -1391,7 +1479,10 @@ const PROVIDERS = {
1391
1479
  anthropic: anthropicApiProvider,
1392
1480
  claude: anthropicApiProvider,
1393
1481
  "claude-cli": claudeCliProvider,
1394
- "claude-code": claudeCliProvider
1482
+ "claude-code": claudeCliProvider,
1483
+ "kimi-cli": kimiCliProvider,
1484
+ kimi: kimiCliProvider,
1485
+ "kimi-code": kimiCliProvider
1395
1486
  };
1396
1487
  function resolveProvider(providerName = "antigravity-cli") {
1397
1488
  const normalized = providerName.trim().toLowerCase();
@@ -1420,6 +1511,54 @@ const ENV_BINDINGS = {
1420
1511
  openai: { apiKey: "OPENAI_API_KEY", baseUrl: "OPENAI_BASE_URL" },
1421
1512
  anthropic: { apiKey: "ANTHROPIC_API_KEY", baseUrl: "ANTHROPIC_BASE_URL" }
1422
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
+ }
1423
1562
  function loadConfigFile(configPath = CONFIG_PATH) {
1424
1563
  let raw;
1425
1564
  try {
@@ -1444,23 +1583,15 @@ function loadConfigFile(configPath = CONFIG_PATH) {
1444
1583
  );
1445
1584
  }
1446
1585
  }
1586
+ function providerConfiguredInFile(providerName, config2) {
1587
+ return fileKeysFor(providerName, config2).length > 0;
1588
+ }
1447
1589
  function resolveProviderSettings(providerName, config2, env = process.env) {
1448
- const aliasNames = Object.entries(providerAliases()).filter(([alias, canonical]) => canonical === providerName && alias !== providerName).map(([alias]) => alias);
1449
- const fromFile = {
1450
- ...Object.assign({}, ...aliasNames.map((alias) => config2.providers?.[alias] ?? {})),
1451
- ...config2.providers?.[providerName] ?? {}
1452
- };
1453
- const bindings = ENV_BINDINGS[providerName] ?? {};
1454
- const settings = { ...fromFile };
1590
+ const mentioned = providerConfiguredInFile(providerName, config2);
1591
+ const settings = mentioned ? { ...fileSettingsFor(providerName, config2) } : envSettingsFor(providerName, env);
1455
1592
  if (!settings.proxy && config2.proxy?.trim()) {
1456
1593
  settings.proxy = config2.proxy.trim();
1457
1594
  }
1458
- for (const [field, envName] of Object.entries(bindings)) {
1459
- const value = env[envName]?.trim();
1460
- if (value) {
1461
- settings[field] = value;
1462
- }
1463
- }
1464
1595
  return settings;
1465
1596
  }
1466
1597
  function setConfigValue(dottedKey, value, configPath = CONFIG_PATH) {
@@ -1604,23 +1735,25 @@ function initConfigFile(configPath = CONFIG_PATH, force = false) {
1604
1735
  }
1605
1736
  }
1606
1737
  function renderEffectiveConfig(config2, env = process.env) {
1607
- 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
+ );
1608
1742
  for (const [providerName, bindings] of Object.entries(ENV_BINDINGS)) {
1609
- if (Object.values(bindings).some((envName) => env[envName]?.trim())) {
1743
+ if (Object.values(bindings).some((variable) => env[variable]?.trim())) {
1610
1744
  providerNames.add(providerName);
1611
1745
  }
1612
1746
  }
1613
1747
  const providers = {};
1614
1748
  for (const name of [...providerNames].sort()) {
1615
- const fileSettings = config2.providers?.[name] ?? {};
1616
- 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";
1617
1753
  const fields = {};
1618
1754
  for (const field of STRING_FIELDS) {
1619
- const envName = bindings[field];
1620
- const envValue = envName ? env[envName]?.trim() : void 0;
1621
- const value = envValue ?? fileSettings[field];
1622
- const source = envValue ? "env" : fileSettings[field] !== void 0 ? "file" : null;
1623
- if (value !== void 0 && source) {
1755
+ const value = effective2[field];
1756
+ if (value !== void 0) {
1624
1757
  const shown = field === "apiKey" ? maskKey(value) : field === "proxy" ? maskUrlCredentials(value) : value;
1625
1758
  fields[field] = `${shown} (${source})`;
1626
1759
  }
@@ -1631,7 +1764,7 @@ function renderEffectiveConfig(config2, env = process.env) {
1631
1764
  if (fileSettings.extraBody !== void 0) {
1632
1765
  fields.extraBody = `${JSON.stringify(fileSettings.extraBody)} (file)`;
1633
1766
  }
1634
- if (Object.keys(fields).length > 0) {
1767
+ if (Object.keys(fields).length > 0 || mentioned) {
1635
1768
  providers[name] = fields;
1636
1769
  }
1637
1770
  }
@@ -1688,30 +1821,32 @@ const PROVIDER_DESCRIPTORS = [
1688
1821
  {
1689
1822
  name: "gemini-api",
1690
1823
  kind: "api",
1691
- required: [{ field: "apiKey", env: "GEMINI_API_KEY" }],
1692
- 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"
1693
1826
  },
1694
1827
  {
1695
1828
  name: "openai",
1696
1829
  kind: "api",
1697
- required: [
1698
- { field: "baseUrl", env: "OPENAI_BASE_URL" },
1699
- { field: "apiKey", env: "OPENAI_API_KEY" },
1700
- { field: "model" }
1701
- ],
1702
- 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>"
1703
1832
  },
1704
1833
  {
1705
1834
  name: "anthropic",
1706
1835
  kind: "api",
1707
- required: [{ field: "apiKey", env: "ANTHROPIC_API_KEY" }],
1708
- fix: "modlens config set anthropic.apiKey <key>"
1836
+ required: [{ field: "apiKey" }],
1837
+ fix: "modlens config set anthropic.apiKey # hidden prompt"
1709
1838
  },
1710
1839
  {
1711
1840
  name: "claude-cli",
1712
1841
  kind: "subprocess",
1713
1842
  bin: "claude",
1714
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)"
1715
1850
  }
1716
1851
  ];
1717
1852
  function findOnPath(bin, env) {
@@ -1748,6 +1883,7 @@ const LOCAL_FAILOVER_ORDER = [
1748
1883
  "antigravity-cli",
1749
1884
  "claude-cli"
1750
1885
  ];
1886
+ const PIN_ONLY_PROVIDERS = ["kimi-cli"];
1751
1887
  const REMOTE_FAILOVER_ORDER = ["gemini-api", "openai", "anthropic", "antigravity-cli"];
1752
1888
  function providerChain(kind, config2, env = process.env) {
1753
1889
  let names = [...kind === "remote" ? REMOTE_FAILOVER_ORDER : LOCAL_FAILOVER_ORDER];
@@ -1769,6 +1905,10 @@ function providerChain(kind, config2, env = process.env) {
1769
1905
  names.splice(index, 1);
1770
1906
  names.unshift(canonical);
1771
1907
  }
1908
+ } else if (index === -1 && canonical && PIN_ONLY_PROVIDERS.includes(canonical)) {
1909
+ if (kind === "local") {
1910
+ names.unshift(canonical);
1911
+ }
1772
1912
  }
1773
1913
  }
1774
1914
  return names.filter((name) => providerAvailable(name, config2, env)).map((name) => resolveProvider(name));
@@ -2667,6 +2807,17 @@ async function analyzeImage(options) {
2667
2807
  }
2668
2808
  const config2 = options.config ?? loadConfigFile();
2669
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
+ }
2670
2821
  if (chain.length === 0) {
2671
2822
  throw new Error(
2672
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)
@@ -2714,7 +2865,10 @@ async function analyzeImage(options) {
2714
2865
  result: parsed.result,
2715
2866
  meta: {
2716
2867
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
2717
- 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,
2718
2872
  conversationId: parsed.meta.conversationId,
2719
2873
  durationSeconds: parsed.meta.durationSeconds,
2720
2874
  usage: parsed.meta.usage,
@@ -2917,7 +3071,10 @@ function runCommand(providerName, invocation, timeoutMs, describeFailure) {
2917
3071
  const plan = resolveSpawnPlan(invocation.command, invocation.args);
2918
3072
  const child = spawn(plan.command, plan.args, {
2919
3073
  cwd: invocation.cwd,
2920
- 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 } } : {}
2921
3078
  });
2922
3079
  const outDecoder = new TextDecoder("utf-8");
2923
3080
  const errDecoder = new TextDecoder("utf-8");
@@ -3759,11 +3916,14 @@ function inspectProvider(descriptor, config2, env) {
3759
3916
  };
3760
3917
  }
3761
3918
  const settings = resolveProviderSettings(descriptor.name, config2, env);
3919
+ const settingsSource = providerConfiguredInFile(descriptor.name, config2) ? "file" : "env";
3762
3920
  const statuses = (descriptor.required ?? []).map((req) => {
3763
- const envValue = req.env ? env[req.env]?.trim() : void 0;
3764
3921
  const value = settings[req.field]?.trim();
3765
- const source = envValue ? "env" : value ? "file" : "missing";
3766
- 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
+ };
3767
3927
  });
3768
3928
  const missing = statuses.filter((s) => !s.present).map((s) => s.field);
3769
3929
  const ready = missing.length === 0;
@@ -4152,6 +4312,53 @@ function recoverPastedImages(options = {}) {
4152
4312
  }
4153
4313
  return result;
4154
4314
  }
4315
+ async function readSecret(promptText, stdin = process.stdin, stderr = process.stderr) {
4316
+ if (!stdin.isTTY) {
4317
+ stdin.setEncoding("utf8");
4318
+ let data = "";
4319
+ for await (const chunk of stdin) {
4320
+ data += chunk;
4321
+ if (data.includes("\n")) {
4322
+ break;
4323
+ }
4324
+ }
4325
+ const value = data.split("\n")[0].trim();
4326
+ if (value === "") {
4327
+ throw new Error("no key arrived on stdin (pipe one line, or run on a terminal)");
4328
+ }
4329
+ return value;
4330
+ }
4331
+ const rl = readline.createInterface({ input: stdin, output: stderr, terminal: true });
4332
+ const muted = rl;
4333
+ stderr.write(promptText);
4334
+ muted._writeToOutput = () => {
4335
+ };
4336
+ try {
4337
+ const value = await new Promise((resolve, reject) => {
4338
+ let settled = false;
4339
+ const settle = (action) => {
4340
+ if (!settled) {
4341
+ settled = true;
4342
+ action();
4343
+ }
4344
+ };
4345
+ rl.question("", (answer) => settle(() => resolve(answer)));
4346
+ rl.on("SIGINT", () => settle(() => reject(new Error("cancelled, nothing was saved"))));
4347
+ rl.on(
4348
+ "close",
4349
+ () => settle(() => reject(new Error("input ended before a key was entered")))
4350
+ );
4351
+ });
4352
+ const trimmed = value.trim();
4353
+ if (trimmed === "") {
4354
+ throw new Error("no key entered");
4355
+ }
4356
+ return trimmed;
4357
+ } finally {
4358
+ stderr.write("\n");
4359
+ rl.close();
4360
+ }
4361
+ }
4155
4362
  const program = new Command();
4156
4363
  function parsePositiveInt(raw, flag) {
4157
4364
  if (!/^\d+$/.test(raw.trim()) || Number.parseInt(raw, 10) <= 0) {
@@ -4159,7 +4366,7 @@ function parsePositiveInt(raw, flag) {
4159
4366
  }
4160
4367
  return Number.parseInt(raw, 10);
4161
4368
  }
4162
- program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.16.6");
4369
+ program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.17.0");
4163
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(
4164
4371
  "--extra-body <json>",
4165
4372
  `JSON merged into the API request body, e.g. '{"thinking":{"type":"disabled"}}'`
@@ -4269,7 +4476,7 @@ program.command("doctor").description(
4269
4476
  configPath: CONFIG_PATH,
4270
4477
  // Lets doctor name an installed skill copy that is older than
4271
4478
  // the CLI reporting on it (issue #33).
4272
- version: "3.16.6"
4479
+ version: "3.17.0"
4273
4480
  });
4274
4481
  const output = options.json ? JSON.stringify(report, null, 2) : renderDoctorReport(report);
4275
4482
  process.stdout.write(`${output}
@@ -4304,9 +4511,18 @@ config.command("init").description(`Create a starter config at ${CONFIG_PATH}`).
4304
4511
  process.exitCode = 1;
4305
4512
  }
4306
4513
  });
4307
- config.command("set <key> <value>").description("Set a value, e.g. modlens config set gemini-api.apiKey <key>").action((key, value) => {
4514
+ config.command("set <key> [value]").description(
4515
+ "Set a value. Omit the value for an apiKey to enter it at a hidden prompt (out of argv and shell history), or to read one piped line (out of argv; the command feeding the pipe is yours to keep out of history)"
4516
+ ).action(async (key, value) => {
4308
4517
  try {
4309
- setConfigValue(key, value);
4518
+ let resolved = value;
4519
+ if (resolved === void 0) {
4520
+ if (!key.endsWith(".apiKey")) {
4521
+ throw new Error(`${key} needs a value: modlens config set ${key} <value>`);
4522
+ }
4523
+ resolved = await readSecret(`${key} (input hidden): `);
4524
+ }
4525
+ setConfigValue(key, resolved);
4310
4526
  process.stdout.write(`Saved ${key} to ${CONFIG_PATH}
4311
4527
  `);
4312
4528
  } catch (error) {
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,11 +93,11 @@ 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
 
99
100
  - `modlens guard [--model <id>]`: should the engine run for the active model at all? Exit 0 allow, 1 deny, verdict as JSON.
100
- - `modlens config <init|set|show>`: keys are `provider`, `proxy` (HTTP/HTTPS proxy for the API providers, `HTTPS_PROXY`/`HTTP_PROXY` also honored), `reuse.<claude|codex|opencode|pi|grok>`, `guards.<denyModels|allowModels|denyWhenUnknown>`, and `<provider>.<apiKey|baseUrl|model|proxy|extraBody>`, plus `openai.structuredOutput` (that route only).
101
+ - `modlens config <init|set|show>`: `set` with the value omitted on an `apiKey` field prompts for it with the echo hidden, so the key never enters argv, shell history, or the conversation with an agent driving the terminal. It also accepts one piped line (`pbpaste | modlens config set openai.apiKey`), which keeps the key out of argv, though whatever produced that pipe is still your own command to keep out of history. Keys are `provider`, `proxy` (HTTP/HTTPS proxy for the API providers, `HTTPS_PROXY`/`HTTP_PROXY` also honored), `reuse.<claude|codex|opencode|pi|grok>`, `guards.<denyModels|allowModels|denyWhenUnknown>`, and `<provider>.<apiKey|baseUrl|model|proxy|extraBody>`, plus `openai.structuredOutput` (that route only).
101
102
  - `modlens doctor`: Node and node:sqlite, provider readiness, the failover chains for this machine, the detected harness, the guard's rules with a live verdict, and the Reuse section with per-harness grant decisions and discovered vision. Spends no quota; `--json` for a machine-readable report.
102
103
 
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,10 +90,10 @@ 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
 
96
97
  - `modlens guard [--model <id>]`:判断当前激活的模型到底该不该运行引擎。退出码 0 表示放行,1 表示拒绝,判定结果以 JSON 输出。
97
- - `modlens config <init|set|show>`:可用的键有 `provider`、`proxy`(API provider 的 HTTP/HTTPS 代理,也认 `HTTPS_PROXY`/`HTTP_PROXY`)、`reuse.<claude|codex|opencode|pi|grok>`、`guards.<denyModels|allowModels|denyWhenUnknown>`,以及 `<provider>.<apiKey|baseUrl|model|proxy|extraBody>`,另有 `openai.structuredOutput`(仅这条路线用得上)。
98
+ - `modlens config <init|set|show>`:`set` 在 `apiKey` 字段上省略值会进入不回显的输入提示,密钥不进 argv、不进 shell 历史,也不进你与终端里那个 agent 的对话。它也接受从管道读一行(`pbpaste | modlens config set openai.apiKey`),这样密钥不进 argv,但产生这条管道的命令本身是否留在历史里,由你自己决定。可用的键有 `provider`、`proxy`(API provider 的 HTTP/HTTPS 代理,也认 `HTTPS_PROXY`/`HTTP_PROXY`)、`reuse.<claude|codex|opencode|pi|grok>`、`guards.<denyModels|allowModels|denyWhenUnknown>`,以及 `<provider>.<apiKey|baseUrl|model|proxy|extraBody>`,另有 `openai.structuredOutput`(仅这条路线用得上)。
98
99
  - `modlens doctor`:报告 Node 与 node:sqlite、各 provider 的就绪状态、本机的故障转移链、检测到的 harness、guard 规则和一次现场判定,以及 Reuse 一节里按 harness 的授权决定与发现的视觉能力。不花任何额度,`--json` 输出机器可读报告。