@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 +11 -0
- package/README.md +4 -3
- package/README.zh-CN.md +5 -4
- package/dist/main.js +262 -46
- package/docs/cli.md +4 -3
- package/docs/cli.zh-CN.md +4 -3
- package/docs/harness-setup.md +32 -2
- package/docs/harness-setup.zh-CN.md +14 -2
- package/docs/security.md +2 -2
- package/docs/security.zh-CN.md +2 -2
- package/docs/troubleshooting.md +2 -2
- package/docs/troubleshooting.zh-CN.md +2 -2
- package/dsh/client.js +634 -1
- package/dsh/index.js +426 -8
- package/package.json +1 -1
- package/skills/modlens/SKILL.md +4 -4
- package/skills/modlens/references/configure.md +34 -8
- package/skills/modlens/references/configure.zh-CN.md +25 -8
- package/skills/modlens/references/onboard.md +1 -1
- package/skills/modlens/references/runtime.md +1 -1
- package/skills/modlens/scripts/run.ps1 +1 -1
- package/skills/modlens/scripts/run.sh +1 -1
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.
|
|
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:
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
##
|
|
81
|
+
## 视觉引擎:六个内置 provider,四家可复用 CLI,一条故障转移链
|
|
82
82
|
|
|
83
|
-
ModLens
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
1449
|
-
const
|
|
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
|
|
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((
|
|
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 =
|
|
1616
|
-
const
|
|
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
|
|
1620
|
-
|
|
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"
|
|
1692
|
-
fix: "modlens config set gemini-api.apiKey
|
|
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
|
-
|
|
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"
|
|
1708
|
-
fix: "modlens config set anthropic.apiKey
|
|
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
|
-
|
|
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
|
-
|
|
3766
|
-
|
|
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.
|
|
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.
|
|
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>
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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>`:
|
|
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
|
|
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
|
-
|
|
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
|
|
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` 输出机器可读报告。
|