@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/dsh/index.js CHANGED
@@ -11,7 +11,9 @@
11
11
  // package.json `dsh.bundle` manifest). Providers, reuse grants, and guard
12
12
  // rules keep living in ~/.modlens/config.json, shared with every harness.
13
13
  import { spawn } from 'node:child_process'
14
- import { readFileSync } from 'node:fs'
14
+ import { chmodSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
15
+ import { homedir } from 'node:os'
16
+ import { dirname, join } from 'node:path'
15
17
  import { fileURLToPath } from 'node:url'
16
18
 
17
19
  const CLI_PATH = fileURLToPath(new URL('../dist/main.js', import.meta.url))
@@ -59,14 +61,29 @@ export function apply(ctx, config = {}) {
59
61
  // optional-inject form, so the route rides a scoped ctx.inject: the closure
60
62
  // runs when the service appears and never runs where it does not (headless
61
63
  // stays untouched, and the plugin itself never waits on it).
62
- if (config.pasteToPath !== false && typeof ctx.inject === 'function') {
64
+ if (typeof ctx.inject === 'function') {
63
65
  ctx.inject(['webServer'], (scope) => {
64
- try {
65
- // scope carries webServer; the plugin's own ctx carries llm for the
66
- // takeover verdicts.
67
- registerPasteRoute(scope, ctx, ownProviders)
68
- } catch (error) {
69
- console.error(`[modlens] paste-to-path route skipped: ${error}`)
66
+ if (config.pasteToPath !== false) {
67
+ try {
68
+ // scope carries webServer; the plugin's own ctx carries llm for the
69
+ // takeover verdicts.
70
+ registerPasteRoute(scope, ctx, ownProviders)
71
+ } catch (error) {
72
+ console.error(`[modlens] paste-to-path route skipped: ${error}`)
73
+ }
74
+ }
75
+ // Same web server, a separate switch: turning paste-to-path off is a
76
+ // statement about how images enter, not about whether the engine can
77
+ // be configured. dsh's own settings surface renders a hardcoded set of
78
+ // cards and does not enumerate namespaces, so the card the browser half
79
+ // contributes talks to this route rather than to a settings schema
80
+ // (issue #39).
81
+ if (config.settingsCard !== false) {
82
+ try {
83
+ registerConfigRoute(scope)
84
+ } catch (error) {
85
+ console.error(`[modlens] settings card route skipped: ${error}`)
86
+ }
70
87
  }
71
88
  })
72
89
  }
@@ -850,3 +867,404 @@ function renderEvidence(value) {
850
867
  }
851
868
  return lines.join('\n')
852
869
  }
870
+
871
+ // The engines a user can pick in the settings card, in the order the docs
872
+ // introduce them. Kept to the names modlens itself uses so the card and
873
+ // `modlens doctor` say the same words.
874
+ const ENGINES = ['antigravity-cli', 'gemini-api', 'openai', 'anthropic', 'claude-cli']
875
+ // The two CLI engines sign in through their own tool, so a key or an endpoint
876
+ // would be a field with nothing behind it. Both still take a model.
877
+ const KEYLESS_ENGINES = ['antigravity-cli', 'claude-cli']
878
+ // Every accepted spelling, mirroring src/providers/index.ts. Settings saved
879
+ // under an alias are the same engine's settings, and a provider pinned by an
880
+ // alias is pinned to that engine: showing either as something else would put
881
+ // the card at odds with what actually reads the images.
882
+ const ENGINE_ALIASES = {
883
+ antigravity: 'antigravity-cli',
884
+ agy: 'antigravity-cli',
885
+ gemini: 'gemini-api',
886
+ 'openai-compat': 'openai',
887
+ claude: 'anthropic',
888
+ 'claude-code': 'claude-cli',
889
+ }
890
+
891
+ /** The canonical engine a stored name means, or '' when it names none. */
892
+ function canonicalEngine(name) {
893
+ if (typeof name !== 'string') return ''
894
+ const trimmed = name.trim().toLowerCase()
895
+ if (ENGINES.includes(trimmed)) return trimmed
896
+ return ENGINE_ALIASES[trimmed] ?? ''
897
+ }
898
+
899
+ /** The config keys holding one engine's settings: its own, plus its aliases. */
900
+ function settingsKeysFor(engine) {
901
+ const aliases = Object.keys(ENGINE_ALIASES).filter((alias) => ENGINE_ALIASES[alias] === engine)
902
+ return [...aliases, engine]
903
+ }
904
+ // Auto mode: the local harnesses whose logins a read may borrow. `claude`
905
+ // absent counts as granted, since claude-cli predates the grant model.
906
+ const REUSE_HARNESSES = ['claude', 'codex', 'opencode', 'pi', 'grok']
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
+
936
+ /** ~/.modlens/config.json, the one file every harness shares. */
937
+ function modlensConfigPath() {
938
+ return join(homedir(), '.modlens', 'config.json')
939
+ }
940
+
941
+ /**
942
+ * The shared config, or a thrown error. Only a missing file reads as empty:
943
+ * a file that exists but cannot be parsed or read is somebody's configuration,
944
+ * and a settings card that treated it as empty would overwrite it on the next
945
+ * save. The card shows the error instead.
946
+ */
947
+ function readModlensConfig() {
948
+ let raw
949
+ try {
950
+ raw = readFileSync(modlensConfigPath(), 'utf8')
951
+ } catch (error) {
952
+ if (error?.code === 'ENOENT') return {}
953
+ throw new Error(`cannot read ${modlensConfigPath()}: ${error?.message ?? error}`)
954
+ }
955
+ let parsed
956
+ try {
957
+ parsed = JSON.parse(raw)
958
+ } catch (error) {
959
+ throw new Error(`${modlensConfigPath()} is not valid JSON: ${error?.message ?? error}`)
960
+ }
961
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
962
+ throw new Error(`${modlensConfigPath()} does not hold a JSON object`)
963
+ }
964
+ return parsed
965
+ }
966
+
967
+ /**
968
+ * What the card is allowed to know. Every engine's endpoint and model, plus
969
+ * whether a key is stored, and never the key itself: a browser that cannot
970
+ * read a secret cannot leak one, and cannot write it back either.
971
+ */
972
+ function engineSummary(config = readModlensConfig()) {
973
+ const engines = {}
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)
980
+ // Alias first, canonical last: the canonical key wins on conflict, the
981
+ // same order resolveProviderSettings uses.
982
+ const settings = inFile
983
+ ? Object.assign({}, ...settingsKeysFor(name).map((key) => config.providers?.[key] ?? {}))
984
+ : engineEnvSettings(name)
985
+ engines[name] = {
986
+ baseUrl: typeof settings.baseUrl === 'string' ? settings.baseUrl : '',
987
+ model: typeof settings.model === 'string' ? settings.model : '',
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' : '',
992
+ }
993
+ }
994
+ const reuse = {}
995
+ for (const harness of REUSE_HARNESSES) {
996
+ const granted = config.reuse?.[harness]
997
+ reuse[harness] = typeof granted === 'boolean' ? granted : harness === 'claude'
998
+ }
999
+ // Three states, kept apart: pinned to an engine, pinned by one of its
1000
+ // aliases (reported canonically), or not pinned at all, which is its own
1001
+ // answer and means the failover chain decides. Collapsing the third into
1002
+ // the first pins an engine the user never chose.
1003
+ return {
1004
+ provider: canonicalEngine(config.provider),
1005
+ engines,
1006
+ keyless: KEYLESS_ENGINES,
1007
+ reuse,
1008
+ }
1009
+ }
1010
+
1011
+ /**
1012
+ * Apply one card submission to the shared file. Only the named engine's own
1013
+ * three fields are touched, so switching engines in the card cannot copy one
1014
+ * engine's endpoint onto another. An absent or empty `apiKey` leaves the
1015
+ * stored one alone: the card never receives a key, so it must never be able
1016
+ * to clear one by submitting the blank field it was shown.
1017
+ */
1018
+ function applyEngineSettings(patch) {
1019
+ const config = readModlensConfig()
1020
+ // The pin moves only when the card says it moved. A save that carried the
1021
+ // currently displayed engine regardless turned "not pinned" into a pin on
1022
+ // whatever happened to be shown, changing which engine reads every later
1023
+ // image without the user asking for it.
1024
+ if (patch?.provider !== undefined) {
1025
+ if (patch.provider === '') {
1026
+ delete config.provider
1027
+ } else if (ENGINES.includes(patch.provider)) {
1028
+ config.provider = patch.provider
1029
+ } else {
1030
+ throw new Error(`unknown engine: ${patch.provider}`)
1031
+ }
1032
+ }
1033
+ // Engine fields are edited one engine at a time, named by `engine`. Absent
1034
+ // means this save touched no engine settings, which is what a reuse-only
1035
+ // save looks like.
1036
+ const engine = patch?.engine
1037
+ if (engine !== undefined) {
1038
+ if (!ENGINES.includes(engine)) {
1039
+ throw new Error(`unknown engine: ${engine}`)
1040
+ }
1041
+ config.providers = { ...config.providers }
1042
+ // Write where this engine's settings already live, so a key saved under
1043
+ // an alias is updated rather than shadowed by a second copy.
1044
+ // Write where the read takes effect. settingsKeysFor merges aliases
1045
+ // first and the canonical key last, so the canonical value wins; picking
1046
+ // the first existing key instead wrote a new value underneath an older
1047
+ // canonical one, which saved successfully and changed nothing. Both CLI
1048
+ // spellings existing at once is ordinary: `config set gemini.apiKey`
1049
+ // then `config set gemini-api.apiKey` leaves exactly that.
1050
+ const holders = settingsKeysFor(engine).filter((key) => config.providers[key] !== undefined)
1051
+ const target = holders.length > 0 ? holders[holders.length - 1] : engine
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] }
1059
+ for (const field of ['baseUrl', 'model']) {
1060
+ const value = typeof patch[field] === 'string' ? patch[field].trim() : ''
1061
+ if (value === '') {
1062
+ delete settings[field]
1063
+ } else {
1064
+ settings[field] = value
1065
+ }
1066
+ }
1067
+ const apiKey = typeof patch.apiKey === 'string' ? patch.apiKey.trim() : ''
1068
+ if (apiKey !== '') {
1069
+ settings.apiKey = apiKey
1070
+ }
1071
+ config.providers[target] = settings
1072
+ }
1073
+ // Auto mode, when the card sent it: only the harnesses this build knows,
1074
+ // only booleans, so an unexpected key cannot land in the shared file.
1075
+ if (patch?.reuse !== null && typeof patch?.reuse === 'object') {
1076
+ config.reuse = { ...config.reuse }
1077
+ for (const harness of REUSE_HARNESSES) {
1078
+ const granted = patch.reuse[harness]
1079
+ if (typeof granted === 'boolean') {
1080
+ config.reuse[harness] = granted
1081
+ }
1082
+ }
1083
+ }
1084
+ const file = modlensConfigPath()
1085
+ // A symlink here would write through to wherever it points, so it is
1086
+ // refused rather than followed: the CLI writes a real file, and anything
1087
+ // else is a setup this card should not silently honor.
1088
+ try {
1089
+ if (lstatSync(file).isSymbolicLink()) {
1090
+ throw new Error(`${file} is a symlink; edit the file it points at instead`)
1091
+ }
1092
+ } catch (error) {
1093
+ if (error?.code !== 'ENOENT') throw error
1094
+ }
1095
+ mkdirSync(dirname(file), { recursive: true })
1096
+ writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 })
1097
+ try {
1098
+ chmodSync(file, 0o600)
1099
+ } catch {
1100
+ // Windows has no POSIX bits; the mode on the write above is all there is.
1101
+ }
1102
+ }
1103
+
1104
+ /**
1105
+ * GET /modlens/config: the engine summary above. POST: one submission.
1106
+ *
1107
+ * The dsh web server listens on loopback, but a page in the same browser can
1108
+ * still reach it, so a write requires a same-origin request: a cross-site POST
1109
+ * could otherwise repoint someone's engine at an endpoint of its choosing.
1110
+ * A read is refused the same way for symmetry, though it carries no secret.
1111
+ */
1112
+ /**
1113
+ * The self-check behind the card's auto-mode section: which local harnesses
1114
+ * exist to be borrowed at all. `doctor --json` already probes them without
1115
+ * network or quota, so the route spawns the CLI this package ships and lifts
1116
+ * its reuse section. Cached briefly, since one probe can take a second and
1117
+ * re-expanding the card should not re-pay it.
1118
+ */
1119
+ const DISCOVERY_TTL_MS = 60_000
1120
+ let discoveryCache = null
1121
+ async function discoverReuse() {
1122
+ if (discoveryCache !== null && Date.now() - discoveryCache.at < DISCOVERY_TTL_MS) {
1123
+ return discoveryCache.value
1124
+ }
1125
+ try {
1126
+ const { stdout, code } = await run(process.execPath, [CLI_PATH, 'doctor', '--json'], AbortSignal.timeout(30_000))
1127
+ if (code !== 0) return null
1128
+ const reuse = JSON.parse(stdout)?.reuse
1129
+ if (!reuse || !Array.isArray(reuse.probes)) return null
1130
+ // doctor names the harness claude-code; the grant key is claude.
1131
+ const probes = reuse.probes.map((probe) => ({
1132
+ harness: probe.harness === 'claude-code' ? 'claude' : probe.harness,
1133
+ cliFound: probe.cliFound === true,
1134
+ loggedIn: probe.loggedIn,
1135
+ cliPath: typeof probe.cliPath === 'string' ? probe.cliPath : '',
1136
+ }))
1137
+ discoveryCache = { at: Date.now(), value: probes }
1138
+ return probes
1139
+ } catch {
1140
+ return null
1141
+ }
1142
+ }
1143
+
1144
+ /**
1145
+ * Open the shared config file in whatever the OS considers its editor. The
1146
+ * card's "open config file" link lands here: the path never has to be
1147
+ * explained to the user, they just get the file. Created empty first when
1148
+ * missing, so the editor has something to open.
1149
+ */
1150
+ function openConfigFile() {
1151
+ const file = modlensConfigPath()
1152
+ try {
1153
+ lstatSync(file)
1154
+ } catch {
1155
+ mkdirSync(dirname(file), { recursive: true })
1156
+ writeFileSync(file, '{}\n', { mode: 0o600 })
1157
+ }
1158
+ const [command, args] =
1159
+ process.platform === 'darwin'
1160
+ ? ['open', [file]]
1161
+ : process.platform === 'win32'
1162
+ ? ['cmd', ['/c', 'start', '', file]]
1163
+ : ['xdg-open', [file]]
1164
+ spawn(command, args, { detached: true, stdio: 'ignore' }).unref()
1165
+ }
1166
+
1167
+ /** localhost, ::1, or anything in 127/8, matching dsh's own /api fence. */
1168
+ function isLoopbackHost(hostname) {
1169
+ if (hostname === 'localhost' || hostname === '[::1]') return true
1170
+ const parts = hostname.split('.')
1171
+ return (
1172
+ parts.length === 4 && parts[0] === '127' && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)
1173
+ )
1174
+ }
1175
+
1176
+ /**
1177
+ * The same fence dsh puts in front of its own /api, for the same two
1178
+ * confused-deputy paths. Host is the header DNS rebinding cannot forge, so it
1179
+ * must name a loopback authority: a rebound page reaches this socket carrying
1180
+ * its own domain there. Origin and Sec-Fetch-Site then rule out a cross-site
1181
+ * page on the machine itself. A dsh serving a LAN address configures
1182
+ * trustedHosts for /api; this route stays loopback-only, since nothing about
1183
+ * editing an API key wants a wider door.
1184
+ */
1185
+ function isTrustedRequest(req) {
1186
+ const host = req.headers?.host
1187
+ if (typeof host !== 'string' || host === '') return false
1188
+ let hostUrl
1189
+ try {
1190
+ hostUrl = new URL(`http://${host}`)
1191
+ } catch {
1192
+ return false
1193
+ }
1194
+ if (!isLoopbackHost(hostUrl.hostname)) return false
1195
+ if (req.headers?.['sec-fetch-site'] === 'cross-site') return false
1196
+ const origin = req.headers?.origin
1197
+ if (origin === undefined) return true
1198
+ try {
1199
+ return new URL(origin).host === hostUrl.host
1200
+ } catch {
1201
+ return false
1202
+ }
1203
+ }
1204
+
1205
+ function registerConfigRoute(ctx) {
1206
+ ctx.webServer.register({
1207
+ name: 'modlens-config',
1208
+ kind: 'exact',
1209
+ path: '/modlens/config',
1210
+ handler: async (req, res) => {
1211
+ const send = (status, body) => {
1212
+ res.writeHead(status, { 'content-type': 'application/json' })
1213
+ res.end(JSON.stringify(body))
1214
+ }
1215
+ if (!isTrustedRequest(req)) {
1216
+ send(403, { error: 'request refused: this route answers same-origin loopback only' })
1217
+ return
1218
+ }
1219
+ if (req.method === 'GET') {
1220
+ try {
1221
+ const summary = engineSummary()
1222
+ const wantsDiscovery = new URL(req.url, 'http://localhost').searchParams.has('discover')
1223
+ if (wantsDiscovery) {
1224
+ // null when the probe failed: the card then falls back to the
1225
+ // plain grant list rather than showing nothing.
1226
+ summary.discovery = await discoverReuse()
1227
+ }
1228
+ send(200, summary)
1229
+ } catch (error) {
1230
+ send(409, { error: String(error?.message ?? error) })
1231
+ }
1232
+ return
1233
+ }
1234
+ if (req.method !== 'POST') {
1235
+ res.writeHead(405).end()
1236
+ return
1237
+ }
1238
+ try {
1239
+ const chunks = []
1240
+ let total = 0
1241
+ for await (const chunk of req) {
1242
+ total += chunk.length
1243
+ if (total > 64 * 1024) {
1244
+ send(413, { error: 'config payload too large' })
1245
+ req.destroy()
1246
+ return
1247
+ }
1248
+ chunks.push(chunk)
1249
+ }
1250
+ const patch = JSON.parse(Buffer.concat(chunks).toString('utf8'))
1251
+ // The card's "open config file" link: an action, not a setting.
1252
+ if (patch?.open === true) {
1253
+ openConfigFile()
1254
+ send(200, { opened: true })
1255
+ return
1256
+ }
1257
+ applyEngineSettings(patch)
1258
+ send(200, engineSummary())
1259
+ } catch (error) {
1260
+ send(400, { error: String(error?.message ?? error) })
1261
+ }
1262
+ },
1263
+ })
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.6",
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.6):
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.6: `modlens <args>`.
26
- 2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.16.6 modlens <args>`.
27
- 3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.16.6 <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
 
@@ -93,9 +93,12 @@ Any free Google account works; no Google AI Pro needed. Sign-in cannot be automa
93
93
 
94
94
  ```bash
95
95
  modlens config set gemini-api.apiKey <key>
96
- # or environment: export GEMINI_API_KEY=<key>
96
+ # value omitted: a hidden prompt, so the key skips argv, shell history, and this chat
97
+ modlens config set gemini-api.apiKey
97
98
  ```
98
99
 
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.
101
+
99
102
  Default model `gemini-3.6-flash` has vision on the free tier (about 10-15 requests/min, 1500/day). Free-tier data may be used by Google to improve products; mention this if the user handles sensitive images.
100
103
 
101
104
  ### openai (any OpenAI-compatible multimodal endpoint)
@@ -108,7 +111,7 @@ modlens config set openai.apiKey <sk-key>
108
111
  modlens config set openai.model qwen3.6-27b
109
112
  ```
110
113
 
111
- 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.
112
115
 
113
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:
114
117
 
@@ -122,12 +125,35 @@ The contract goes out as `response_format: json_schema` in strict form, derived
122
125
 
123
126
  ```bash
124
127
  modlens config set anthropic.apiKey <sk-ant-key>
125
- # or: export ANTHROPIC_API_KEY=<key>
126
128
  ```
127
129
 
128
130
  Default model is Claude Haiku (`claude-haiku-4-5-20251001`). Schema is enforced through a forced tool call.
129
131
 
130
- **`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.
131
157
 
132
158
  ### claude-cli (Claude Code login, no key)
133
159