@liustack/modlens 2.7.4 → 2.7.6

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/README.md CHANGED
@@ -138,9 +138,12 @@ Two more subcommands: `modlens config <init|set|show>` manages providers and key
138
138
  modlens recover-paste # newest pasted image, path printed as JSON
139
139
  modlens recover-paste --count 3 # the three newest
140
140
  modlens recover-paste --session <id> # exact session (skills pass ${CLAUDE_SESSION_ID})
141
+ modlens recover-paste --harness pi # force one harness's format
141
142
  # --transcript <path> overrides everything; --cwd <dir> sets the project directory
142
143
  ```
143
144
 
145
+ Recovered images are written 0600 into a 0700 directory, so nobody else on a shared machine can read them. Locating a session checks the cwd recorded inside the transcript as well as the directory, because directory slugs collide (`/tmp/a.b` and `/tmp/a-b` produce the same one) and without that check you can be handed a neighbouring project's images.
146
+
144
147
  ## Providers and config
145
148
 
146
149
  ModLens ships five vision providers. `antigravity-cli` stays the default: zero keys, pure free quota.
package/README.zh-CN.md CHANGED
@@ -138,9 +138,12 @@ modlens -i <图片路径或 URL> [选项]
138
138
  modlens recover-paste # 捞最新一张,路径以 JSON 打印
139
139
  modlens recover-paste --count 3 # 捞最近三张
140
140
  modlens recover-paste --session <id> # 精确会话(skill 会传 ${CLAUDE_SESSION_ID})
141
+ modlens recover-paste --harness pi # 强制按某家宿主的格式解析
141
142
  # --transcript <path> 优先级最高,--cwd <dir> 指定项目目录
142
143
  ```
143
144
 
145
+ 恢复出来的图片写成 0600、放进 0700 目录,共享机器上别人读不到。定位会话时除了目录,还会核对会话记录里写着的真实工作目录,因为目录 slug 会碰撞(`/tmp/a.b` 和 `/tmp/a-b` 算出同一个),不核对就可能把隔壁项目的图交给你。
146
+
144
147
  ## Provider 与配置
145
148
 
146
149
  ModLens 内置五个视觉 provider,默认还是 `antigravity-cli`:零 key,纯免费额度。
package/dist/main.js CHANGED
@@ -7,128 +7,6 @@ import { spawn } from "child_process";
7
7
  import * as os from "os";
8
8
  import * as crypto from "crypto";
9
9
  import { createRequire } from "module";
10
- const CONFIG_DIR = path.join(os.homedir(), ".modlens");
11
- const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
12
- const ENV_BINDINGS = {
13
- "gemini-api": { apiKey: "GEMINI_API_KEY" },
14
- openai: { apiKey: "OPENAI_API_KEY", baseUrl: "OPENAI_BASE_URL" },
15
- anthropic: { apiKey: "ANTHROPIC_API_KEY", baseUrl: "ANTHROPIC_BASE_URL" }
16
- };
17
- function loadConfigFile(configPath = CONFIG_PATH) {
18
- let raw;
19
- try {
20
- raw = fs.readFileSync(configPath, "utf-8");
21
- } catch {
22
- return {};
23
- }
24
- try {
25
- const parsed = JSON.parse(raw);
26
- if (!parsed || typeof parsed !== "object") {
27
- return {};
28
- }
29
- return parsed;
30
- } catch (error) {
31
- throw new Error(
32
- `Failed to parse ${configPath}: ${error.message}. Fix or delete the file.`
33
- );
34
- }
35
- }
36
- function defaultProviderName(config2) {
37
- return config2.provider?.trim() || "antigravity-cli";
38
- }
39
- const PROVIDER_ALIASES = {
40
- antigravity: "antigravity-cli",
41
- agy: "antigravity-cli",
42
- gemini: "gemini-api",
43
- claude: "claude-cli"
44
- };
45
- function resolveProviderSettings(providerName, config2, env = process.env) {
46
- const aliasNames = Object.entries(PROVIDER_ALIASES).filter(([, canonical]) => canonical === providerName).map(([alias]) => alias);
47
- const fromFile = {
48
- ...Object.assign({}, ...aliasNames.map((alias) => config2.providers?.[alias] ?? {})),
49
- ...config2.providers?.[providerName] ?? {}
50
- };
51
- const bindings = ENV_BINDINGS[providerName] ?? {};
52
- const settings = { ...fromFile };
53
- for (const [field, envName] of Object.entries(bindings)) {
54
- const value = env[envName]?.trim();
55
- if (value) {
56
- settings[field] = value;
57
- }
58
- }
59
- return settings;
60
- }
61
- function setConfigValue(dottedKey, value, configPath = CONFIG_PATH) {
62
- const config2 = loadConfigFile(configPath);
63
- if (dottedKey === "provider") {
64
- config2.provider = value;
65
- } else {
66
- const dot = dottedKey.indexOf(".");
67
- if (dot <= 0 || dot === dottedKey.length - 1) {
68
- throw new Error(
69
- `Invalid config key: ${dottedKey}. Use "provider" or "<provider>.<apiKey|baseUrl|model>".`
70
- );
71
- }
72
- const providerName = dottedKey.slice(0, dot);
73
- const field = dottedKey.slice(dot + 1);
74
- if (!["apiKey", "baseUrl", "model"].includes(field)) {
75
- throw new Error(`Unknown config field: ${field}. Use apiKey, baseUrl, or model.`);
76
- }
77
- config2.providers ??= {};
78
- config2.providers[providerName] ??= {};
79
- config2.providers[providerName][field] = value;
80
- }
81
- fs.mkdirSync(path.dirname(configPath), { recursive: true });
82
- fs.writeFileSync(configPath, `${JSON.stringify(config2, null, 2)}
83
- `, { mode: 384 });
84
- try {
85
- fs.chmodSync(configPath, 384);
86
- } catch {
87
- }
88
- }
89
- const CONFIG_TEMPLATE = {
90
- provider: "antigravity-cli",
91
- providers: {
92
- "antigravity-cli": { model: "gemini-3.6-flash-low" },
93
- "gemini-api": { apiKey: "", model: "gemini-3.6-flash" },
94
- openai: { baseUrl: "", apiKey: "", model: "" },
95
- anthropic: { apiKey: "", model: "claude-haiku-4-5-20251001" },
96
- "claude-cli": { model: "haiku" }
97
- }
98
- };
99
- function initConfigFile(configPath = CONFIG_PATH, force = false) {
100
- if (!force && fs.existsSync(configPath)) {
101
- throw new Error(`${configPath} already exists. Use --force to overwrite.`);
102
- }
103
- fs.mkdirSync(path.dirname(configPath), { recursive: true });
104
- fs.writeFileSync(configPath, `${JSON.stringify(CONFIG_TEMPLATE, null, 2)}
105
- `, { mode: 384 });
106
- try {
107
- fs.chmodSync(configPath, 384);
108
- } catch {
109
- }
110
- }
111
- function renderConfig(config2) {
112
- const masked = {
113
- ...config2,
114
- providers: Object.fromEntries(
115
- Object.entries(config2.providers ?? {}).map(([name, settings]) => [
116
- name,
117
- {
118
- ...settings,
119
- ...settings.apiKey ? { apiKey: maskKey(settings.apiKey) } : {}
120
- }
121
- ])
122
- )
123
- };
124
- return JSON.stringify(masked, null, 2);
125
- }
126
- function maskKey(key) {
127
- if (key.length <= 8) {
128
- return "****";
129
- }
130
- return `${key.slice(0, 6)}...${key.slice(-2)}`;
131
- }
132
10
  function buildVisionPrompt(options) {
133
11
  const readInstruction = options.imageKind === "inline" ? "Analyze the image attached to this message." : options.imageKind === "remote" ? `Fetch the image at this URL and analyze it: ${options.imageSource}` : `Read the image file at this path and analyze it: ${options.imageSource}`;
134
12
  const basePrompt = `${readInstruction}
@@ -370,6 +248,23 @@ function agyLogDir() {
370
248
  return path.join(os.homedir(), ".gemini", "antigravity-cli", "log");
371
249
  }
372
250
  const LOG_FRESHNESS_MS = 2 * 60 * 1e3;
251
+ function parseAgyLogTime(line, now = /* @__PURE__ */ new Date()) {
252
+ const match = /\b[IWEF](\d{2})(\d{2})\s+(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?/.exec(line);
253
+ if (!match) {
254
+ return null;
255
+ }
256
+ const [, month, day, hour, minute, second, fraction] = match;
257
+ const stamp = new Date(
258
+ now.getFullYear(),
259
+ Number(month) - 1,
260
+ Number(day),
261
+ Number(hour),
262
+ Number(minute),
263
+ Number(second),
264
+ fraction ? Number(fraction.slice(0, 3)) : 0
265
+ ).getTime();
266
+ return stamp - now.getTime() > 24 * 60 * 60 * 1e3 ? new Date(new Date(stamp).setFullYear(now.getFullYear() - 1)).getTime() : stamp;
267
+ }
373
268
  function readRecentAgyLog(since) {
374
269
  try {
375
270
  const dir = agyLogDir();
@@ -380,7 +275,11 @@ function readRecentAgyLog(since) {
380
275
  if (!newest || newest.mtime < since) {
381
276
  return "";
382
277
  }
383
- return fs.readFileSync(newest.full, "utf-8").slice(-8e3);
278
+ const recent = fs.readFileSync(newest.full, "utf-8").slice(-64e3).split("\n").filter((line) => {
279
+ const stamp = parseAgyLogTime(line);
280
+ return stamp !== null && stamp >= since;
281
+ });
282
+ return recent.join("\n");
384
283
  } catch {
385
284
  return "";
386
285
  }
@@ -743,11 +642,8 @@ Respond with ONE JSON object only, no markdown fences, no commentary. Fill this
743
642
  if (result === null) {
744
643
  throw new Error(`OpenAI-compatible API returned non-JSON output: ${truncate(text)}`);
745
644
  }
746
- const shaped = result;
747
- const missing = ["summary", "ocr", "layout", "semantics", "visual", "uncertainty"].filter(
748
- (field) => shaped[field] === void 0 || shaped[field] === null
749
- );
750
- if (missing.length > 0 || typeof shaped.summary !== "string" || typeof shaped.ocr !== "object" || !Array.isArray(shaped.uncertainty)) {
645
+ const missing = missingSchemaFields(result);
646
+ if (missing.length > 0) {
751
647
  throw new Error(
752
648
  `OpenAI-compatible API returned JSON that does not match the vision schema${missing.length > 0 ? ` (missing: ${missing.join(", ")})` : ""}. Retry, or switch to gemini-api / anthropic for enforced schemas. Got: ${truncate(text)}`
753
649
  );
@@ -761,6 +657,28 @@ Respond with ONE JSON object only, no markdown fences, no commentary. Fill this
761
657
  }
762
658
  };
763
659
  }
660
+ function missingSchemaFields(result) {
661
+ const missing = [];
662
+ const root = result ?? {};
663
+ const child = (key) => root[key] && typeof root[key] === "object" ? root[key] : {};
664
+ const expect = (path2, ok) => {
665
+ if (!ok) {
666
+ missing.push(path2);
667
+ }
668
+ };
669
+ expect("summary", typeof root.summary === "string");
670
+ expect("ocr", typeof root.ocr === "object" && root.ocr !== null);
671
+ expect("ocr.full_text", typeof child("ocr").full_text === "string");
672
+ expect("ocr.lines", Array.isArray(child("ocr").lines));
673
+ expect("layout", typeof root.layout === "object" && root.layout !== null);
674
+ expect("layout.regions", Array.isArray(child("layout").regions));
675
+ expect("semantics", typeof root.semantics === "object" && root.semantics !== null);
676
+ expect("semantics.scene", typeof child("semantics").scene === "string");
677
+ expect("semantics.entities", Array.isArray(child("semantics").entities));
678
+ expect("visual", typeof root.visual === "object" && root.visual !== null);
679
+ expect("uncertainty", Array.isArray(root.uncertainty));
680
+ return missing;
681
+ }
764
682
  function toDataUrl(image) {
765
683
  return `data:${image.mimeType};base64,${image.data}`;
766
684
  }
@@ -795,12 +713,134 @@ function resolveProvider(providerName = "antigravity-cli") {
795
713
  }
796
714
  return provider;
797
715
  }
716
+ function providerAliases() {
717
+ return Object.fromEntries(
718
+ Object.entries(PROVIDERS).map(([alias, provider]) => [alias, provider.name])
719
+ );
720
+ }
798
721
  function listProviders() {
799
722
  return [...new Set(Object.values(PROVIDERS).map((provider) => provider.name))];
800
723
  }
724
+ const CONFIG_DIR = path.join(os.homedir(), ".modlens");
725
+ const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
726
+ const ENV_BINDINGS = {
727
+ "gemini-api": { apiKey: "GEMINI_API_KEY" },
728
+ openai: { apiKey: "OPENAI_API_KEY", baseUrl: "OPENAI_BASE_URL" },
729
+ anthropic: { apiKey: "ANTHROPIC_API_KEY", baseUrl: "ANTHROPIC_BASE_URL" }
730
+ };
731
+ function loadConfigFile(configPath = CONFIG_PATH) {
732
+ let raw;
733
+ try {
734
+ raw = fs.readFileSync(configPath, "utf-8");
735
+ } catch (error) {
736
+ if (error.code === "ENOENT") {
737
+ return {};
738
+ }
739
+ throw new Error(
740
+ `Cannot read ${configPath}: ${error.message}. Fix the file or its permissions.`
741
+ );
742
+ }
743
+ try {
744
+ const parsed = JSON.parse(raw);
745
+ if (!parsed || typeof parsed !== "object") {
746
+ return {};
747
+ }
748
+ return parsed;
749
+ } catch (error) {
750
+ throw new Error(
751
+ `Failed to parse ${configPath}: ${error.message}. Fix or delete the file.`
752
+ );
753
+ }
754
+ }
755
+ function defaultProviderName(config2) {
756
+ return config2.provider?.trim() || "antigravity-cli";
757
+ }
758
+ function resolveProviderSettings(providerName, config2, env = process.env) {
759
+ const aliasNames = Object.entries(providerAliases()).filter(([alias, canonical]) => canonical === providerName && alias !== providerName).map(([alias]) => alias);
760
+ const fromFile = {
761
+ ...Object.assign({}, ...aliasNames.map((alias) => config2.providers?.[alias] ?? {})),
762
+ ...config2.providers?.[providerName] ?? {}
763
+ };
764
+ const bindings = ENV_BINDINGS[providerName] ?? {};
765
+ const settings = { ...fromFile };
766
+ for (const [field, envName] of Object.entries(bindings)) {
767
+ const value = env[envName]?.trim();
768
+ if (value) {
769
+ settings[field] = value;
770
+ }
771
+ }
772
+ return settings;
773
+ }
774
+ function setConfigValue(dottedKey, value, configPath = CONFIG_PATH) {
775
+ const config2 = loadConfigFile(configPath);
776
+ if (dottedKey === "provider") {
777
+ config2.provider = value;
778
+ } else {
779
+ const dot = dottedKey.indexOf(".");
780
+ if (dot <= 0 || dot === dottedKey.length - 1) {
781
+ throw new Error(
782
+ `Invalid config key: ${dottedKey}. Use "provider" or "<provider>.<apiKey|baseUrl|model>".`
783
+ );
784
+ }
785
+ const providerName = dottedKey.slice(0, dot);
786
+ const field = dottedKey.slice(dot + 1);
787
+ if (!["apiKey", "baseUrl", "model"].includes(field)) {
788
+ throw new Error(`Unknown config field: ${field}. Use apiKey, baseUrl, or model.`);
789
+ }
790
+ config2.providers ??= {};
791
+ config2.providers[providerName] ??= {};
792
+ config2.providers[providerName][field] = value;
793
+ }
794
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
795
+ fs.writeFileSync(configPath, `${JSON.stringify(config2, null, 2)}
796
+ `, { mode: 384 });
797
+ try {
798
+ fs.chmodSync(configPath, 384);
799
+ } catch {
800
+ }
801
+ }
802
+ const CONFIG_TEMPLATE = {
803
+ // Empty means the built-in default provider.
804
+ provider: "",
805
+ providers: {}
806
+ };
807
+ function initConfigFile(configPath = CONFIG_PATH, force = false) {
808
+ if (!force && fs.existsSync(configPath)) {
809
+ throw new Error(`${configPath} already exists. Use --force to overwrite.`);
810
+ }
811
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
812
+ fs.writeFileSync(configPath, `${JSON.stringify(CONFIG_TEMPLATE, null, 2)}
813
+ `, { mode: 384 });
814
+ try {
815
+ fs.chmodSync(configPath, 384);
816
+ } catch {
817
+ }
818
+ }
819
+ function renderConfig(config2) {
820
+ const masked = {
821
+ ...config2,
822
+ providers: Object.fromEntries(
823
+ Object.entries(config2.providers ?? {}).map(([name, settings]) => [
824
+ name,
825
+ {
826
+ ...settings,
827
+ ...settings.apiKey ? { apiKey: maskKey(settings.apiKey) } : {}
828
+ }
829
+ ])
830
+ )
831
+ };
832
+ return JSON.stringify(masked, null, 2);
833
+ }
834
+ function maskKey(key) {
835
+ if (key.length <= 8) {
836
+ return "****";
837
+ }
838
+ return `${key.slice(0, 6)}...${key.slice(-2)}`;
839
+ }
801
840
  const DEFAULT_TIMEOUT_MS = 18e4;
802
841
  const KILL_GRACE_MS = 3e4;
803
842
  const DRAIN_GRACE_MS = 500;
843
+ const SIGKILL_GRACE_MS = 2e3;
804
844
  async function analyzeImage(options) {
805
845
  const resolvedInput = resolveInput(options.input);
806
846
  if (resolvedInput.kind === "local") {
@@ -893,6 +933,12 @@ function runCommand(providerName, invocation, timeoutMs, describeFailure) {
893
933
  const timer = setTimeout(() => {
894
934
  timedOut = true;
895
935
  child.kill("SIGTERM");
936
+ settle(null);
937
+ setTimeout(() => {
938
+ if (!child.killed) {
939
+ child.kill("SIGKILL");
940
+ }
941
+ }, SIGKILL_GRACE_MS).unref();
896
942
  }, timeoutMs);
897
943
  const settle = (code) => {
898
944
  if (settled) {
@@ -901,6 +947,8 @@ function runCommand(providerName, invocation, timeoutMs, describeFailure) {
901
947
  settled = true;
902
948
  clearTimeout(timer);
903
949
  clearTimeout(drainTimer);
950
+ stdout += outDecoder.decode();
951
+ stderr += errDecoder.decode();
904
952
  child.stdout?.destroy();
905
953
  child.stderr?.destroy();
906
954
  child.unref();
@@ -972,37 +1020,44 @@ const EXT_BY_MIME = {
972
1020
  "image/webp": "webp",
973
1021
  "image/gif": "gif"
974
1022
  };
975
- function transcriptBelongsTo(filePath, cwd) {
1023
+ function transcriptBelongsTo(lines, cwd) {
976
1024
  const wanted = path.resolve(cwd);
977
- let raw;
978
- try {
979
- raw = fs.readFileSync(filePath, "utf-8");
980
- } catch {
981
- return false;
982
- }
983
- for (const line of raw.split("\n")) {
1025
+ let sawCwd = false;
1026
+ for (const line of lines) {
984
1027
  if (!line.includes('"cwd"')) {
985
1028
  continue;
986
1029
  }
987
1030
  try {
988
1031
  const recorded = JSON.parse(line).cwd;
989
- if (typeof recorded === "string") {
990
- const resolved = path.resolve(recorded);
991
- return resolved === wanted || resolved.startsWith(`${wanted}${path.sep}`);
1032
+ if (typeof recorded !== "string") {
1033
+ continue;
1034
+ }
1035
+ sawCwd = true;
1036
+ const resolved = path.resolve(recorded);
1037
+ if (resolved === wanted || resolved.startsWith(`${wanted}${path.sep}`)) {
1038
+ return true;
992
1039
  }
993
1040
  } catch {
994
1041
  }
995
1042
  }
996
- return true;
1043
+ return !sawCwd;
997
1044
  }
998
- function forEachJsonLine(filePath, visit) {
999
- let raw;
1045
+ function readLines(filePath) {
1000
1046
  try {
1001
- raw = fs.readFileSync(filePath, "utf-8");
1047
+ return fs.readFileSync(filePath, "utf-8").split("\n");
1002
1048
  } catch {
1049
+ return null;
1050
+ }
1051
+ }
1052
+ function forEachJsonLine(filePath, visit) {
1053
+ const lines = readLines(filePath);
1054
+ if (!lines) {
1003
1055
  return;
1004
1056
  }
1005
- for (const line of raw.split("\n")) {
1057
+ forEachParsedLine(lines, visit);
1058
+ }
1059
+ function forEachParsedLine(lines, visit) {
1060
+ for (const line of lines) {
1006
1061
  if (!line.includes('"image"')) {
1007
1062
  continue;
1008
1063
  }
@@ -1025,9 +1080,9 @@ function jsonlSource(harness, filePath, extractLine) {
1025
1080
  }
1026
1081
  };
1027
1082
  }
1028
- function newestJsonlTimestamp(filePath, extractLine) {
1083
+ function newestJsonlTimestamp(lines, extractLine) {
1029
1084
  let latest = null;
1030
- forEachJsonLine(filePath, (line) => {
1085
+ forEachParsedLine(lines, (line) => {
1031
1086
  if (extractLine(line).length === 0) {
1032
1087
  return;
1033
1088
  }
@@ -1054,10 +1109,11 @@ function jsonlAdapter(options) {
1054
1109
  findNewest: (cwd) => {
1055
1110
  let best = null;
1056
1111
  for (const file of listJsonl(dirFor(cwd))) {
1057
- if (!transcriptBelongsTo(file, cwd)) {
1112
+ const lines = readLines(file);
1113
+ if (!lines || !transcriptBelongsTo(lines, cwd)) {
1058
1114
  continue;
1059
1115
  }
1060
- const timestamp = newestJsonlTimestamp(file, extractLine);
1116
+ const timestamp = newestJsonlTimestamp(lines, extractLine);
1061
1117
  if (timestamp !== null && (!best || timestamp > best.timestamp)) {
1062
1118
  best = { ref: jsonlSource(name, file, extractLine), timestamp };
1063
1119
  }
@@ -1066,7 +1122,11 @@ function jsonlAdapter(options) {
1066
1122
  },
1067
1123
  findSession: (cwd, sessionId) => {
1068
1124
  for (const file of listJsonl(dirFor(cwd))) {
1069
- if (matchesSession(path.basename(file), sessionId) && transcriptBelongsTo(file, cwd)) {
1125
+ if (!matchesSession(path.basename(file), sessionId)) {
1126
+ continue;
1127
+ }
1128
+ const lines = readLines(file);
1129
+ if (lines && transcriptBelongsTo(lines, cwd)) {
1070
1130
  return jsonlSource(name, file, extractLine);
1071
1131
  }
1072
1132
  }
@@ -1240,7 +1300,7 @@ function harnessFromPsTable(psOutput, startPid) {
1240
1300
  const tokens = proc.command.trim().split(/\s+/);
1241
1301
  const candidates = [tokens[0]];
1242
1302
  if (/^(node|bun|deno)$/.test(path.basename(tokens[0] ?? ""))) {
1243
- const script = tokens.slice(1).find((token) => !token.startsWith("-"));
1303
+ const script = tokens.slice(1).find((token) => !token.startsWith("-") && /[/\\]|\.(m|c)?[jt]s$/.test(token));
1244
1304
  if (script) {
1245
1305
  candidates.push(script);
1246
1306
  }
@@ -1348,6 +1408,12 @@ function recoverPastedImages(options = {}) {
1348
1408
  "This is a Codex session: pasted images already exist as temp files, and each image tag in the message carries its path. Read the path from the tag instead of running recover-paste."
1349
1409
  );
1350
1410
  }
1411
+ const requested = options.harness?.trim();
1412
+ if (requested && requested !== "none" && !ADAPTERS.some((a) => a.name === requested)) {
1413
+ throw new Error(
1414
+ `Unknown harness "${requested}". Supported: ${ADAPTERS.map((a) => a.name).join(", ")} (or none to scan all).`
1415
+ );
1416
+ }
1351
1417
  const scoped = detected && detected !== "none" ? detected : null;
1352
1418
  if (scoped && !ADAPTERS.some((adapter) => adapter.name === scoped)) {
1353
1419
  throw new Error(
@@ -1412,7 +1478,7 @@ function recoverPastedImages(options = {}) {
1412
1478
  return result;
1413
1479
  }
1414
1480
  const program = new Command();
1415
- program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("2.7.4");
1481
+ program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("2.7.6");
1416
1482
  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").action(async (options) => {
1417
1483
  try {
1418
1484
  const timeoutMs = Number.parseInt(options.timeout, 10);
@@ -1481,9 +1547,13 @@ config.command("init").description(`Create a starter config at ${CONFIG_PATH}`).
1481
1547
  try {
1482
1548
  initConfigFile(CONFIG_PATH, Boolean(options.force));
1483
1549
  process.stdout.write(
1484
- `Created ${CONFIG_PATH}
1485
- Fill in the apiKey fields you need, or use: modlens config set <provider>.apiKey <key>
1486
- `
1550
+ [
1551
+ `Created ${CONFIG_PATH}`,
1552
+ "Everything is optional. Two things you can set:",
1553
+ " modlens config set provider <name> which provider analyzes images",
1554
+ " modlens config set <provider>.<apiKey|baseUrl|model> <value> provider credentials",
1555
+ ""
1556
+ ].join("\n")
1487
1557
  );
1488
1558
  } catch (error) {
1489
1559
  process.stderr.write(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liustack/modlens",
3
- "version": "2.7.4",
3
+ "version": "2.7.6",
4
4
  "description": "Plug-in vision for text-only LLMs, powered by the free Antigravity CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -73,6 +73,7 @@ Harnesses rarely hand you a clean path. First identify which harness you are in,
73
73
  - The output is JSON with real file paths, ordered oldest to newest, so the LAST path is the user's most recent paste. Analyze that one first. Entries carry `filename` (the original attachment name) when the harness stored one; if the user's message or an error mentions a filename, match on it.
74
74
  - Run every command yourself: `recover-paste`, then `modlens -i <path>` on the recovered file, then answer from the JSON. Never ask the user to run modlens or to relay paths.
75
75
  - The output's `detected` field names the harness scope that was applied. If it is absent, detection failed and every store was scanned by newest-image timestamp: before describing anything, check that `harness` and `filename` match what you expect, force the scope with `--harness <claude-code|pi|opencode>` if they do not, and when in doubt ask the user for the file instead of describing the wrong image.
76
+ - Recovery is scoped to this project: the harness's own record of its working directory is checked, not just the directory name, so images from a neighbouring project are never handed over. Recovered files are private to the user (0600).
76
77
  - If recovery fails (session storage is each harness's internals and may change), ask the user to drag the image file into the terminal or type its path.
77
78
 
78
79
  **Any other harness, or nothing matches** (no path tag and `recover-paste` reports no transcripts): do not guess. Ask the user for the image file path, or suggest dragging the file into the terminal.