@liustack/modlens 3.17.3 → 3.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.18.0 - 2026-08-16
4
+
5
+ - **Windows: the CLI providers start again, and a shim is now read by recognising it rather than parsing it ([#43](https://github.com/liustack/modlens/issues/43)).** npm installs a compiled CLI as a `.cmd` that runs the `.exe` beside it, and handing that `.cmd` to spawn is the `EINVAL` that broke the `claude-cli` fallback and the `opencode` reuse probe. The first attempt at this classified each line of the batch file against a whitelist and reasoned about the ones it recognised. Review found eight defects in it, every one the same shape: a line cannot be judged on its own, because whether it runs at all is a property of the file. The last was an `IF EXIST` whose condition is false, where `cmd` runs nothing and the reader still produced a plan that launched the provider. Trading a loud failure for a silent wrong action is worse than the bug being fixed, so that version was withdrawn from 3.17.1 rather than shipped.
6
+ What ships instead recognises the exact shapes the two real generators emit, each with its own fixed recipe, and declines everything else. npm's Node shim is two templates rather than one, because the `PATHEXT` edit changes meaning between them: through `cmd-shim` 9.0.1 it sits inside `SETLOCAL` while the execution line begins with `endLocal`, so it is undone before the lookup and before the child starts, and honouring it would reproduce a bug npm fixed in 9.0.2; from 9.0.2 it rides the execution line after `endLocal`, where it applies to either arm. pnpm writes it inside the `ELSE` block and never calls `endlocal`, so it applies to that arm alone. npm's native template calls no `ENDLOCAL` at all, so its child inherits the `dp0` the prologue set, and the plan carries it.
7
+ A bare `node` is now resolved the way `cmd` resolves it, which Windows CI settled rather than reasoning: the working directory before `PATH` unless `NoDefaultCurrentDirectoryInExePath` says otherwise, relative `PATH` entries anchored to that directory, `PATHEXT` extensions before the bare name, and a hit that is itself a batch file declined. There is no `process.execPath` fallback, which would run a different Node than the shell whenever a portable install or an overridden `PATH` is involved.
8
+ The existence test never takes its operand from the file: each recipe tests the one candidate it is about to run, built from the shim's own directory, and the file has to name exactly that. A path captured out of an untrusted file and handed to the filesystem is a file-existence oracle, and a UNC or device path turns a check into network I/O and authentication that reading a local shim never authorised. Relative, drive-relative, root-relative, UNC and device paths are refused before the file is opened. Existence is tri-state, because `existsSync` folds a permission error into "absent" and absent decides which program runs.
9
+ **What this costs.** Shims outside the recognised set are now declined and left to the original `EINVAL` rather than being run a way that might differ from your shell. Current npm and pnpm installs are covered. Not covered: `cmd-shim` 1.0.1 through 2.1.0 and 3.0.2, `@zkochan/cmd-shim` 2.x through 5.1.0, newer pnpm shims carrying `progArgs`, a pnpm shim whose pinned `nodeExecPath` is itself a `.cmd` or `.bat`, an npm shebang using a separated argument value such as `--require ./preload.cjs`, and a shim whose own relative target text contains a space. A directory with a space in its name is fine: that space lives in `%dp0%` and expands at run time. If one of those is yours, the issue tracker is the place, and a fork is a reasonable answer too, since Windows fixes here move at the speed of CI rather than of a developer with the machine in front of them.
10
+ - **`PATH` is read the way each platform reads it.** Building a child environment by spreading `process.env` produces a plain object, and that object is case-sensitive even on Windows, where the key is usually spelled `Path`. Every CLI could then read as missing. One shared rule now covers the outer command lookup, the lookup inside a shim recipe, and the environment a plan hands the child: exact name on POSIX, folded name on Windows, with the spelling Node itself would pass.
11
+
3
12
  ## 3.17.3 - 2026-08-16
4
13
 
5
14
  - **Windows: a successful read is no longer thrown away by its own cleanup ([#50](https://github.com/liustack/modlens/issues/50)).** The reporter saw `kimi-cli` run its full 45 seconds, finish the turn, and then fail with `EPERM` on a path that turned out to be modlens's own throwaway directory. It is not a kimi permission problem and no amount of granting it access would have helped: that directory is the provider's working directory, and Windows will not delete a directory a live process still holds open as its current directory. Something still held it when kimi's output arrived, which is ordinary: modlens stops waiting once the direct child exits, so a descendant, a language server, or a short-lived handle can outlive the output. `rmSync` threw, the throw came out of the `finally` that runs cleanup, and it replaced a perfectly good result with a failed attempt. Removal is best effort now, with one retry for the handle that is released a moment later, and a directory that still cannot go is left in the system temp directory for the OS to collect. Every subprocess provider was exposed to this on Windows, not just `kimi-cli`; kimi only made it reliable enough to notice. Thanks to @StarChen-Cycler, whose report pinned the failing path to the workdir itself, which is what made the cause findable.
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.17.3`, and the text-only DeepSeek model behind dsh reads images through a native `modlens_read_image` tool. Updating is the same command again. The version is named rather than `@latest` on purpose: pnpm 11 holds back releases published in the last 24 hours and resolves the tag against what survives, so `@latest` would install whatever shipped a day ago ([details](docs/harness-setup.md#keeping-it-up-to-date)).
37
+ **🥇 The first vision plugin for DeepSeek Harness (dsh):** one command, `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.18.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
 
@@ -169,6 +169,8 @@ npx -y skills add liustack/vibemaster -g
169
169
 
170
170
  The projects worth recommending in the DeepSeek Harness ecosystem.
171
171
 
172
+ - 🖥️ **[DeepSeek Harness Desktop](https://github.com/anywhere-labs/deepseek-harness-desktop)** — A desktop front end for DeepSeek Harness. Start and manage the Harness service on your own machine without installing Node.js or running a command. A plugin market, remote control from a phone, and IM channels are on its roadmap. [Site](https://www.dshdesktop.cn)
173
+ 为 DeepSeek Harness 生态打造的现代化桌面端。不用配置 Node.js,也不用敲命令,就能启动和管理本机的 Harness 服务。后续还会支持插件市场、移动端远程控制和 IM Channels。[官网](https://www.dshdesktop.cn)
172
174
  - 🛒 **[dsh-market](https://github.com/dsh-market/dsh-market)** — The plugin market inside DeepSeek Harness. Browse 800+ community plugins with category filters and screenshot previews, one-click install and update, and live theme switching. Most need no restart.
173
175
  DeepSeek Harness 的可视化插件市场。设置页里直接逛社区全部 800+ 插件:分类筛选、截图预览、一键安装与更新、主题即点即换,装完多数免重启。
174
176
 
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.17.3`,dsh 背后的纯文本 DeepSeek 模型即可通过原生 `modlens_read_image` 工具读图。更新就是再跑一遍同一条命令。这里点名版本号而不用 `@latest` 是有意的:pnpm 11 会扣住最近 24 小时内发布的版本,dist-tag 只在剩下的里面解析,用 `@latest` 装到的会是一天前发布的那个([细节](docs/harness-setup.zh-CN.md#保持更新))。
37
+ **🥇 全网第一个支持 DeepSeek Harness(dsh)的外挂视觉识别插件:**一条命令 `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.18.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.17.3
72
+ npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.18.0
73
73
  ```
74
74
 
75
75
  装完即有 `modlens_read_image` 工具,选「(modlens vision)」模型变体即可直接粘贴识图。引擎配置同样在 `~/.modlens`,详见[宿主接入](docs/harness-setup.zh-CN.md)。
@@ -181,6 +181,8 @@ Codex 桌面 App 中识别一张推文截图。作者、配文、照片内容(
181
181
 
182
182
  DeepSeek Harness 生态里最值得推荐的项目。
183
183
 
184
+ - 🖥️ **[DeepSeek Harness Desktop](https://github.com/anywhere-labs/deepseek-harness-desktop)** — 为 DeepSeek Harness 生态打造的现代化桌面端。不用配置 Node.js,也不用敲命令,就能启动和管理本机的 Harness 服务。后续还会支持插件市场、移动端远程控制和 IM Channels。[官网](https://www.dshdesktop.cn)
185
+ A desktop front end for DeepSeek Harness. Start and manage the Harness service on your own machine without installing Node.js or running a command. A plugin market, remote control from a phone, and IM channels are on its roadmap. [Site](https://www.dshdesktop.cn)
184
186
  - 🛒 **[dsh-market](https://github.com/dsh-market/dsh-market)** — DeepSeek Harness 的可视化插件市场。设置页里直接逛社区全部 800+ 插件:分类筛选、截图预览、一键安装与更新、主题即点即换,装完多数免重启。
185
187
  The plugin market inside DeepSeek Harness. Browse 800+ community plugins with category filters and screenshot previews, one-click install and update, and live theme switching. Most need no restart.
186
188
 
package/dist/main.js CHANGED
@@ -1863,6 +1863,57 @@ function maskKey(key) {
1863
1863
  }
1864
1864
  return `${key.slice(0, 6)}...${key.slice(-2)}`;
1865
1865
  }
1866
+ function envValue(env, name, platform = process.platform) {
1867
+ if (platform !== "win32") {
1868
+ return env[name];
1869
+ }
1870
+ const key = windowsKeyFor(env, name);
1871
+ return key === void 0 ? void 0 : env[key];
1872
+ }
1873
+ function windowsKeyFor(env, name) {
1874
+ const folded = name.toUpperCase();
1875
+ return enumerateEnvKeys(env).sort().find((candidate) => candidate.toUpperCase() === folded);
1876
+ }
1877
+ function enumerateEnvKeys(env) {
1878
+ const keys = [];
1879
+ for (const key in env) {
1880
+ keys.push(key);
1881
+ }
1882
+ return keys;
1883
+ }
1884
+ function canonicalWindowsEnv(env) {
1885
+ const seen = /* @__PURE__ */ new Set();
1886
+ const entries = [];
1887
+ for (const key of enumerateEnvKeys(env).sort()) {
1888
+ const folded = key.toUpperCase();
1889
+ if (seen.has(folded)) {
1890
+ continue;
1891
+ }
1892
+ seen.add(folded);
1893
+ const value = env[key];
1894
+ if (value !== void 0) {
1895
+ entries.push([key, value]);
1896
+ }
1897
+ }
1898
+ return Object.fromEntries(entries);
1899
+ }
1900
+ function withWindowsEnvAssignment(env, name, value) {
1901
+ const canonical = canonicalWindowsEnv(env);
1902
+ const key = windowsKeyFor(canonical, name) ?? name;
1903
+ const folded = name.toUpperCase();
1904
+ const rest = Object.fromEntries(
1905
+ Object.entries(canonical).filter(([candidate]) => candidate.toUpperCase() !== folded)
1906
+ );
1907
+ return { ...rest, [key]: value };
1908
+ }
1909
+ function withoutWindowsEnvVariable(env, name) {
1910
+ const folded = name.toUpperCase();
1911
+ return Object.fromEntries(
1912
+ Object.entries(canonicalWindowsEnv(env)).filter(
1913
+ ([candidate]) => candidate.toUpperCase() !== folded
1914
+ )
1915
+ );
1916
+ }
1866
1917
  const PROVIDER_DESCRIPTORS = [
1867
1918
  {
1868
1919
  name: "antigravity-cli",
@@ -1902,8 +1953,11 @@ const PROVIDER_DESCRIPTORS = [
1902
1953
  }
1903
1954
  ];
1904
1955
  function findOnPath(bin, env) {
1905
- const dirs = (env.PATH ?? "").split(path.delimiter).filter(Boolean);
1906
- const suffixes = process.platform === "win32" ? [...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean), ""] : [""];
1956
+ const dirs = (envValue(env, "PATH") ?? "").split(path.delimiter).filter(Boolean);
1957
+ const suffixes = process.platform === "win32" ? [
1958
+ ...(envValue(env, "PATHEXT") ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean),
1959
+ ""
1960
+ ] : [""];
1907
1961
  for (const dir of dirs) {
1908
1962
  for (const suffix of suffixes) {
1909
1963
  const full = path.join(dir, bin + suffix);
@@ -1965,190 +2019,253 @@ function providerChain(kind, config2, env = process.env) {
1965
2019
  }
1966
2020
  return names.filter((name) => providerAvailable(name, config2, env)).map((name) => resolveProvider(name));
1967
2021
  }
1968
- function tokenizeCmdLine(line) {
1969
- const pattern = /(?:"[^"]*"|[^\s"])+/g;
1970
- const args = line.match(pattern) ?? [];
1971
- if (line.replace(pattern, "").trim() !== "") {
1972
- return null;
2022
+ function existenceOf(target) {
2023
+ try {
2024
+ fs.statSync(target);
2025
+ return "present";
2026
+ } catch (error) {
2027
+ return error.code === "ENOENT" ? "absent" : "unknown";
1973
2028
  }
1974
- return args.map((raw, index) => {
1975
- const text = index === 0 ? raw.replace(/^@/, "") : raw;
1976
- const whole = /^"([^"]*)"$/.exec(text);
1977
- return whole ? { value: whole[1], quoted: true } : { value: text, quoted: false };
1978
- });
1979
2029
  }
1980
- function expandShimPath(token, shimDir) {
1981
- const relative = /^%~?dp0%?\\?(.*)$/i.exec(token);
1982
- if (relative) {
1983
- return path.win32.join(shimDir, relative[1]);
1984
- }
1985
- if (path.win32.isAbsolute(token)) {
1986
- return token;
2030
+ const REAL_DEPS = {
2031
+ platform: process.platform,
2032
+ readFileSync: (p) => fs.readFileSync(p, "utf-8"),
2033
+ resolveOnPath: findOnPath,
2034
+ existence: existenceOf
2035
+ };
2036
+ const PATHEXT_EDIT = /^@?SET PATHEXT=%PATHEXT:;\.([A-Z]+);=;%$/;
2037
+ function withPathextEdit(env, removed) {
2038
+ const canonical = canonicalWindowsEnv(env);
2039
+ const key = Object.keys(canonical).find((name) => name.toUpperCase() === "PATHEXT");
2040
+ const current = key === void 0 ? "" : canonical[key] ?? "";
2041
+ const needle = new RegExp(`;\\.${removed};`, "gi");
2042
+ const edited = current.replace(needle, ";");
2043
+ if (edited === "") {
2044
+ return withoutWindowsEnvVariable(canonical, "PATHEXT");
2045
+ }
2046
+ return withWindowsEnvAssignment(canonical, "PATHEXT", edited);
2047
+ }
2048
+ const DEFAULT_PATHEXT = ".COM;.EXE;.BAT;.CMD";
2049
+ function resolveLikeCmd(name, env, cwd, deps) {
2050
+ const effectiveEnv = canonicalWindowsEnv(env);
2051
+ const exts = (Object.entries(effectiveEnv).find(([key]) => key.toUpperCase() === "PATHEXT")?.[1] ?? DEFAULT_PATHEXT).split(";").map((ext) => ext.trim()).filter(Boolean);
2052
+ const skipCwd = Object.keys(effectiveEnv).some(
2053
+ (key) => key.toUpperCase() === "NODEFAULTCURRENTDIRECTORYINEXEPATH"
2054
+ );
2055
+ const pathValue = Object.entries(effectiveEnv).find(([key]) => key.toUpperCase() === "PATH")?.[1] ?? "";
2056
+ const effectiveCwd = cwd ?? process.cwd();
2057
+ const dirs = [
2058
+ ...skipCwd ? [] : [effectiveCwd],
2059
+ ...pathValue.split(";").filter(Boolean).map((dir) => path.win32.resolve(effectiveCwd, dir))
2060
+ ];
2061
+ for (const dir of dirs) {
2062
+ for (const suffix of [...exts, ""]) {
2063
+ const candidate = path.win32.join(dir, `${name}${suffix}`);
2064
+ const found = deps.existence(candidate);
2065
+ if (found === "unknown") {
2066
+ return null;
2067
+ }
2068
+ if (found === "present") {
2069
+ return /\.(cmd|bat)$/i.test(candidate) ? null : candidate;
2070
+ }
2071
+ }
1987
2072
  }
1988
2073
  return null;
1989
2074
  }
1990
- const CMD_SYNTAX = /"|%~?\d/;
1991
- const CMD_CONTROL = /[\^&|<>()]/;
1992
- function literalToken(token, shimDir) {
1993
- const text = token.value;
1994
- if (CMD_SYNTAX.test(text) || !token.quoted && CMD_CONTROL.test(text)) {
1995
- return null;
2075
+ function templatePath(text, shimDir) {
2076
+ const rooted = /^%(?:dp0%|~dp0)\\?(.*)$/i.exec(text);
2077
+ if (!rooted) {
2078
+ return path.win32.isAbsolute(text) && !text.includes("%") ? text : null;
1996
2079
  }
1997
- const substituted = text.replace(/%dp0%|%~dp0/gi, `${shimDir}\\`);
1998
- if (substituted.includes("%")) {
2080
+ const rest = rooted[1];
2081
+ if (rest.includes("%") || rest === "") {
1999
2082
  return null;
2000
2083
  }
2001
- return /^(%dp0%|%~dp0)/i.test(text) ? path.win32.normalize(substituted) : substituted;
2084
+ return path.win32.normalize(path.win32.join(shimDir, rest));
2002
2085
  }
2003
- function isNodeInterpreter(token) {
2004
- return /^node(\.exe)?$/i.test(path.win32.basename(token));
2086
+ function isFullyQualifiedLocalPath(target) {
2087
+ return /^[A-Za-z]:[\\/]/.test(target);
2005
2088
  }
2006
- function carriesForeignEnv(content) {
2007
- const setRe = /^\s*@?SET\s+"?([A-Za-z_][A-Za-z0-9_]*)=/gim;
2008
- let match;
2009
- while ((match = setRe.exec(content)) !== null) {
2010
- const name = match[1].toLowerCase();
2011
- if (name !== "dp0" && name !== "_prog" && name !== "pathext") {
2012
- return true;
2013
- }
2089
+ const FLAG = /^--?[A-Za-z0-9][-A-Za-z0-9._]*(?:=[-A-Za-z0-9._/\\:]+)?$/;
2090
+ function interpreterTail(middle, shimDir) {
2091
+ const tokens = middle.trim().split(/\s+/).filter((token) => token !== "");
2092
+ if (tokens.length === 0) {
2093
+ return null;
2014
2094
  }
2015
- return false;
2095
+ const quoted = /^"([^"]*)"$/.exec(tokens[tokens.length - 1]);
2096
+ if (!quoted) {
2097
+ return null;
2098
+ }
2099
+ const entry = templatePath(quoted[1], shimDir);
2100
+ if (entry === null) {
2101
+ return null;
2102
+ }
2103
+ const flags = tokens.slice(0, -1);
2104
+ return flags.every((flag) => FLAG.test(flag)) ? [...flags, entry] : null;
2016
2105
  }
2017
- function progIsNode(content, shimDir) {
2018
- const progRe = /^\s*@?SET\s+"?_prog=([^"\r\n]*)"?/gim;
2019
- const values = [];
2020
- let match;
2021
- while ((match = progRe.exec(content)) !== null) {
2022
- values.push(match[1].trim());
2023
- }
2024
- if (values.length === 0) {
2025
- return { ok: false };
2026
- }
2027
- let absolute;
2028
- for (const value of values) {
2029
- const expanded = expandShimPath(value, shimDir) ?? value;
2030
- if (!isNodeInterpreter(expanded)) {
2031
- return { ok: false };
2032
- }
2033
- if (path.win32.isAbsolute(value)) {
2034
- absolute = value;
2035
- }
2036
- }
2037
- return { ok: true, ...absolute ? { absolute } : {} };
2038
- }
2039
- const STRUCTURAL_LINE = [
2040
- /^\s*$/,
2041
- /^\s*@?ECHO\s+off\s*$/i,
2042
- /^\s*@?SETLOCAL\s*$/i,
2043
- /^\s*@?ENDLOCAL\s*$/i,
2044
- /^\s*GOTO\s+\S+\s*$/i,
2045
- /^\s*:\S+\s*$/,
2046
- /^\s*EXIT\s+\/b\s*$/i,
2047
- /^\s*CALL\s+:\S+\s*$/i,
2048
- /^\s*@?SET\s+dp0=%~dp0\s*$/i,
2049
- /^\s*@?SET\s+"?_prog=[^"\r\n]*"?\s*$/i,
2050
- /^\s*@?SET\s+PATHEXT=%PATHEXT:[^%]*%\s*$/i,
2051
- /^\s*@?IF\s+EXIST\s+"[^"]*"\s*\(\s*$/i,
2052
- /^\s*\)\s*ELSE\s*\(\s*$/i,
2053
- /^\s*\)\s*$/
2054
- ];
2055
- function parseCmdShimTarget(cmdPath, content) {
2056
- const shimDir = path.win32.dirname(cmdPath);
2057
- if (carriesForeignEnv(content)) {
2106
+ function nodeRecipe(shimDir, tail2, effective, env, cwd, deps) {
2107
+ const local = path.win32.join(shimDir, "node.exe");
2108
+ const found = deps.existence(local);
2109
+ if (found === "unknown") {
2058
2110
  return null;
2059
2111
  }
2060
- const lines = content.split(/\r?\n/);
2061
- const executionLines = [];
2062
- for (const line of lines) {
2063
- if (STRUCTURAL_LINE.some((pattern) => pattern.test(line))) {
2064
- continue;
2065
- }
2066
- if (!line.includes("%*")) {
2067
- return null;
2068
- }
2069
- executionLines.push(line);
2112
+ if (found === "present") {
2113
+ return {
2114
+ command: local,
2115
+ args: tail2,
2116
+ ...effective.present === env ? {} : { env: effective.present }
2117
+ };
2118
+ }
2119
+ const resolved = resolveLikeCmd("node", effective.absent, cwd, deps);
2120
+ return resolved === null ? null : {
2121
+ command: resolved,
2122
+ args: tail2,
2123
+ ...effective.absent === env ? {} : { env: effective.absent }
2124
+ };
2125
+ }
2126
+ const NPM_PROLOGUE = [
2127
+ "@ECHO off",
2128
+ "GOTO start",
2129
+ ":find_dp0",
2130
+ "SET dp0=%~dp0",
2131
+ "EXIT /b",
2132
+ ":start",
2133
+ "SETLOCAL",
2134
+ "CALL :find_dp0"
2135
+ ];
2136
+ const NPM_PREFIX = "endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & ";
2137
+ const NPM_EXEC_LEGACY = /^"%_prog%"(.*)%\*$/;
2138
+ const NPM_EXEC_CURRENT = /^set PATHEXT=%PATHEXT:;\.([A-Z]+);=;% & "%_prog%"(.*)%\*$/;
2139
+ const NPM_NATIVE_EXEC = /^"([^"]*)"\s+%\*$/;
2140
+ const PNPM_IF = /^@IF EXIST "([^"]*)" \($/;
2141
+ const PNPM_ARM = /^"([^"]*)"(.*)%\*$/;
2142
+ const PNPM_BARE_ARM = /^node(.*)%\*$/;
2143
+ const PNPM_ONELINE = /^@"([^"]*)"(.*)%\*$/;
2144
+ function normalizeLines(content) {
2145
+ const lines = content.split(/\r?\n/).map((line) => line.trim());
2146
+ while (lines.length > 0 && lines[lines.length - 1] === "") {
2147
+ lines.pop();
2148
+ }
2149
+ return lines;
2150
+ }
2151
+ function matchNpm(lines, shimDir, env, cwd, deps) {
2152
+ if (lines.length < NPM_PROLOGUE.length) {
2153
+ return null;
2070
2154
  }
2071
- if (executionLines.length === 0) {
2155
+ if (!NPM_PROLOGUE.every((expected, index) => lines[index] === expected)) {
2072
2156
  return null;
2073
2157
  }
2074
- let agreed = null;
2075
- for (const line of executionLines) {
2076
- const parsed = parseExecutionLine(line, content, shimDir);
2077
- if (!parsed) {
2158
+ const body = lines.slice(NPM_PROLOGUE.length).filter((line) => line !== "");
2159
+ if (body.length === 1) {
2160
+ const native = NPM_NATIVE_EXEC.exec(body[0]);
2161
+ if (!native) {
2078
2162
  return null;
2079
2163
  }
2080
- if (agreed && !sameTarget(agreed, parsed)) {
2164
+ const target = templatePath(native[1], shimDir);
2165
+ if (target === null || !/\.exe$/i.test(target)) {
2081
2166
  return null;
2082
2167
  }
2083
- agreed ??= parsed;
2168
+ const normalizedDir = path.win32.normalize(shimDir);
2169
+ const dp0 = normalizedDir.endsWith("\\") ? normalizedDir : `${normalizedDir}\\`;
2170
+ return {
2171
+ command: target,
2172
+ args: [],
2173
+ env: withWindowsEnvAssignment(env, "dp0", dp0)
2174
+ };
2084
2175
  }
2085
- return agreed;
2086
- }
2087
- function sameTarget(a, b) {
2088
- return a.nodeExec === b.nodeExec && a.args.length === b.args.length && a.args.every((value, index) => value === b.args[index]);
2176
+ if (body[0] !== 'IF EXIST "%dp0%\\node.exe" (') return null;
2177
+ if (body[1] !== 'SET "_prog=%dp0%\\node.exe"') return null;
2178
+ if (body[2] !== ") ELSE (") return null;
2179
+ if (body[3] !== 'SET "_prog=node"') return null;
2180
+ if (body.length === 7) {
2181
+ if (!PATHEXT_EDIT.test(body[4])) return null;
2182
+ if (body[5] !== ")") return null;
2183
+ if (!body[6].startsWith(NPM_PREFIX)) return null;
2184
+ const exec = NPM_EXEC_LEGACY.exec(body[6].slice(NPM_PREFIX.length));
2185
+ if (!exec) return null;
2186
+ const tail2 = interpreterTail(exec[1], shimDir);
2187
+ return tail2 === null ? null : nodeRecipe(shimDir, tail2, { present: env, absent: env }, env, cwd, deps);
2188
+ }
2189
+ if (body.length === 6) {
2190
+ if (body[4] !== ")") return null;
2191
+ if (!body[5].startsWith(NPM_PREFIX)) return null;
2192
+ const exec = NPM_EXEC_CURRENT.exec(body[5].slice(NPM_PREFIX.length));
2193
+ if (!exec) return null;
2194
+ const tail2 = interpreterTail(exec[2], shimDir);
2195
+ if (tail2 === null) return null;
2196
+ const edited = withPathextEdit(env, exec[1]);
2197
+ return nodeRecipe(shimDir, tail2, { present: edited, absent: edited }, env, cwd, deps);
2198
+ }
2199
+ return null;
2089
2200
  }
2090
- function parseExecutionLine(line, content, shimDir) {
2091
- {
2092
- const tokens = tokenizeCmdLine(line);
2093
- if (!tokens) {
2094
- return null;
2095
- }
2096
- const forwardIndex = tokens.findIndex((token) => !token.quoted && token.value === "%*");
2097
- if (forwardIndex < 2 || forwardIndex !== tokens.length - 1) {
2201
+ function matchPnpm(lines, shimDir, env, cwd, deps) {
2202
+ if (lines[0] !== "@SETLOCAL") {
2203
+ return null;
2204
+ }
2205
+ const body = lines.slice(1).filter((line) => line !== "");
2206
+ if (body.length === 1) {
2207
+ const one = PNPM_ONELINE.exec(body[0]);
2208
+ if (!one) {
2098
2209
  return null;
2099
2210
  }
2100
- const runTokens = tokens.slice(0, forwardIndex);
2101
- const lastAmp = runTokens.reduce(
2102
- (found, token, index) => !token.quoted && token.value === "&" ? index : found,
2103
- -1
2104
- );
2105
- let words = lastAmp >= 0 ? runTokens.slice(lastAmp + 1) : runTokens;
2106
- if (words.length < 2) {
2211
+ const program2 = templatePath(one[1], shimDir);
2212
+ if (program2 === null) {
2107
2213
  return null;
2108
2214
  }
2109
- let nodeExec;
2110
- if (/^-S(\.exe)?$/i.test(path.win32.basename(words[0].value))) {
2111
- words = words.slice(1);
2112
- }
2113
- const interpreter = words[0].value;
2114
- if (interpreter === "%_prog%") {
2115
- const prog = progIsNode(content, shimDir);
2116
- if (!prog.ok) {
2117
- return null;
2118
- }
2119
- nodeExec = prog.absolute;
2120
- } else {
2121
- const expanded = expandShimPath(interpreter, shimDir) ?? interpreter;
2122
- if (!isNodeInterpreter(expanded)) {
2123
- return null;
2124
- }
2125
- if (path.win32.isAbsolute(interpreter)) {
2126
- nodeExec = interpreter;
2127
- }
2128
- }
2129
- const args = [];
2130
- let expandable = true;
2131
- for (const word of words.slice(1)) {
2132
- const expanded = literalToken(word, shimDir);
2133
- if (expanded === null) {
2134
- expandable = false;
2135
- break;
2136
- }
2137
- args.push(expanded);
2138
- }
2139
- if (!expandable || args.length === 0) {
2140
- return null;
2215
+ if (one[2].trim() === "") {
2216
+ return /\.exe$/i.test(program2) ? { command: program2, args: [] } : null;
2141
2217
  }
2142
- return { args, ...nodeExec ? { nodeExec } : {} };
2218
+ const tail22 = interpreterTail(one[2], shimDir);
2219
+ return tail22 === null || /\.(cmd|bat)$/i.test(program2) ? null : { command: program2, args: tail22 };
2143
2220
  }
2221
+ if (body.length !== 6) {
2222
+ return null;
2223
+ }
2224
+ const opened = PNPM_IF.exec(body[0]);
2225
+ if (!opened) return null;
2226
+ const local = path.win32.join(shimDir, "node.exe");
2227
+ const candidate = templatePath(opened[1], shimDir);
2228
+ if (candidate === null || candidate.toLowerCase() !== local.toLowerCase()) {
2229
+ return null;
2230
+ }
2231
+ const present = PNPM_ARM.exec(body[1]);
2232
+ if (!present) return null;
2233
+ const presentProgram = templatePath(present[1], shimDir);
2234
+ if (presentProgram === null || presentProgram.toLowerCase() !== local.toLowerCase()) {
2235
+ return null;
2236
+ }
2237
+ if (body[2] !== ") ELSE (") return null;
2238
+ const edit = PATHEXT_EDIT.exec(body[3]);
2239
+ if (!edit) return null;
2240
+ const absent = PNPM_BARE_ARM.exec(body[4]);
2241
+ if (!absent) return null;
2242
+ if (body[5] !== ")") return null;
2243
+ const tail2 = interpreterTail(present[2], shimDir);
2244
+ const otherTail = interpreterTail(absent[1], shimDir);
2245
+ if (tail2 === null || otherTail === null || tail2.join(" ") !== otherTail.join(" ")) {
2246
+ return null;
2247
+ }
2248
+ return nodeRecipe(
2249
+ shimDir,
2250
+ tail2,
2251
+ { present: env, absent: withPathextEdit(env, edit[1]) },
2252
+ env,
2253
+ cwd,
2254
+ deps
2255
+ );
2144
2256
  }
2145
- const REAL_DEPS = {
2146
- platform: process.platform,
2147
- readFileSync: (p) => fs.readFileSync(p, "utf-8"),
2148
- resolveOnPath: findOnPath,
2149
- execPath: process.execPath
2150
- };
2151
- function resolveSpawnPlan(command, args, env = process.env, deps = REAL_DEPS) {
2257
+ function recognizeShim(cmdPath, content, env, cwd, deps = REAL_DEPS) {
2258
+ if (!isFullyQualifiedLocalPath(cmdPath)) {
2259
+ return null;
2260
+ }
2261
+ const shimDir = path.win32.dirname(cmdPath);
2262
+ const lines = normalizeLines(content);
2263
+ if (lines.length === 0) {
2264
+ return null;
2265
+ }
2266
+ return matchNpm(lines, shimDir, env, cwd, deps) ?? matchPnpm(lines, shimDir, env, cwd, deps);
2267
+ }
2268
+ function resolveSpawnPlan(command, args, env = process.env, cwd, deps = REAL_DEPS) {
2152
2269
  if (deps.platform !== "win32") {
2153
2270
  return { command, args };
2154
2271
  }
@@ -2159,19 +2276,23 @@ function resolveSpawnPlan(command, args, env = process.env, deps = REAL_DEPS) {
2159
2276
  if (!/\.(cmd|bat)$/i.test(path.win32.basename(resolved))) {
2160
2277
  return { command: resolved, args };
2161
2278
  }
2279
+ if (!isFullyQualifiedLocalPath(resolved)) {
2280
+ return { command: resolved, args };
2281
+ }
2162
2282
  let content;
2163
2283
  try {
2164
2284
  content = deps.readFileSync(resolved);
2165
2285
  } catch {
2166
2286
  return { command: resolved, args };
2167
2287
  }
2168
- const target = parseCmdShimTarget(resolved, content);
2169
- if (!target) {
2288
+ const recipe = recognizeShim(resolved, content, env, cwd, deps);
2289
+ if (recipe === null) {
2170
2290
  return { command: resolved, args };
2171
2291
  }
2172
2292
  return {
2173
- command: target.nodeExec ?? deps.execPath,
2174
- args: [...target.args, ...args]
2293
+ command: recipe.command,
2294
+ args: [...recipe.args, ...args],
2295
+ ...recipe.env ? { env: recipe.env } : {}
2175
2296
  };
2176
2297
  }
2177
2298
  const VISION_MODEL_PATTERNS = [
@@ -2214,7 +2335,11 @@ function defaultRunCli(bin, args, timeoutMs) {
2214
2335
  return execFileSync(plan.command, plan.args, {
2215
2336
  encoding: "utf-8",
2216
2337
  timeout: timeoutMs,
2217
- stdio: "pipe"
2338
+ stdio: "pipe",
2339
+ // A recognised shim hands back the whole environment its child would
2340
+ // have got, so the probe runs under what the shell would have given
2341
+ // it rather than under ours (issue #43).
2342
+ ...plan.env ? { env: plan.env } : {}
2218
2343
  });
2219
2344
  }
2220
2345
  function timed(run) {
@@ -2798,7 +2923,8 @@ function fetchPiKey(piPath, modelId, provider, timeoutMs) {
2798
2923
  const key = execFileSync(plan.command, plan.args, {
2799
2924
  encoding: "utf-8",
2800
2925
  stdio: ["ignore", "pipe", "pipe"],
2801
- timeout: timeoutMs
2926
+ timeout: timeoutMs,
2927
+ ...plan.env ? { env: plan.env } : {}
2802
2928
  }).trim();
2803
2929
  if (!key) {
2804
2930
  throw new Error("empty");
@@ -3134,13 +3260,20 @@ function emptyWorkdir() {
3134
3260
  function runCommand(providerName, invocation, timeoutMs, describeFailure) {
3135
3261
  const runStartedAt = Date.now();
3136
3262
  return new Promise((resolve, reject) => {
3137
- const plan = resolveSpawnPlan(invocation.command, invocation.args);
3263
+ const childEnv = invocation.env ? { ...process.env, ...invocation.env } : void 0;
3264
+ const plan = resolveSpawnPlan(
3265
+ invocation.command,
3266
+ invocation.args,
3267
+ childEnv ?? process.env,
3268
+ invocation.cwd
3269
+ );
3270
+ const spawnEnv = plan.env ?? childEnv;
3138
3271
  const child = spawn(plan.command, plan.args, {
3139
3272
  cwd: invocation.cwd,
3140
3273
  stdio: ["ignore", "pipe", "pipe"],
3141
3274
  // A provider may mark its own child, which is how kimi-cli tells a
3142
3275
  // modlens started by kimi that running kimi again would loop.
3143
- ...invocation.env ? { env: { ...process.env, ...invocation.env } } : {}
3276
+ ...spawnEnv ? { env: spawnEnv } : {}
3144
3277
  });
3145
3278
  const outDecoder = new TextDecoder("utf-8");
3146
3279
  const errDecoder = new TextDecoder("utf-8");
@@ -4432,7 +4565,7 @@ function parsePositiveInt(raw, flag) {
4432
4565
  }
4433
4566
  return Number.parseInt(raw, 10);
4434
4567
  }
4435
- program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.17.3");
4568
+ program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.18.0");
4436
4569
  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(
4437
4570
  "--extra-body <json>",
4438
4571
  `JSON merged into the API request body, e.g. '{"thinking":{"type":"disabled"}}'`
@@ -4542,7 +4675,7 @@ program.command("doctor").description(
4542
4675
  configPath: CONFIG_PATH,
4543
4676
  // Lets doctor name an installed skill copy that is older than
4544
4677
  // the CLI reporting on it (issue #33).
4545
- version: "3.17.3"
4678
+ version: "3.18.0"
4546
4679
  });
4547
4680
  const output = options.json ? JSON.stringify(report, null, 2) : renderDoctorReport(report);
4548
4681
  process.stdout.write(`${output}
@@ -55,7 +55,7 @@ OpenCode with DeepSeek: `opencode auth login`, pick DeepSeek and paste the key (
55
55
  dsh is different from the other harnesses: modlens plugs in as a native tool, not a prompt-triggered skill. The package itself is a dsh bundle, so one command installs it into a profile:
56
56
 
57
57
  ```sh
58
- npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.17.3
58
+ npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.18.0
59
59
  ```
60
60
 
61
61
  This registers a `modlens_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.
@@ -81,7 +81,7 @@ modlens ships often, and both install shapes freeze at whatever version they
81
81
  got. On dsh, re-run the install with the version named:
82
82
 
83
83
  ```sh
84
- npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.17.3
84
+ npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.18.0
85
85
  ```
86
86
 
87
87
  `npm view @liustack/modlens version` prints the current one, and this page is
@@ -55,7 +55,7 @@ OpenCode 接 DeepSeek:执行 `opencode auth login`,选择 DeepSeek 并粘贴
55
55
  dsh 与其他 harness 不同:modlens 以原生工具的形式接入,而不是靠提示词触发的 skill。本包自身就是一个 dsh bundle,一条命令即可装进某个 profile:
56
56
 
57
57
  ```sh
58
- npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.17.3
58
+ npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.18.0
59
59
  ```
60
60
 
61
61
  这会注册一个 `modlens_read_image` 工具,它的 schema 随每次请求抵达模型(不靠触发启发式),运行同一个包里自带的 modlens CLI,并把结构化证据作为工具的标准 JSON 输出返回。引擎、复用授权和 guard 规则仍在 `~/.modlens/config.json` 里,与其他所有 harness 共享。dsh 还在开发者预览阶段,插件接口可能变化。这个插件刻意保持很小的接触面(原生工具注册、视觉变体所用的 llm 适配层、附件读取器,以及一个 agent 执行前钩子),其中任何一处变动,它都会大声报错而不是无声退化。
@@ -71,7 +71,7 @@ dsh 的网页用户面前没有终端,所以引擎设置有一张卡片,在*
71
71
  modlens 发布很频繁,而两种安装形态都会冻结在装进来的那个版本上。dsh 上重跑一遍安装即可,版本号要点名:
72
72
 
73
73
  ```sh
74
- npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.17.3
74
+ npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.18.0
75
75
  ```
76
76
 
77
77
  `npm view @liustack/modlens version` 可以查到当前版本号,本页的版本号则由发布流程自动写入。
@@ -163,7 +163,7 @@ simply lands on an older one. Name the exact version instead, which pnpm treats
163
163
  as a deliberate request rather than a resolution:
164
164
 
165
165
  ```sh
166
- npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.17.3
166
+ npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.18.0
167
167
  ```
168
168
 
169
169
  `npm view @liustack/modlens version` prints the current one. pnpm 11 installs a named
@@ -178,7 +178,7 @@ file:
178
178
 
179
179
  ```yaml
180
180
  minimumReleaseAgeExclude:
181
- - '@liustack/modlens@3.17.3'
181
+ - '@liustack/modlens@3.18.0'
182
182
  ```
183
183
 
184
184
  Or lift the gate for a single command, which lifts it for everything that
@@ -144,7 +144,7 @@ dsh profile 装到的是旧版 modlens。`dsh.bundle` 声明从 3.9.0 起才存
144
144
  `@latest` 绕不开这一层,本页早先的说法是错的。冷静期先把候选版本过滤掉,dist-tag 才在剩下的里面解析,于是它直接落到了更旧的那个上。改成写死精确版本号,pnpm 会把它当作一次明确的指定,而不是一次解析:
145
145
 
146
146
  ```sh
147
- npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.17.3
147
+ npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.18.0
148
148
  ```
149
149
 
150
150
  `npm view @liustack/modlens version` 可以查到当前版本号。pnpm 11 会装上被点名的版本,11.1.3 起还会把它作为一条已批准的例外写进该 profile 的 `pnpm-workspace.yaml`,其余所有包和 modlens 以后的版本仍然留在窗口后面。
@@ -153,7 +153,7 @@ npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.17.3
153
153
 
154
154
  ```yaml
155
155
  minimumReleaseAgeExclude:
156
- - '@liustack/modlens@3.17.3'
156
+ - '@liustack/modlens@3.18.0'
157
157
  ```
158
158
 
159
159
  或者只为这一条命令解除冷静期,注意它解除的是这条命令解析到的所有包,不只 modlens:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liustack/modlens",
3
- "version": "3.17.3",
3
+ "version": "3.18.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.17.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.18.0):
24
24
 
25
- 1. A `modlens` on `PATH` whose major version is 3 and is at least 3.17.3: `modlens <args>`.
26
- 2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.17.3 modlens <args>`.
27
- 3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.17.3 <args>`.
25
+ 1. A `modlens` on `PATH` whose major version is 3 and is at least 3.18.0: `modlens <args>`.
26
+ 2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.18.0 modlens <args>`.
27
+ 3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.18.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.
@@ -8,7 +8,7 @@ shell syntax.
8
8
 
9
9
  ## Pinned version
10
10
 
11
- - Pinned CLI version: 3.17.3
11
+ - Pinned CLI version: 3.18.0
12
12
  - npm package: `@liustack/modlens`
13
13
  - CLI binary name: `modlens`
14
14
 
@@ -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.17.3'
27
+ $Pinned = '3.18.0'
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.17.3"
25
+ PINNED="3.18.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"