@liustack/modlens 3.12.1 → 3.14.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 +10 -0
- package/dist/main.js +69 -27032
- package/docs/harness-setup.md +18 -0
- package/dsh/client.js +126 -0
- package/dsh/index.js +128 -25
- package/package.json +83 -77
- package/skills/modlens/SKILL.md +5 -5
- package/skills/modlens/references/runtime.md +7 -6
- package/skills/modlens/scripts/run.ps1 +4 -4
- package/skills/modlens/scripts/run.sh +4 -4
package/docs/harness-setup.md
CHANGED
|
@@ -57,3 +57,21 @@ npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@latest
|
|
|
57
57
|
```
|
|
58
58
|
|
|
59
59
|
This registers a `read_image` tool whose schema reaches the model on every request (no trigger heuristics), runs the modlens CLI shipped inside the same package, and returns the structured evidence as the tool's canonical JSON output. Engines, reuse grants, and guard rules stay in `~/.modlens/config.json`, shared with every other harness. dsh is in developer preview and its plugin surface may change; the plugin keeps its touch small (raw tool registration, the llm adapter surface for the vision variants, the attachment reader, and one agent pre-step hook) and degrades loudly if any of them moves.
|
|
60
|
+
|
|
61
|
+
### Paste-to-path (web profile)
|
|
62
|
+
|
|
63
|
+
Pasting an image into the dsh Web UI under a **text-only model** used to die at
|
|
64
|
+
image admission. The plugin now ships a browser half (loaded automatically by
|
|
65
|
+
dsh's client plugin system) that takes over the paste in exactly that case:
|
|
66
|
+
the image bytes go to the plugin's `/modlens/paste` route on the dsh web
|
|
67
|
+
server (loopback, magic-byte checked, 25 MB cap), land as a private temp file,
|
|
68
|
+
and the composer receives the file path as plain text — the same shape Pi,
|
|
69
|
+
OpenCode, and Claude Code hand their models, and the modlens skill's and
|
|
70
|
+
`read_image` tool's primary trigger. Admission never fires because the message
|
|
71
|
+
carries no image attachment.
|
|
72
|
+
|
|
73
|
+
The takeover is conditional: when the selected model is a `(modlens vision)`
|
|
74
|
+
variant or a known vision model, the native paste flow is left alone (variants
|
|
75
|
+
convert at request time with the thumbnail preserved; vision models read
|
|
76
|
+
images themselves). `pasteToPath: false` in the plugin row turns the whole
|
|
77
|
+
feature off.
|
package/dsh/client.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Browser half of the modlens dsh plugin: paste-to-path.
|
|
2
|
+
//
|
|
3
|
+
// A capture-phase paste listener runs before the composer's own handler.
|
|
4
|
+
// When the clipboard carries image files, the default intake (attachment ->
|
|
5
|
+
// host image admission -> "model does not support images" for text-only
|
|
6
|
+
// models) is suppressed; the bytes go to the plugin's host route
|
|
7
|
+
// (POST /modlens/paste), land as a private temp file, and the returned path
|
|
8
|
+
// is inserted into the composer as plain text. A text-only model then sees
|
|
9
|
+
// exactly what Pi, OpenCode, and Claude Code hand their models: a file path,
|
|
10
|
+
// which is also the modlens skill's and read_image tool's primary trigger.
|
|
11
|
+
//
|
|
12
|
+
// Hand-written in the lazy-CJS bundle protocol (window.__ModuleLoader__.load
|
|
13
|
+
// with a factory returning cordis-plugin exports), so no build step and no
|
|
14
|
+
// imports from dsh client packages — the same zero-dependency stance as the
|
|
15
|
+
// host half.
|
|
16
|
+
window.__ModuleLoader__.load({
|
|
17
|
+
id: '@liustack/modlens',
|
|
18
|
+
factory: () => {
|
|
19
|
+
var module = { exports: {} }
|
|
20
|
+
var exports = module.exports
|
|
21
|
+
|
|
22
|
+
function imageFilesOf(event) {
|
|
23
|
+
var items = event.clipboardData && event.clipboardData.items
|
|
24
|
+
if (!items) return []
|
|
25
|
+
var files = []
|
|
26
|
+
for (var i = 0; i < items.length; i++) {
|
|
27
|
+
var item = items[i]
|
|
28
|
+
if (item.kind !== 'file') continue
|
|
29
|
+
var file = item.getAsFile()
|
|
30
|
+
if (file && /^image\//.test(file.type)) files.push(file)
|
|
31
|
+
}
|
|
32
|
+
return files
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function insertText(target, text) {
|
|
36
|
+
var el =
|
|
37
|
+
target && (target.tagName === 'TEXTAREA' || target.tagName === 'INPUT')
|
|
38
|
+
? target
|
|
39
|
+
: document.activeElement
|
|
40
|
+
if (!el || (el.tagName !== 'TEXTAREA' && el.tagName !== 'INPUT')) return
|
|
41
|
+
el.focus()
|
|
42
|
+
// execCommand fires the input event React's controlled textarea needs;
|
|
43
|
+
// the prototype-setter dance is the fallback for engines dropping it.
|
|
44
|
+
var inserted = false
|
|
45
|
+
try {
|
|
46
|
+
inserted = document.execCommand('insertText', false, text)
|
|
47
|
+
} catch {
|
|
48
|
+
inserted = false
|
|
49
|
+
}
|
|
50
|
+
if (!inserted) {
|
|
51
|
+
var proto =
|
|
52
|
+
el.tagName === 'TEXTAREA' ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype
|
|
53
|
+
var setter = Object.getOwnPropertyDescriptor(proto, 'value').set
|
|
54
|
+
setter.call(el, el.value + text)
|
|
55
|
+
el.dispatchEvent(new Event('input', { bubbles: true }))
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function uploadOne(file) {
|
|
60
|
+
return file.arrayBuffer().then((buffer) =>
|
|
61
|
+
fetch('/modlens/paste', { method: 'POST', body: buffer }).then((res) => {
|
|
62
|
+
if (!res.ok) {
|
|
63
|
+
return res
|
|
64
|
+
.json()
|
|
65
|
+
.catch(() => ({}))
|
|
66
|
+
.then((body) => {
|
|
67
|
+
throw new Error(body.error || `paste upload failed (${res.status})`)
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
return res.json()
|
|
71
|
+
}),
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The takeover is for text-only models: the (modlens vision) variants
|
|
76
|
+
// convert pastes at request time with the thumbnail preserved, and real
|
|
77
|
+
// vision models read images natively — both keep the original paste UX.
|
|
78
|
+
// The model selector button's accessible label is the only client-side
|
|
79
|
+
// source of the current model; when it cannot be found, taking over is
|
|
80
|
+
// the safe default (text-only is the common case this exists for).
|
|
81
|
+
var VISION_HINT = /\(modlens vision\)|deepseek-(vl|ocr)|janus|glm-[\d.]*v\b|vision|image/i
|
|
82
|
+
|
|
83
|
+
function currentModelLabel() {
|
|
84
|
+
var buttons = document.querySelectorAll('button[aria-label]')
|
|
85
|
+
for (var i = 0; i < buttons.length; i++) {
|
|
86
|
+
var label = buttons[i].getAttribute('aria-label') || ''
|
|
87
|
+
if (/选择模型|select model|current model/i.test(label)) return label
|
|
88
|
+
}
|
|
89
|
+
return ''
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function onPaste(event) {
|
|
93
|
+
var files = imageFilesOf(event)
|
|
94
|
+
if (files.length === 0) return
|
|
95
|
+
if (VISION_HINT.test(currentModelLabel())) return
|
|
96
|
+
// Take the paste before the composer's intake starts an attachment (and
|
|
97
|
+
// with it the host-side image admission a text-only model fails).
|
|
98
|
+
event.preventDefault()
|
|
99
|
+
event.stopImmediatePropagation()
|
|
100
|
+
var target = event.target
|
|
101
|
+
Promise.all(files.map(uploadOne))
|
|
102
|
+
.then((results) => {
|
|
103
|
+
var text = results
|
|
104
|
+
.map((r) => r.path)
|
|
105
|
+
.filter(Boolean)
|
|
106
|
+
.join(' ')
|
|
107
|
+
if (text) insertText(target, `${text} `)
|
|
108
|
+
})
|
|
109
|
+
.catch((error) => {
|
|
110
|
+
console.error(`[modlens] paste-to-path failed: ${error && error.message ? error.message : error}`)
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function apply(ctx) {
|
|
115
|
+
document.addEventListener('paste', onPaste, true)
|
|
116
|
+
// cordis effect: unregister on plugin disposal (HMR, profile reload).
|
|
117
|
+
if (typeof ctx.effect === 'function') {
|
|
118
|
+
ctx.effect(() => () => document.removeEventListener('paste', onPaste, true), 'modlens: paste-to-path listener')
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
exports.apply = apply
|
|
123
|
+
exports.inject = []
|
|
124
|
+
return module.exports
|
|
125
|
+
},
|
|
126
|
+
})
|
package/dsh/index.js
CHANGED
|
@@ -43,6 +43,24 @@ export function apply(ctx, config = {}) {
|
|
|
43
43
|
if (config.visionProvider !== false) {
|
|
44
44
|
registerVisionProvider(ctx, config)
|
|
45
45
|
}
|
|
46
|
+
// Paste-to-path: the browser half (dsh/client.js) intercepts image pastes
|
|
47
|
+
// and POSTs the bytes here; the file lands in a private temp dir and the
|
|
48
|
+
// path text goes into the composer instead of an image attachment. A
|
|
49
|
+
// text-only model then never trips image admission, and the path is the
|
|
50
|
+
// same trigger shape Pi, OpenCode, and Claude Code hand their models.
|
|
51
|
+
// webServer exists only under the web profile, and this cordis has no
|
|
52
|
+
// optional-inject form, so the route rides a scoped ctx.inject: the closure
|
|
53
|
+
// runs when the service appears and never runs where it does not (headless
|
|
54
|
+
// stays untouched, and the plugin itself never waits on it).
|
|
55
|
+
if (config.pasteToPath !== false && typeof ctx.inject === 'function') {
|
|
56
|
+
ctx.inject(['webServer'], (scope) => {
|
|
57
|
+
try {
|
|
58
|
+
registerPasteRoute(scope)
|
|
59
|
+
} catch (error) {
|
|
60
|
+
console.error(`[modlens] paste-to-path route skipped: ${error}`)
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
}
|
|
46
64
|
// Registered as a raw JSON-Schema tool definition (no dsh package imports:
|
|
47
65
|
// the developer-preview registry accepts these and out-of-tree resolution
|
|
48
66
|
// of @deepseek-ai/dsh-tools is not yet reliable), so this plugin owns its
|
|
@@ -134,6 +152,68 @@ export function apply(ctx, config = {}) {
|
|
|
134
152
|
}
|
|
135
153
|
}
|
|
136
154
|
|
|
155
|
+
// Image magic bytes for the paste route: refuse anything that is not a real
|
|
156
|
+
// image before a byte touches disk. Mirrors the CLI's sniffing table.
|
|
157
|
+
const PASTE_SNIFFS = [
|
|
158
|
+
{ ext: '.png', test: (b) => b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47 },
|
|
159
|
+
{ ext: '.jpg', test: (b) => b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff },
|
|
160
|
+
{ ext: '.gif', test: (b) => b.length >= 6 && b.toString('ascii', 0, 3) === 'GIF' },
|
|
161
|
+
{ ext: '.webp', test: (b) => b.length >= 12 && b.toString('ascii', 0, 4) === 'RIFF' && b.toString('ascii', 8, 12) === 'WEBP' },
|
|
162
|
+
{ ext: '.heic', test: (b) => b.length >= 12 && b.toString('ascii', 4, 8) === 'ftyp' },
|
|
163
|
+
]
|
|
164
|
+
const PASTE_MAX_BYTES = 25 * 1024 * 1024
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* POST /modlens/paste: image bytes in, `{ path }` out. Bound to the dsh web
|
|
168
|
+
* server, which listens on loopback by default; the file is private (0600)
|
|
169
|
+
* in a fresh unpredictable temp dir, magic-byte checked and size-capped.
|
|
170
|
+
*/
|
|
171
|
+
function registerPasteRoute(ctx) {
|
|
172
|
+
ctx.webServer.register({
|
|
173
|
+
name: 'modlens-paste',
|
|
174
|
+
kind: 'exact',
|
|
175
|
+
path: '/modlens/paste',
|
|
176
|
+
handler: async (req, res) => {
|
|
177
|
+
if (req.method !== 'POST') {
|
|
178
|
+
res.writeHead(405).end()
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
const chunks = []
|
|
183
|
+
let total = 0
|
|
184
|
+
for await (const chunk of req) {
|
|
185
|
+
total += chunk.length
|
|
186
|
+
if (total > PASTE_MAX_BYTES) {
|
|
187
|
+
res.writeHead(413, { 'content-type': 'application/json' })
|
|
188
|
+
res.end(JSON.stringify({ error: `image over the ${PASTE_MAX_BYTES}-byte limit` }))
|
|
189
|
+
req.destroy()
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
chunks.push(chunk)
|
|
193
|
+
}
|
|
194
|
+
const buffer = Buffer.concat(chunks)
|
|
195
|
+
const sniff = PASTE_SNIFFS.find((s) => s.test(buffer))
|
|
196
|
+
if (!sniff) {
|
|
197
|
+
res.writeHead(400, { 'content-type': 'application/json' })
|
|
198
|
+
res.end(JSON.stringify({ error: 'not a recognized image (png/jpeg/gif/webp/heic)' }))
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
const { mkdtemp, writeFile } = await import('node:fs/promises')
|
|
202
|
+
const { tmpdir } = await import('node:os')
|
|
203
|
+
const { join } = await import('node:path')
|
|
204
|
+
const dir = await mkdtemp(join(tmpdir(), 'modlens-dsh-paste-'))
|
|
205
|
+
const file = join(dir, `paste${sniff.ext}`)
|
|
206
|
+
await writeFile(file, buffer, { mode: 0o600 })
|
|
207
|
+
res.writeHead(200, { 'content-type': 'application/json' })
|
|
208
|
+
res.end(JSON.stringify({ path: file }))
|
|
209
|
+
} catch (error) {
|
|
210
|
+
res.writeHead(500, { 'content-type': 'application/json' })
|
|
211
|
+
res.end(JSON.stringify({ error: String(error && error.message ? error.message : error) }))
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
|
|
137
217
|
/**
|
|
138
218
|
* Phase 3: the paste unlock. dsh's image admission asks the selected
|
|
139
219
|
* provider's adapter for inputModalities, and the DeepSeek adapter hardcodes
|
|
@@ -297,21 +377,47 @@ function abortableWait(promise, signal) {
|
|
|
297
377
|
})
|
|
298
378
|
}
|
|
299
379
|
|
|
380
|
+
/**
|
|
381
|
+
* Image blocks hide at two depths: top-level message content (pastes), and
|
|
382
|
+
* inside tool-result content (dsh's own read_image tool nests one there).
|
|
383
|
+
* The upstream adapter's rejection check recurses (issue #24), so the
|
|
384
|
+
* conversion must recurse the same way or a nested image wedges the session
|
|
385
|
+
* permanently — the durable log keeps the real block, and every later turn
|
|
386
|
+
* re-fails on it.
|
|
387
|
+
*/
|
|
388
|
+
function contentHasImage(blocks) {
|
|
389
|
+
return (
|
|
390
|
+
Array.isArray(blocks) &&
|
|
391
|
+
blocks.some(
|
|
392
|
+
(b) => b?.type === 'image' || (b?.type === 'tool-result' && contentHasImage(b.content)),
|
|
393
|
+
)
|
|
394
|
+
)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async function convertBlocks(blocks, convertOne) {
|
|
398
|
+
const out = []
|
|
399
|
+
for (const block of blocks) {
|
|
400
|
+
if (block?.type === 'image') {
|
|
401
|
+
out.push(await convertOne(block))
|
|
402
|
+
} else if (block?.type === 'tool-result' && contentHasImage(block.content)) {
|
|
403
|
+
out.push({ ...block, content: await convertBlocks(block.content, convertOne) })
|
|
404
|
+
} else {
|
|
405
|
+
out.push(block)
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return out
|
|
409
|
+
}
|
|
410
|
+
|
|
300
411
|
async function convertImagesToEvidence(ctx, messages, signal, adapter) {
|
|
301
412
|
const out = []
|
|
302
413
|
for (const message of messages) {
|
|
303
|
-
if (!
|
|
414
|
+
if (!contentHasImage(message.content)) {
|
|
304
415
|
out.push(message)
|
|
305
416
|
continue
|
|
306
417
|
}
|
|
307
|
-
const content =
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
content.push(block)
|
|
311
|
-
continue
|
|
312
|
-
}
|
|
313
|
-
content.push(await abortableWait(cachedEvidence(ctx, adapter, block), signal))
|
|
314
|
-
}
|
|
418
|
+
const content = await convertBlocks(message.content, (block) =>
|
|
419
|
+
abortableWait(cachedEvidence(ctx, adapter, block), signal),
|
|
420
|
+
)
|
|
315
421
|
out.push({ ...message, content })
|
|
316
422
|
}
|
|
317
423
|
return out
|
|
@@ -331,28 +437,19 @@ function registerAutoRead(ctx) {
|
|
|
331
437
|
if (decision.kind !== 'enter') {
|
|
332
438
|
return decision
|
|
333
439
|
}
|
|
334
|
-
|
|
335
|
-
(message) =>
|
|
336
|
-
Array.isArray(message.content) &&
|
|
337
|
-
message.content.some((block) => block?.type === 'image'),
|
|
338
|
-
)
|
|
339
|
-
if (!hasImage) {
|
|
440
|
+
if (!decision.messages.some((message) => contentHasImage(message.content))) {
|
|
340
441
|
return decision
|
|
341
442
|
}
|
|
342
443
|
const messages = []
|
|
343
444
|
for (const message of decision.messages) {
|
|
344
|
-
if (!
|
|
445
|
+
if (!contentHasImage(message.content)) {
|
|
345
446
|
messages.push(message)
|
|
346
447
|
continue
|
|
347
448
|
}
|
|
348
|
-
const content =
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
continue
|
|
353
|
-
}
|
|
354
|
-
content.push((await readImageBlock(ctx, block, payload.signal)).block)
|
|
355
|
-
}
|
|
449
|
+
const content = await convertBlocks(
|
|
450
|
+
message.content,
|
|
451
|
+
async (block) => (await readImageBlock(ctx, block, payload.signal)).block,
|
|
452
|
+
)
|
|
356
453
|
messages.push({ ...message, content })
|
|
357
454
|
}
|
|
358
455
|
return { kind: 'enter', messages }
|
|
@@ -427,7 +524,13 @@ async function readImageBlock(ctx, block, signal) {
|
|
|
427
524
|
|
|
428
525
|
function run(command, args, signal) {
|
|
429
526
|
return new Promise((resolve, reject) => {
|
|
430
|
-
const child = spawn(command, args, {
|
|
527
|
+
const child = spawn(command, args, {
|
|
528
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
529
|
+
signal,
|
|
530
|
+
// In the packaged desktop app process.execPath is the Electron binary;
|
|
531
|
+
// this makes it behave as plain node for the spawned CLI (issue #25).
|
|
532
|
+
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
|
|
533
|
+
})
|
|
431
534
|
let stdout = ''
|
|
432
535
|
let stderr = ''
|
|
433
536
|
child.stdout.on('data', (chunk) => {
|
package/package.json
CHANGED
|
@@ -1,80 +1,86 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
"
|
|
77
|
-
|
|
2
|
+
"name": "@liustack/modlens",
|
|
3
|
+
"version": "3.14.0",
|
|
4
|
+
"description": "Plug-in vision for text-only LLMs, powered by the free Antigravity CLI",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"modlens": "./dist/main.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"dev": "vite build --watch",
|
|
11
|
+
"build": "vite build",
|
|
12
|
+
"typecheck": "tsc --noEmit",
|
|
13
|
+
"test": "vitest run",
|
|
14
|
+
"coverage": "vitest run --coverage",
|
|
15
|
+
"lint": "biome check src scripts dsh",
|
|
16
|
+
"format": "biome check --write src scripts",
|
|
17
|
+
"eval": "node evals/run.mjs",
|
|
18
|
+
"release": "node scripts/release.mjs",
|
|
19
|
+
"prepublishOnly": "pnpm build",
|
|
20
|
+
"docs:list": "node scripts/docs-list.js"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"docs",
|
|
25
|
+
"skills/modlens/SKILL.md",
|
|
26
|
+
"skills/modlens/scripts",
|
|
27
|
+
"skills/modlens/references",
|
|
28
|
+
"CHANGELOG.md",
|
|
29
|
+
"SECURITY.md",
|
|
30
|
+
"dsh",
|
|
31
|
+
"cordis.patch.yml"
|
|
32
|
+
],
|
|
33
|
+
"keywords": [
|
|
34
|
+
"cli",
|
|
35
|
+
"vision",
|
|
36
|
+
"ocr",
|
|
37
|
+
"antigravity",
|
|
38
|
+
"agent-skill",
|
|
39
|
+
"claude-code",
|
|
40
|
+
"skill",
|
|
41
|
+
"image-to-text",
|
|
42
|
+
"multimodal",
|
|
43
|
+
"modlens"
|
|
44
|
+
],
|
|
45
|
+
"author": "Leon Liu",
|
|
46
|
+
"license": "MIT",
|
|
47
|
+
"repository": {
|
|
48
|
+
"type": "git",
|
|
49
|
+
"url": "git+https://github.com/liustack/modlens.git"
|
|
50
|
+
},
|
|
51
|
+
"bugs": {
|
|
52
|
+
"url": "https://github.com/liustack/modlens/issues"
|
|
53
|
+
},
|
|
54
|
+
"homepage": "https://github.com/liustack/modlens#readme",
|
|
55
|
+
"engines": {
|
|
56
|
+
"node": ">=22.19"
|
|
57
|
+
},
|
|
58
|
+
"dependencies": {
|
|
59
|
+
"commander": "^13.1.0",
|
|
60
|
+
"undici": "^8.10.0"
|
|
61
|
+
},
|
|
62
|
+
"devDependencies": {
|
|
63
|
+
"@biomejs/biome": "^2.5.7",
|
|
64
|
+
"@types/node": "^22.19.7",
|
|
65
|
+
"@vitest/coverage-v8": "^3.2.7",
|
|
66
|
+
"typescript": "^5.9.3",
|
|
67
|
+
"vite": "^6.4.1",
|
|
68
|
+
"vitest": "^3.2.7"
|
|
69
|
+
},
|
|
70
|
+
"exports": {
|
|
71
|
+
".": "./dsh/index.js",
|
|
72
|
+
"./dsh": "./dsh/index.js",
|
|
73
|
+
"./package.json": "./package.json",
|
|
74
|
+
"./client": "./dsh/client.js"
|
|
75
|
+
},
|
|
76
|
+
"dsh": {
|
|
77
|
+
"bundle": {
|
|
78
|
+
"patch": "./cordis.patch.yml"
|
|
79
|
+
},
|
|
80
|
+
"client": {
|
|
81
|
+
"inject": [],
|
|
82
|
+
"platform": "web",
|
|
83
|
+
"immediately": true
|
|
84
|
+
}
|
|
78
85
|
}
|
|
79
|
-
}
|
|
80
86
|
}
|
package/skills/modlens/SKILL.md
CHANGED
|
@@ -20,12 +20,12 @@ 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.
|
|
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.14.0):
|
|
24
24
|
|
|
25
|
-
1. A `modlens` on `PATH` whose major version is 3 and is at least 3.
|
|
26
|
-
2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.
|
|
27
|
-
3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.
|
|
28
|
-
4. Otherwise tell the user no JavaScript runtime was found and that installing Node 22.
|
|
25
|
+
1. A `modlens` on `PATH` whose major version is 3 and is at least 3.14.0: `modlens <args>`.
|
|
26
|
+
2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.14.0 modlens <args>`.
|
|
27
|
+
3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.14.0 <args>`.
|
|
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.
|
|
31
31
|
|
|
@@ -8,7 +8,7 @@ shell syntax.
|
|
|
8
8
|
|
|
9
9
|
## Pinned version
|
|
10
10
|
|
|
11
|
-
- Pinned CLI version: 3.
|
|
11
|
+
- Pinned CLI version: 3.14.0
|
|
12
12
|
- npm package: `@liustack/modlens`
|
|
13
13
|
- CLI binary name: `modlens`
|
|
14
14
|
|
|
@@ -22,7 +22,7 @@ launcher/reference copies ever drift from `package.json`.
|
|
|
22
22
|
Each call resolves a way to run the CLI, in this order:
|
|
23
23
|
|
|
24
24
|
1. **A compatible `modlens` already on `PATH`** — run it directly, by name.
|
|
25
|
-
2. **`npx` present, and `node` meets the CLI's 22.
|
|
25
|
+
2. **`npx` present, and `node` meets the CLI's 22.19 floor** — `npx --yes --package @liustack/modlens@<pinned> modlens <args>`. An npx sitting on an older node is skipped: it would select a path known to fail at run time.
|
|
26
26
|
3. **`bunx` present** — `bunx --bun @liustack/modlens@<pinned> <args>`.
|
|
27
27
|
4. **A native artifact** — reserved for phase B. None is published yet, so this
|
|
28
28
|
branch reports `nativeArtifact.available: false` and moves on.
|
|
@@ -70,13 +70,13 @@ would have set.
|
|
|
70
70
|
- `checked.pathCli` — `{ present, path, version, compatible }` for a `modlens`
|
|
71
71
|
on `PATH`, with `compatible` applying the rule above.
|
|
72
72
|
- `checked.npx` — `{ present, path, nodeMeetsFloor }`; `nodeMeetsFloor` is whether
|
|
73
|
-
the local node satisfies the CLI's 22.
|
|
73
|
+
the local node satisfies the CLI's 22.19 floor, required for the npx path.
|
|
74
74
|
- `checked.bunx` — `{ present, path }`.
|
|
75
75
|
- `checked.node` — `{ present, version }`.
|
|
76
76
|
- `nativeArtifact` — `{ available, note }`; `available` is `false` in phase A.
|
|
77
77
|
- `selected` — the resolved path: `path`, `npx`, `bunx`, or `none`.
|
|
78
78
|
- `nextSteps` — when `selected` is `none`, one or two plain-language actions for
|
|
79
|
-
the user (install Node 22.
|
|
79
|
+
the user (install Node 22.19+, or Bun); empty otherwise.
|
|
80
80
|
- `cliDoctor` — when a CLI is resolvable, the CLI's own `doctor --json` report
|
|
81
81
|
(provider, config, and harness diagnosis) is nested here; `null` otherwise.
|
|
82
82
|
|
|
@@ -87,8 +87,9 @@ first time (that is how those runners work); after that it is served from the
|
|
|
87
87
|
local cache.
|
|
88
88
|
|
|
89
89
|
One capability note for the bunx path: Bun cannot load `node:sqlite`, which
|
|
90
|
-
OpenCode paste recovery needs, so on a machine
|
|
91
|
-
bunx, `recover-paste` for OpenCode requires
|
|
90
|
+
OpenCode paste recovery needs (unflagged in Node since 22.13), so on a machine
|
|
91
|
+
where the launcher resolved to bunx, `recover-paste` for OpenCode requires a
|
|
92
|
+
real Node install — 22.19+, since that is the floor this launcher accepts.
|
|
92
93
|
|
|
93
94
|
## Delivery form: local CLI, long term
|
|
94
95
|
|
|
@@ -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.
|
|
27
|
+
$Pinned = '3.14.0'
|
|
28
28
|
# -------------------------------------------------------------------------------
|
|
29
29
|
|
|
30
30
|
$NativeNote = 'no native artifact is published for this tool yet; phase A ships npm launch paths only'
|
|
@@ -72,7 +72,7 @@ function Test-Compatible {
|
|
|
72
72
|
# The npx path runs the CLI on this machine's node, so npx is only usable when
|
|
73
73
|
# node itself meets the CLI's floor. An old node with a working npx used to be
|
|
74
74
|
# selected anyway, a path known to fail at run time.
|
|
75
|
-
$NodeFloor = '22.
|
|
75
|
+
$NodeFloor = '22.19.0'
|
|
76
76
|
function Test-NodeMeetsFloor {
|
|
77
77
|
if (-not (Get-Command node -ErrorAction SilentlyContinue)) { return $false }
|
|
78
78
|
try { $nv = ((& node --version 2>$null) -replace '^v', '') } catch { return $false }
|
|
@@ -165,7 +165,7 @@ function Build-DiagnosisJson {
|
|
|
165
165
|
$steps = @()
|
|
166
166
|
if ($script:Selected -eq 'none') {
|
|
167
167
|
$major = $Pinned.Split('.')[0]
|
|
168
|
-
$first = "Install Node 22.
|
|
168
|
+
$first = "Install Node 22.19+ from https://nodejs.org so npx can run $Package@$Pinned, then re-run this launcher."
|
|
169
169
|
if ($script:NpxPresent -and (-not $script:NodeFloorOk)) {
|
|
170
170
|
$first = "npx is present but node $(if ($script:NodeVer) { $script:NodeVer } else { 'missing' }) is below the $NodeFloor floor this CLI needs. Upgrade Node at https://nodejs.org, then re-run this launcher."
|
|
171
171
|
}
|
|
@@ -215,7 +215,7 @@ function Write-DiagnosisText {
|
|
|
215
215
|
Write-Output ''
|
|
216
216
|
Write-Output ("No runtime can launch {0} here. {1}" -f $Bin, $NativeNote)
|
|
217
217
|
Write-Output 'Next steps:'
|
|
218
|
-
Write-Output ' - Install Node 22.
|
|
218
|
+
Write-Output ' - Install Node 22.19+ from https://nodejs.org, then re-run this launcher.'
|
|
219
219
|
Write-Output (" - Or install Bun from https://bun.sh, or put a compatible {0} on PATH." -f $Bin)
|
|
220
220
|
}
|
|
221
221
|
}
|
|
@@ -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.
|
|
25
|
+
PINNED="3.14.0"
|
|
26
26
|
# -------------------------------------------------------------------------------
|
|
27
27
|
|
|
28
28
|
NATIVE_NOTE="no native artifact is published for this tool yet; phase A ships npm launch paths only"
|
|
@@ -70,7 +70,7 @@ cli_version() {
|
|
|
70
70
|
# The npx path runs the CLI on this machine's node, so npx is only usable when
|
|
71
71
|
# node itself meets the CLI's floor. An old node with a working npx used to be
|
|
72
72
|
# selected anyway, a path known to fail at run time.
|
|
73
|
-
NODE_FLOOR="22.
|
|
73
|
+
NODE_FLOOR="22.19.0"
|
|
74
74
|
node_meets_floor() {
|
|
75
75
|
command -v node >/dev/null 2>&1 || return 1
|
|
76
76
|
_nv="$(node --version 2>/dev/null | sed 's/^v//')"
|
|
@@ -189,7 +189,7 @@ compute_next_steps() {
|
|
|
189
189
|
if [ "$G_NPX_PRESENT" = 1 ] && [ "$G_NODE_FLOOR_OK" = 0 ]; then
|
|
190
190
|
_s1="npx is present but node ${G_NODE_VER:-missing} is below the $NODE_FLOOR floor this CLI needs. Upgrade Node at https://nodejs.org, then re-run this launcher."
|
|
191
191
|
else
|
|
192
|
-
_s1="Install Node 22.
|
|
192
|
+
_s1="Install Node 22.19+ from https://nodejs.org so npx can run $PKG@$PINNED, then re-run this launcher."
|
|
193
193
|
fi
|
|
194
194
|
_s2="No JavaScript runtime? Install Bun from https://bun.sh to use bunx, or put a compatible $BIN (major ${PINNED%%.*}, at or above $PINNED) on PATH."
|
|
195
195
|
G_NEXTSTEPS="$(printf '"%s", "%s"' "$(json_escape "$_s1")" "$(json_escape "$_s2")")"
|
|
@@ -255,7 +255,7 @@ emit_text() {
|
|
|
255
255
|
if [ "$G_SEL" = "none" ]; then
|
|
256
256
|
printf '\nNo runtime can launch %s here. %s\n' "$BIN" "$NATIVE_NOTE"
|
|
257
257
|
printf 'Next steps:\n'
|
|
258
|
-
printf ' - Install Node 22.
|
|
258
|
+
printf ' - Install Node 22.19+ from https://nodejs.org, then re-run this launcher.\n'
|
|
259
259
|
printf ' - Or install Bun from https://bun.sh, or put a compatible %s on PATH.\n' "$BIN"
|
|
260
260
|
fi
|
|
261
261
|
}
|