@liustack/modlens 3.16.6 → 3.16.7
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 +5 -0
- package/README.md +1 -1
- package/README.zh-CN.md +2 -2
- package/dist/main.js +61 -4
- package/docs/cli.md +1 -1
- package/docs/cli.zh-CN.md +1 -1
- package/docs/harness-setup.md +32 -2
- package/docs/harness-setup.zh-CN.md +14 -2
- package/docs/troubleshooting.md +2 -2
- package/docs/troubleshooting.zh-CN.md +2 -2
- package/dsh/client.js +612 -1
- package/dsh/index.js +376 -8
- package/package.json +1 -1
- package/skills/modlens/SKILL.md +4 -4
- package/skills/modlens/references/configure.md +4 -0
- package/skills/modlens/references/configure.zh-CN.md +4 -0
- 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/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 (
|
|
64
|
+
if (typeof ctx.inject === 'function') {
|
|
63
65
|
ctx.inject(['webServer'], (scope) => {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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,354 @@ 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
|
+
/** ~/.modlens/config.json, the one file every harness shares. */
|
|
909
|
+
function modlensConfigPath() {
|
|
910
|
+
return join(homedir(), '.modlens', 'config.json')
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* The shared config, or a thrown error. Only a missing file reads as empty:
|
|
915
|
+
* a file that exists but cannot be parsed or read is somebody's configuration,
|
|
916
|
+
* and a settings card that treated it as empty would overwrite it on the next
|
|
917
|
+
* save. The card shows the error instead.
|
|
918
|
+
*/
|
|
919
|
+
function readModlensConfig() {
|
|
920
|
+
let raw
|
|
921
|
+
try {
|
|
922
|
+
raw = readFileSync(modlensConfigPath(), 'utf8')
|
|
923
|
+
} catch (error) {
|
|
924
|
+
if (error?.code === 'ENOENT') return {}
|
|
925
|
+
throw new Error(`cannot read ${modlensConfigPath()}: ${error?.message ?? error}`)
|
|
926
|
+
}
|
|
927
|
+
let parsed
|
|
928
|
+
try {
|
|
929
|
+
parsed = JSON.parse(raw)
|
|
930
|
+
} catch (error) {
|
|
931
|
+
throw new Error(`${modlensConfigPath()} is not valid JSON: ${error?.message ?? error}`)
|
|
932
|
+
}
|
|
933
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
934
|
+
throw new Error(`${modlensConfigPath()} does not hold a JSON object`)
|
|
935
|
+
}
|
|
936
|
+
return parsed
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* What the card is allowed to know. Every engine's endpoint and model, plus
|
|
941
|
+
* whether a key is stored, and never the key itself: a browser that cannot
|
|
942
|
+
* read a secret cannot leak one, and cannot write it back either.
|
|
943
|
+
*/
|
|
944
|
+
function engineSummary(config = readModlensConfig()) {
|
|
945
|
+
const engines = {}
|
|
946
|
+
for (const name of ENGINES) {
|
|
947
|
+
// Alias first, canonical last: the canonical key wins on conflict, the
|
|
948
|
+
// same order resolveProviderSettings uses.
|
|
949
|
+
const settings = Object.assign({}, ...settingsKeysFor(name).map((key) => config.providers?.[key] ?? {}))
|
|
950
|
+
engines[name] = {
|
|
951
|
+
baseUrl: typeof settings.baseUrl === 'string' ? settings.baseUrl : '',
|
|
952
|
+
model: typeof settings.model === 'string' ? settings.model : '',
|
|
953
|
+
hasKey: typeof settings.apiKey === 'string' && settings.apiKey !== '',
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
const reuse = {}
|
|
957
|
+
for (const harness of REUSE_HARNESSES) {
|
|
958
|
+
const granted = config.reuse?.[harness]
|
|
959
|
+
reuse[harness] = typeof granted === 'boolean' ? granted : harness === 'claude'
|
|
960
|
+
}
|
|
961
|
+
// Three states, kept apart: pinned to an engine, pinned by one of its
|
|
962
|
+
// aliases (reported canonically), or not pinned at all, which is its own
|
|
963
|
+
// answer and means the failover chain decides. Collapsing the third into
|
|
964
|
+
// the first pins an engine the user never chose.
|
|
965
|
+
return {
|
|
966
|
+
provider: canonicalEngine(config.provider),
|
|
967
|
+
engines,
|
|
968
|
+
keyless: KEYLESS_ENGINES,
|
|
969
|
+
reuse,
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* Apply one card submission to the shared file. Only the named engine's own
|
|
975
|
+
* three fields are touched, so switching engines in the card cannot copy one
|
|
976
|
+
* engine's endpoint onto another. An absent or empty `apiKey` leaves the
|
|
977
|
+
* stored one alone: the card never receives a key, so it must never be able
|
|
978
|
+
* to clear one by submitting the blank field it was shown.
|
|
979
|
+
*/
|
|
980
|
+
function applyEngineSettings(patch) {
|
|
981
|
+
const config = readModlensConfig()
|
|
982
|
+
// The pin moves only when the card says it moved. A save that carried the
|
|
983
|
+
// currently displayed engine regardless turned "not pinned" into a pin on
|
|
984
|
+
// whatever happened to be shown, changing which engine reads every later
|
|
985
|
+
// image without the user asking for it.
|
|
986
|
+
if (patch?.provider !== undefined) {
|
|
987
|
+
if (patch.provider === '') {
|
|
988
|
+
delete config.provider
|
|
989
|
+
} else if (ENGINES.includes(patch.provider)) {
|
|
990
|
+
config.provider = patch.provider
|
|
991
|
+
} else {
|
|
992
|
+
throw new Error(`unknown engine: ${patch.provider}`)
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
// Engine fields are edited one engine at a time, named by `engine`. Absent
|
|
996
|
+
// means this save touched no engine settings, which is what a reuse-only
|
|
997
|
+
// save looks like.
|
|
998
|
+
const engine = patch?.engine
|
|
999
|
+
if (engine !== undefined) {
|
|
1000
|
+
if (!ENGINES.includes(engine)) {
|
|
1001
|
+
throw new Error(`unknown engine: ${engine}`)
|
|
1002
|
+
}
|
|
1003
|
+
config.providers = { ...config.providers }
|
|
1004
|
+
// Write where this engine's settings already live, so a key saved under
|
|
1005
|
+
// an alias is updated rather than shadowed by a second copy.
|
|
1006
|
+
// Write where the read takes effect. settingsKeysFor merges aliases
|
|
1007
|
+
// first and the canonical key last, so the canonical value wins; picking
|
|
1008
|
+
// the first existing key instead wrote a new value underneath an older
|
|
1009
|
+
// canonical one, which saved successfully and changed nothing. Both CLI
|
|
1010
|
+
// spellings existing at once is ordinary: `config set gemini.apiKey`
|
|
1011
|
+
// then `config set gemini-api.apiKey` leaves exactly that.
|
|
1012
|
+
const holders = settingsKeysFor(engine).filter((key) => config.providers[key] !== undefined)
|
|
1013
|
+
const target = holders.length > 0 ? holders[holders.length - 1] : engine
|
|
1014
|
+
const settings = { ...config.providers[target] }
|
|
1015
|
+
for (const field of ['baseUrl', 'model']) {
|
|
1016
|
+
const value = typeof patch[field] === 'string' ? patch[field].trim() : ''
|
|
1017
|
+
if (value === '') {
|
|
1018
|
+
delete settings[field]
|
|
1019
|
+
} else {
|
|
1020
|
+
settings[field] = value
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
const apiKey = typeof patch.apiKey === 'string' ? patch.apiKey.trim() : ''
|
|
1024
|
+
if (apiKey !== '') {
|
|
1025
|
+
settings.apiKey = apiKey
|
|
1026
|
+
}
|
|
1027
|
+
config.providers[target] = settings
|
|
1028
|
+
}
|
|
1029
|
+
// Auto mode, when the card sent it: only the harnesses this build knows,
|
|
1030
|
+
// only booleans, so an unexpected key cannot land in the shared file.
|
|
1031
|
+
if (patch?.reuse !== null && typeof patch?.reuse === 'object') {
|
|
1032
|
+
config.reuse = { ...config.reuse }
|
|
1033
|
+
for (const harness of REUSE_HARNESSES) {
|
|
1034
|
+
const granted = patch.reuse[harness]
|
|
1035
|
+
if (typeof granted === 'boolean') {
|
|
1036
|
+
config.reuse[harness] = granted
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
const file = modlensConfigPath()
|
|
1041
|
+
// A symlink here would write through to wherever it points, so it is
|
|
1042
|
+
// refused rather than followed: the CLI writes a real file, and anything
|
|
1043
|
+
// else is a setup this card should not silently honor.
|
|
1044
|
+
try {
|
|
1045
|
+
if (lstatSync(file).isSymbolicLink()) {
|
|
1046
|
+
throw new Error(`${file} is a symlink; edit the file it points at instead`)
|
|
1047
|
+
}
|
|
1048
|
+
} catch (error) {
|
|
1049
|
+
if (error?.code !== 'ENOENT') throw error
|
|
1050
|
+
}
|
|
1051
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
1052
|
+
writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 })
|
|
1053
|
+
try {
|
|
1054
|
+
chmodSync(file, 0o600)
|
|
1055
|
+
} catch {
|
|
1056
|
+
// Windows has no POSIX bits; the mode on the write above is all there is.
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/**
|
|
1061
|
+
* GET /modlens/config: the engine summary above. POST: one submission.
|
|
1062
|
+
*
|
|
1063
|
+
* The dsh web server listens on loopback, but a page in the same browser can
|
|
1064
|
+
* still reach it, so a write requires a same-origin request: a cross-site POST
|
|
1065
|
+
* could otherwise repoint someone's engine at an endpoint of its choosing.
|
|
1066
|
+
* A read is refused the same way for symmetry, though it carries no secret.
|
|
1067
|
+
*/
|
|
1068
|
+
/**
|
|
1069
|
+
* The self-check behind the card's auto-mode section: which local harnesses
|
|
1070
|
+
* exist to be borrowed at all. `doctor --json` already probes them without
|
|
1071
|
+
* network or quota, so the route spawns the CLI this package ships and lifts
|
|
1072
|
+
* its reuse section. Cached briefly, since one probe can take a second and
|
|
1073
|
+
* re-expanding the card should not re-pay it.
|
|
1074
|
+
*/
|
|
1075
|
+
const DISCOVERY_TTL_MS = 60_000
|
|
1076
|
+
let discoveryCache = null
|
|
1077
|
+
async function discoverReuse() {
|
|
1078
|
+
if (discoveryCache !== null && Date.now() - discoveryCache.at < DISCOVERY_TTL_MS) {
|
|
1079
|
+
return discoveryCache.value
|
|
1080
|
+
}
|
|
1081
|
+
try {
|
|
1082
|
+
const { stdout, code } = await run(process.execPath, [CLI_PATH, 'doctor', '--json'], AbortSignal.timeout(30_000))
|
|
1083
|
+
if (code !== 0) return null
|
|
1084
|
+
const reuse = JSON.parse(stdout)?.reuse
|
|
1085
|
+
if (!reuse || !Array.isArray(reuse.probes)) return null
|
|
1086
|
+
// doctor names the harness claude-code; the grant key is claude.
|
|
1087
|
+
const probes = reuse.probes.map((probe) => ({
|
|
1088
|
+
harness: probe.harness === 'claude-code' ? 'claude' : probe.harness,
|
|
1089
|
+
cliFound: probe.cliFound === true,
|
|
1090
|
+
loggedIn: probe.loggedIn,
|
|
1091
|
+
cliPath: typeof probe.cliPath === 'string' ? probe.cliPath : '',
|
|
1092
|
+
}))
|
|
1093
|
+
discoveryCache = { at: Date.now(), value: probes }
|
|
1094
|
+
return probes
|
|
1095
|
+
} catch {
|
|
1096
|
+
return null
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/**
|
|
1101
|
+
* Open the shared config file in whatever the OS considers its editor. The
|
|
1102
|
+
* card's "open config file" link lands here: the path never has to be
|
|
1103
|
+
* explained to the user, they just get the file. Created empty first when
|
|
1104
|
+
* missing, so the editor has something to open.
|
|
1105
|
+
*/
|
|
1106
|
+
function openConfigFile() {
|
|
1107
|
+
const file = modlensConfigPath()
|
|
1108
|
+
try {
|
|
1109
|
+
lstatSync(file)
|
|
1110
|
+
} catch {
|
|
1111
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
1112
|
+
writeFileSync(file, '{}\n', { mode: 0o600 })
|
|
1113
|
+
}
|
|
1114
|
+
const [command, args] =
|
|
1115
|
+
process.platform === 'darwin'
|
|
1116
|
+
? ['open', [file]]
|
|
1117
|
+
: process.platform === 'win32'
|
|
1118
|
+
? ['cmd', ['/c', 'start', '', file]]
|
|
1119
|
+
: ['xdg-open', [file]]
|
|
1120
|
+
spawn(command, args, { detached: true, stdio: 'ignore' }).unref()
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/** localhost, ::1, or anything in 127/8, matching dsh's own /api fence. */
|
|
1124
|
+
function isLoopbackHost(hostname) {
|
|
1125
|
+
if (hostname === 'localhost' || hostname === '[::1]') return true
|
|
1126
|
+
const parts = hostname.split('.')
|
|
1127
|
+
return (
|
|
1128
|
+
parts.length === 4 && parts[0] === '127' && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
|
1129
|
+
)
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
/**
|
|
1133
|
+
* The same fence dsh puts in front of its own /api, for the same two
|
|
1134
|
+
* confused-deputy paths. Host is the header DNS rebinding cannot forge, so it
|
|
1135
|
+
* must name a loopback authority: a rebound page reaches this socket carrying
|
|
1136
|
+
* its own domain there. Origin and Sec-Fetch-Site then rule out a cross-site
|
|
1137
|
+
* page on the machine itself. A dsh serving a LAN address configures
|
|
1138
|
+
* trustedHosts for /api; this route stays loopback-only, since nothing about
|
|
1139
|
+
* editing an API key wants a wider door.
|
|
1140
|
+
*/
|
|
1141
|
+
function isTrustedRequest(req) {
|
|
1142
|
+
const host = req.headers?.host
|
|
1143
|
+
if (typeof host !== 'string' || host === '') return false
|
|
1144
|
+
let hostUrl
|
|
1145
|
+
try {
|
|
1146
|
+
hostUrl = new URL(`http://${host}`)
|
|
1147
|
+
} catch {
|
|
1148
|
+
return false
|
|
1149
|
+
}
|
|
1150
|
+
if (!isLoopbackHost(hostUrl.hostname)) return false
|
|
1151
|
+
if (req.headers?.['sec-fetch-site'] === 'cross-site') return false
|
|
1152
|
+
const origin = req.headers?.origin
|
|
1153
|
+
if (origin === undefined) return true
|
|
1154
|
+
try {
|
|
1155
|
+
return new URL(origin).host === hostUrl.host
|
|
1156
|
+
} catch {
|
|
1157
|
+
return false
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
function registerConfigRoute(ctx) {
|
|
1162
|
+
ctx.webServer.register({
|
|
1163
|
+
name: 'modlens-config',
|
|
1164
|
+
kind: 'exact',
|
|
1165
|
+
path: '/modlens/config',
|
|
1166
|
+
handler: async (req, res) => {
|
|
1167
|
+
const send = (status, body) => {
|
|
1168
|
+
res.writeHead(status, { 'content-type': 'application/json' })
|
|
1169
|
+
res.end(JSON.stringify(body))
|
|
1170
|
+
}
|
|
1171
|
+
if (!isTrustedRequest(req)) {
|
|
1172
|
+
send(403, { error: 'request refused: this route answers same-origin loopback only' })
|
|
1173
|
+
return
|
|
1174
|
+
}
|
|
1175
|
+
if (req.method === 'GET') {
|
|
1176
|
+
try {
|
|
1177
|
+
const summary = engineSummary()
|
|
1178
|
+
const wantsDiscovery = new URL(req.url, 'http://localhost').searchParams.has('discover')
|
|
1179
|
+
if (wantsDiscovery) {
|
|
1180
|
+
// null when the probe failed: the card then falls back to the
|
|
1181
|
+
// plain grant list rather than showing nothing.
|
|
1182
|
+
summary.discovery = await discoverReuse()
|
|
1183
|
+
}
|
|
1184
|
+
send(200, summary)
|
|
1185
|
+
} catch (error) {
|
|
1186
|
+
send(409, { error: String(error?.message ?? error) })
|
|
1187
|
+
}
|
|
1188
|
+
return
|
|
1189
|
+
}
|
|
1190
|
+
if (req.method !== 'POST') {
|
|
1191
|
+
res.writeHead(405).end()
|
|
1192
|
+
return
|
|
1193
|
+
}
|
|
1194
|
+
try {
|
|
1195
|
+
const chunks = []
|
|
1196
|
+
let total = 0
|
|
1197
|
+
for await (const chunk of req) {
|
|
1198
|
+
total += chunk.length
|
|
1199
|
+
if (total > 64 * 1024) {
|
|
1200
|
+
send(413, { error: 'config payload too large' })
|
|
1201
|
+
req.destroy()
|
|
1202
|
+
return
|
|
1203
|
+
}
|
|
1204
|
+
chunks.push(chunk)
|
|
1205
|
+
}
|
|
1206
|
+
const patch = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
1207
|
+
// The card's "open config file" link: an action, not a setting.
|
|
1208
|
+
if (patch?.open === true) {
|
|
1209
|
+
openConfigFile()
|
|
1210
|
+
send(200, { opened: true })
|
|
1211
|
+
return
|
|
1212
|
+
}
|
|
1213
|
+
applyEngineSettings(patch)
|
|
1214
|
+
send(200, engineSummary())
|
|
1215
|
+
} catch (error) {
|
|
1216
|
+
send(400, { error: String(error?.message ?? error) })
|
|
1217
|
+
}
|
|
1218
|
+
},
|
|
1219
|
+
})
|
|
1220
|
+
}
|
package/package.json
CHANGED
package/skills/modlens/SKILL.md
CHANGED
|
@@ -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.
|
|
23
|
+
If your harness forbids running scripts, reason through the same order by hand and run the first line that works (the pinned version is 3.16.7):
|
|
24
24
|
|
|
25
|
-
1. A `modlens` on `PATH` whose major version is 3 and is at least 3.16.
|
|
26
|
-
2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.16.
|
|
27
|
-
3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.16.
|
|
25
|
+
1. A `modlens` on `PATH` whose major version is 3 and is at least 3.16.7: `modlens <args>`.
|
|
26
|
+
2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.16.7 modlens <args>`.
|
|
27
|
+
3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.16.7 <args>`.
|
|
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.
|
|
@@ -93,9 +93,13 @@ 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
|
+
# value omitted: a hidden prompt, so the key skips argv, shell history, and this chat
|
|
97
|
+
modlens config set gemini-api.apiKey
|
|
96
98
|
# or environment: export GEMINI_API_KEY=<key>
|
|
97
99
|
```
|
|
98
100
|
|
|
101
|
+
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.
|
|
102
|
+
|
|
99
103
|
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
104
|
|
|
101
105
|
### openai (any OpenAI-compatible multimodal endpoint)
|
|
@@ -93,9 +93,13 @@ agy # 用户需自己在浏览器完成登录,然后退出
|
|
|
93
93
|
|
|
94
94
|
```bash
|
|
95
95
|
modlens config set gemini-api.apiKey <key>
|
|
96
|
+
# 省略值:进入隐藏输入,密钥不进 argv、不进 shell 历史,也不进这段对话
|
|
97
|
+
modlens config set gemini-api.apiKey
|
|
96
98
|
# 或走环境变量:export GEMINI_API_KEY=<key>
|
|
97
99
|
```
|
|
98
100
|
|
|
101
|
+
用户就在自己终端前时,先给隐藏输入这条。大多数人图方便还是会把 key 直接贴进对话,那也没问题:照收照存。隐藏输入是留给在乎的人的。
|
|
102
|
+
|
|
99
103
|
默认模型 `gemini-3.6-flash` 在免费档就有视觉能力(约每分钟 10-15 次请求,每天 1500 次)。免费档的数据可能被 Google 用于改进产品,用户要处理敏感图片时请提醒这一点。
|
|
100
104
|
|
|
101
105
|
### openai(任意 OpenAI 兼容的多模态端点)
|
|
@@ -27,7 +27,7 @@ Consent rules:
|
|
|
27
27
|
- One question per decision, never a bundled yes. Reusing Codex and reusing pi credentials are two questions (or one question with independent options), not one.
|
|
28
28
|
- Each question names the harness, whose quota it spends, and the accounting promise. Example wording: "Allow modlens to reuse your signed-in Codex CLI for image reads? Every reused read is labeled in the result so you always see whose quota was spent."
|
|
29
29
|
- The do-nothing outcome must be safe and stated: "If you skip this, modlens just uses the engines you configure yourself."
|
|
30
|
-
-
|
|
30
|
+
- When a key is needed, offer the clean path first, in one line: "Run `modlens config set gemini-api.apiKey` in your terminal and paste the key at the hidden prompt. It stays out of this chat and out of your shell history, and I never see it." Most users will paste the key into the chat anyway, because that is the convenient path, and that is fine: take exactly the key they hand over, use it, and never go looking for keys they did not. The offer is for the users who care, not a gate.
|
|
31
31
|
|
|
32
32
|
## 4. Apply only what was consented to
|
|
33
33
|
|
|
@@ -24,7 +24,7 @@ $ErrorActionPreference = 'Stop'
|
|
|
24
24
|
# package.json version, and the release script rewrites it on every bump.
|
|
25
25
|
$Package = '@liustack/modlens'
|
|
26
26
|
$Bin = 'modlens'
|
|
27
|
-
$Pinned = '3.16.
|
|
27
|
+
$Pinned = '3.16.7'
|
|
28
28
|
# -------------------------------------------------------------------------------
|
|
29
29
|
|
|
30
30
|
$NativeNote = 'no native artifact is published for this tool yet; phase A ships npm launch paths only'
|
|
@@ -22,7 +22,7 @@ set -eu
|
|
|
22
22
|
# package.json version, and the release script rewrites it on every bump.
|
|
23
23
|
PKG="@liustack/modlens"
|
|
24
24
|
BIN="modlens"
|
|
25
|
-
PINNED="3.16.
|
|
25
|
+
PINNED="3.16.7"
|
|
26
26
|
# -------------------------------------------------------------------------------
|
|
27
27
|
|
|
28
28
|
NATIVE_NOTE="no native artifact is published for this tool yet; phase A ships npm launch paths only"
|