deepseek-web-retrieval 0.3.0 → 0.5.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.
Files changed (62) hide show
  1. package/CHANGELOG.md +96 -230
  2. package/README.md +53 -25
  3. package/dist/cli.js +1358 -617
  4. package/docs/agents/codex.md +32 -11
  5. package/docs/agents/opencode-v2.md +5 -5
  6. package/docs/agents/pi.md +92 -90
  7. package/docs/design/installer-design.md +72 -9
  8. package/docs/hosts.jsonc +2 -2
  9. package/docs/integration.md +14 -9
  10. package/docs/research/control-matrix.md +48 -13
  11. package/docs/research/mcp-compatibility.md +9 -4
  12. package/docs/research/search-api-defaults.md +309 -0
  13. package/examples/pi.settings.jsonc +17 -0
  14. package/index.ts +2 -2
  15. package/package.json +35 -4
  16. package/src/core/auth.ts +4 -6
  17. package/src/core/client.ts +65 -53
  18. package/src/core/constants.ts +30 -24
  19. package/src/core/descriptions.ts +30 -6
  20. package/src/core/domains.ts +56 -67
  21. package/src/core/env.ts +112 -0
  22. package/src/core/format.ts +53 -50
  23. package/src/core/index.ts +9 -9
  24. package/src/core/jsonc.ts +6 -6
  25. package/src/core/object.ts +4 -0
  26. package/src/core/options.ts +21 -26
  27. package/src/core/search.ts +45 -44
  28. package/src/core/transport.ts +109 -113
  29. package/src/core/types.ts +47 -47
  30. package/src/core/url.ts +20 -20
  31. package/src/installer/apply.ts +256 -81
  32. package/src/installer/cli.ts +135 -104
  33. package/src/installer/detect.ts +46 -31
  34. package/src/installer/formats/index.ts +26 -28
  35. package/src/installer/formats/json.ts +81 -77
  36. package/src/installer/formats/rules.ts +55 -28
  37. package/src/installer/formats/toml.ts +88 -81
  38. package/src/installer/formats/value.ts +34 -30
  39. package/src/installer/hosts/codex.ts +100 -54
  40. package/src/installer/hosts/index.ts +126 -41
  41. package/src/installer/hosts/opencode.ts +70 -51
  42. package/src/installer/hosts/pi.ts +269 -75
  43. package/src/installer/hosts/shared.ts +19 -14
  44. package/src/installer/operation-lock.ts +54 -0
  45. package/src/installer/ownership.ts +129 -0
  46. package/src/installer/paths.ts +7 -5
  47. package/src/installer/persistence.ts +66 -0
  48. package/src/installer/render.ts +29 -29
  49. package/src/installer/revert.ts +33 -29
  50. package/src/installer/rulesContent.ts +24 -13
  51. package/src/installer/state.ts +116 -31
  52. package/src/installer/types.ts +74 -56
  53. package/src/installer/uninstall.ts +40 -17
  54. package/src/mcp/auth.ts +7 -8
  55. package/src/mcp/cli.ts +38 -20
  56. package/src/mcp/client.ts +35 -0
  57. package/src/mcp/server.ts +68 -97
  58. package/src/opencode/auth.ts +24 -26
  59. package/src/opencode/plugin.ts +99 -87
  60. package/src/pi/extension.ts +164 -0
  61. package/src/pi/settings.ts +49 -0
  62. package/src/mcp/config.ts +0 -124
package/dist/cli.js CHANGED
@@ -16,13 +16,82 @@ var __export = (target, all) => {
16
16
  var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
17
17
 
18
18
  // src/core/constants.ts
19
- var SEARCH_ENDPOINT = "https://api.deepseek.com/v1/tools/search", FETCH_ENDPOINT = "https://api.deepseek.com/v1/tools/fetch", DEFAULT_SEARCH_COUNT = 10, DEFAULT_MAX_RESULTS = 5, DEFAULT_MIN_RERANK = 1.5, DEFAULT_MIN_AUTHORITY = 2, DEFAULT_MAX_SUMMARY_CHARS = 1200, DEFAULT_FETCH_MAX_CHARS = 16000, DEFAULT_TIMEOUT_MS = 30000, MIN_TIMEOUT_MS = 1000, MAX_TIMEOUT_MS = 120000, MAX_QUERY_CHARS = 128, MAX_URL_CHARS = 8192, MAX_ERROR_BODY_CHARS = 2000, MAX_RESPONSE_BYTES, FETCH_TRUNCATION_MARKER = `
19
+ var SEARCH_ENDPOINT = "https://api.deepseek.com/v1/tools/search", FETCH_ENDPOINT = "https://api.deepseek.com/v1/tools/fetch", DEFAULT_SEARCH_COUNT = 10, DEFAULT_MAX_RESULTS = 10, DEFAULT_MIN_RERANK = 0, DEFAULT_MIN_AUTHORITY = 0, DEFAULT_MAX_SUMMARY_CHARS = 1200, DEFAULT_FETCH_MAX_CHARS = 16000, DEFAULT_TIMEOUT_MS = 30000, MIN_TIMEOUT_MS = 1000, MAX_TIMEOUT_MS = 120000, MAX_QUERY_CHARS = 128, MAX_URL_CHARS = 8192, MAX_ERROR_BODY_CHARS = 2000, MAX_RESPONSE_BYTES, FETCH_TRUNCATION_MARKER = `
20
20
 
21
21
  [content truncated]`, DEFAULT_API_KEY_SOURCE = "auto", DEFAULT_MAX_RETRIES = 0, MAX_RETRIES = 5, DEFAULT_RETRY_BASE_DELAY_MS = 250, MAX_RETRY_DELAY_MS = 1e4, API_KEY_ENV = "DEEPSEEK_API_KEY", ENABLE_SEARCH_ENV = "DEEPSEEK_ENABLE_SEARCH", ENABLE_FETCH_ENV = "DEEPSEEK_ENABLE_FETCH";
22
22
  var init_constants = __esm(() => {
23
23
  MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
24
24
  });
25
25
 
26
+ // src/installer/persistence.ts
27
+ import { randomUUID } from "node:crypto";
28
+ import {
29
+ closeSync,
30
+ fchmodSync,
31
+ fsyncSync,
32
+ lstatSync,
33
+ mkdirSync,
34
+ openSync,
35
+ readFileSync,
36
+ realpathSync,
37
+ renameSync,
38
+ rmSync,
39
+ statSync,
40
+ writeFileSync
41
+ } from "node:fs";
42
+ import { basename, dirname, join } from "node:path";
43
+ function atomicWrite(file, text, mode) {
44
+ let target = file;
45
+ let existingMode;
46
+ try {
47
+ const entry = lstatSync(file);
48
+ if (entry.isSymbolicLink())
49
+ target = realpathSync(file);
50
+ existingMode = statSync(target).mode & 511;
51
+ } catch (error) {
52
+ if (error.code !== "ENOENT")
53
+ throw error;
54
+ try {
55
+ if (lstatSync(file).isSymbolicLink())
56
+ throw new Error(`Cannot write dangling symlink ${file}`);
57
+ } catch (linkError) {
58
+ if (linkError.code !== "ENOENT")
59
+ throw linkError;
60
+ }
61
+ }
62
+ const dir = dirname(target);
63
+ mkdirSync(dir, { recursive: true });
64
+ const temporary = join(dir, `.${basename(target)}.${randomUUID()}.tmp`);
65
+ let fd;
66
+ try {
67
+ fd = openSync(temporary, "wx", 384);
68
+ writeFileSync(fd, text);
69
+ if (readFileSync(temporary, "utf8") !== text)
70
+ throw new Error(`Incomplete write to ${file}`);
71
+ fchmodSync(fd, mode ?? existingMode ?? 438 & ~process.umask());
72
+ fsyncSync(fd);
73
+ closeSync(fd);
74
+ fd = undefined;
75
+ renameSync(temporary, target);
76
+ syncDirectory(dir);
77
+ } finally {
78
+ if (fd !== undefined)
79
+ closeSync(fd);
80
+ rmSync(temporary, { force: true });
81
+ }
82
+ }
83
+ function syncDirectory(dir) {
84
+ if (process.platform === "win32")
85
+ return;
86
+ const fd = openSync(dir, "r");
87
+ try {
88
+ fsyncSync(fd);
89
+ } finally {
90
+ closeSync(fd);
91
+ }
92
+ }
93
+ var init_persistence = () => {};
94
+
26
95
  // node_modules/jsonc-parser/lib/esm/impl/scanner.js
27
96
  function createScanner(text, ignoreTrivia = false) {
28
97
  const len = text.length;
@@ -750,10 +819,10 @@ function parse(text, errors = [], options = ParseOptions.DEFAULT) {
750
819
  currentParent = previousParents.pop();
751
820
  },
752
821
  onArrayBegin: () => {
753
- const array2 = [];
754
- onValue(array2);
822
+ const array = [];
823
+ onValue(array);
755
824
  previousParents.push(currentParent);
756
- currentParent = array2;
825
+ currentParent = array;
757
826
  currentProperty = null;
758
827
  },
759
828
  onArrayEnd: () => {
@@ -1379,10 +1448,12 @@ var init_jsonc = __esm(() => {
1379
1448
  init_main();
1380
1449
  });
1381
1450
 
1382
- // src/installer/formats/value.ts
1451
+ // src/core/object.ts
1383
1452
  function isPlainObject(value) {
1384
1453
  return typeof value === "object" && value !== null && !Array.isArray(value);
1385
1454
  }
1455
+
1456
+ // src/installer/formats/value.ts
1386
1457
  function deepEqual(a, b) {
1387
1458
  if (a === b)
1388
1459
  return true;
@@ -1432,13 +1503,14 @@ function appendEntries(existing, entries, path) {
1432
1503
  const appended = entries.filter((entry) => !list.some((item) => deepEqual(item, entry)));
1433
1504
  return { list, appended };
1434
1505
  }
1506
+ var init_value = () => {};
1435
1507
 
1436
1508
  // src/installer/formats/json.ts
1437
- import { existsSync, readFileSync } from "node:fs";
1509
+ import { existsSync, readFileSync as readFileSync2 } from "node:fs";
1438
1510
  function loadJson(file) {
1439
1511
  if (!existsSync(file))
1440
1512
  return { exists: false, text: "", value: {}, lossy: false };
1441
- const text = readFileSync(file, "utf8");
1513
+ const text = readFileSync2(file, "utf8");
1442
1514
  try {
1443
1515
  return { exists: true, text, value: parseJsonc(text), lossy: false };
1444
1516
  } catch (error) {
@@ -1547,6 +1619,7 @@ var JSON_FORMATTING, jsonCodec;
1547
1619
  var init_json = __esm(() => {
1548
1620
  init_main();
1549
1621
  init_jsonc();
1622
+ init_value();
1550
1623
  JSON_FORMATTING = { insertSpaces: true, tabSize: 2, insertFinalNewline: false };
1551
1624
  jsonCodec = {
1552
1625
  format: "json",
@@ -1569,7 +1642,7 @@ var init_json = __esm(() => {
1569
1642
  });
1570
1643
 
1571
1644
  // src/installer/formats/rules.ts
1572
- import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
1645
+ import { existsSync as existsSync2, readFileSync as readFileSync3 } from "node:fs";
1573
1646
  function rulesMarkers(host) {
1574
1647
  return {
1575
1648
  begin: `<!-- deepseek-web-retrieval:${host}:rules:begin -->`,
@@ -1591,28 +1664,47 @@ ${block}
1591
1664
  `;
1592
1665
  }
1593
1666
  function removeBlock(host, text) {
1594
- const markers = rulesMarkers(host);
1595
- const start = text.indexOf(markers.begin);
1596
- if (start === -1)
1667
+ const range = blockRange(host, text);
1668
+ if (range === undefined)
1597
1669
  return text;
1598
- const end = text.indexOf(markers.end);
1599
- if (end === -1)
1600
- return text.slice(0, start);
1601
- let after = text.slice(end + markers.end.length);
1602
- after = after.replace(/^\n{1,2}/, "");
1603
- const before = text.slice(0, start);
1604
- return before.endsWith(`
1670
+ const before = text.slice(0, range.start).trimEnd();
1671
+ const after = text.slice(range.end).replace(/^\n{1,2}/, "").trimStart();
1672
+ if (before && after)
1673
+ return `${before}
1605
1674
 
1606
- `) ? `${before.slice(0, -1)}${after}` : before + after;
1675
+ ${after}`;
1676
+ if (before)
1677
+ return `${before}
1678
+ `;
1679
+ return after;
1607
1680
  }
1608
1681
  function hasBlock(host, text) {
1682
+ return blockRange(host, text) !== undefined;
1683
+ }
1684
+ function blockRange(host, text) {
1609
1685
  const markers = rulesMarkers(host);
1610
- return text.includes(markers.begin) && text.includes(markers.end);
1686
+ const start = text.indexOf(markers.begin);
1687
+ const end = text.indexOf(markers.end);
1688
+ if (start === -1 && end === -1)
1689
+ return;
1690
+ if (start === -1 || end < start || text.indexOf(markers.begin, start + markers.begin.length) !== -1 || text.indexOf(markers.end, end + markers.end.length) !== -1) {
1691
+ throw new Error(`malformed rules block for ${host}: expected one begin marker followed by one end marker; repair the markers before retrying`);
1692
+ }
1693
+ return { start, end: end + markers.end.length };
1611
1694
  }
1612
1695
  function rulesCodec(host) {
1613
1696
  return {
1614
1697
  format: "rules",
1615
- load: (file) => existsSync2(file) ? { exists: true, text: readFileSync2(file, "utf8"), value: {}, lossy: false } : { exists: false, text: "", value: {}, lossy: false },
1698
+ load: (file) => {
1699
+ const exists = existsSync2(file);
1700
+ const text = exists ? readFileSync3(file, "utf8") : "";
1701
+ try {
1702
+ blockRange(host, text);
1703
+ return { exists, text, value: {}, lossy: false };
1704
+ } catch (error) {
1705
+ return { exists, text, value: {}, lossy: false, parseError: String(error) };
1706
+ }
1707
+ },
1616
1708
  apply: (text, edit2) => {
1617
1709
  if (edit2.format !== "rules")
1618
1710
  throw new Error(`rules codec cannot apply a ${edit2.format} edit`);
@@ -2669,30 +2761,30 @@ function stringifyInlineTable(obj, depth, numberAsFloat, strictTemporal) {
2669
2761
  }
2670
2762
  return res + " }";
2671
2763
  }
2672
- function stringifyArray(array2, depth, numberAsFloat, strictTemporal) {
2673
- if (array2.length === 0)
2764
+ function stringifyArray(array, depth, numberAsFloat, strictTemporal) {
2765
+ if (array.length === 0)
2674
2766
  return "[]";
2675
2767
  let res = "[ ";
2676
- for (let i = 0;i < array2.length; i++) {
2768
+ for (let i = 0;i < array.length; i++) {
2677
2769
  if (i)
2678
2770
  res += ", ";
2679
- if (array2[i] === null || array2[i] === undefined) {
2771
+ if (array[i] === null || array[i] === undefined) {
2680
2772
  throw new TypeError("arrays cannot contain null or undefined values");
2681
2773
  }
2682
- res += stringifyValue(array2[i], extendedTypeOf(array2[i]), depth - 1, numberAsFloat, strictTemporal);
2774
+ res += stringifyValue(array[i], extendedTypeOf(array[i]), depth - 1, numberAsFloat, strictTemporal);
2683
2775
  }
2684
2776
  return res + " ]";
2685
2777
  }
2686
- function stringifyArrayTable(array2, key, depth, numberAsFloat, strictTemporal) {
2778
+ function stringifyArrayTable(array, key, depth, numberAsFloat, strictTemporal) {
2687
2779
  if (depth === 0) {
2688
2780
  throw new Error("Could not stringify the object: maximum object depth exceeded");
2689
2781
  }
2690
2782
  let res = "";
2691
- for (let i = 0;i < array2.length; i++) {
2783
+ for (let i = 0;i < array.length; i++) {
2692
2784
  res += `${res && `
2693
2785
  `}[[${key}]]
2694
2786
  `;
2695
- res += stringifyTable(0, array2[i], key, depth, numberAsFloat, strictTemporal);
2787
+ res += stringifyTable(0, array[i], key, depth, numberAsFloat, strictTemporal);
2696
2788
  }
2697
2789
  return res;
2698
2790
  }
@@ -2813,11 +2905,11 @@ var init_dist = __esm(() => {
2813
2905
  });
2814
2906
 
2815
2907
  // src/installer/formats/toml.ts
2816
- import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
2908
+ import { existsSync as existsSync3, readFileSync as readFileSync4 } from "node:fs";
2817
2909
  function loadToml(file) {
2818
2910
  if (!existsSync3(file))
2819
2911
  return { exists: false, text: "", value: {}, lossy: false };
2820
- const text = readFileSync3(file, "utf8");
2912
+ const text = readFileSync4(file, "utf8");
2821
2913
  try {
2822
2914
  return { exists: true, text, value: parse3(text), lossy: hasTomlComment(text) };
2823
2915
  } catch (error) {
@@ -2863,7 +2955,7 @@ function applyTomlOps(value, ops) {
2863
2955
  const parent = navigate(target, op.path, createdParents);
2864
2956
  const key = op.path[op.path.length - 1];
2865
2957
  if (op.op === "set") {
2866
- const previous = Object.prototype.hasOwnProperty.call(parent, key) ? parent[key] : undefined;
2958
+ const previous = Object.hasOwn(parent, key) ? parent[key] : undefined;
2867
2959
  parent[key] = structuredClone(op.value);
2868
2960
  recorded.push({ op: "set", path: op.path, applied: structuredClone(op.value), previous });
2869
2961
  continue;
@@ -2945,6 +3037,7 @@ function tomlOpHolds(text, op) {
2945
3037
  var tomlCodec;
2946
3038
  var init_toml = __esm(() => {
2947
3039
  init_dist();
3040
+ init_value();
2948
3041
  tomlCodec = {
2949
3042
  format: "toml",
2950
3043
  load: loadToml,
@@ -2971,11 +3064,8 @@ var init_toml = __esm(() => {
2971
3064
  });
2972
3065
 
2973
3066
  // src/installer/formats/index.ts
2974
- import { mkdirSync, writeFileSync } from "node:fs";
2975
- import { dirname } from "node:path";
2976
3067
  function writeConfig(file, serialized) {
2977
- mkdirSync(dirname(file), { recursive: true });
2978
- writeFileSync(file, serialized);
3068
+ atomicWrite(file, serialized);
2979
3069
  }
2980
3070
  function codecFor(format2, host) {
2981
3071
  if (format2 === "json")
@@ -2985,140 +3075,737 @@ function codecFor(format2, host) {
2985
3075
  return rulesCodec(host);
2986
3076
  }
2987
3077
  var init_formats = __esm(() => {
3078
+ init_persistence();
2988
3079
  init_json();
2989
3080
  init_rules();
2990
3081
  init_toml();
2991
3082
  });
2992
3083
 
2993
- // src/installer/detect.ts
2994
- import { existsSync as existsSync4, readFileSync as readFileSync4 } from "node:fs";
2995
- function replacingExistingEntryNote(path) {
2996
- return `An existing ${path} entry is being replaced; its previous form is recorded for uninstall.`;
3084
+ // src/installer/revert.ts
3085
+ import { existsSync as existsSync4, readFileSync as readFileSync5, rmdirSync, unlinkSync } from "node:fs";
3086
+ import { dirname as dirname2 } from "node:path";
3087
+ function pruneEmptyDir(dir) {
3088
+ try {
3089
+ rmdirSync(dir);
3090
+ } catch {}
2997
3091
  }
2998
- function othersStayRegisteredNote(others) {
2999
- return `Other MCP servers stay registered (${others.join(", ")}); routing to DeepSeek is not exclusive while they offer web tools.`;
3092
+ function revertRecordedFile(file, host, opts) {
3093
+ const lines = [];
3094
+ if (!existsSync4(file.file)) {
3095
+ lines.push(`already absent: ${file.file}${opts.label}`);
3096
+ return lines;
3097
+ }
3098
+ const codec = codecFor(file.format, host);
3099
+ const text = readFileSync5(file.file, "utf8");
3100
+ if (file.format === "rules") {
3101
+ const edit2 = { file: file.file, format: "rules", block: file.blockApplied ?? "" };
3102
+ if (!codec.verify(text, edit2)) {
3103
+ lines.push(`no rules block${opts.label} in ${file.file}; left untouched`);
3104
+ return lines;
3105
+ }
3106
+ const updated = codec.revert(text, file).text;
3107
+ if (opts.whole && codec.isEffectivelyEmpty(updated)) {
3108
+ unlinkSync(file.file);
3109
+ pruneEmptyDir(dirname2(file.file));
3110
+ lines.push(`deleted ${file.file} (installer-created, now empty)`);
3111
+ } else {
3112
+ writeConfig(file.file, updated);
3113
+ lines.push(`removed rules block${opts.label} from ${file.file}`);
3114
+ }
3115
+ return lines;
3116
+ }
3117
+ const reverted = codec.revert(text, file);
3118
+ if (opts.whole && file.created && codec.isEffectivelyEmpty(reverted.text)) {
3119
+ unlinkSync(file.file);
3120
+ pruneEmptyDir(dirname2(file.file));
3121
+ lines.push(`deleted ${file.file} (installer-created, now empty)`);
3122
+ } else {
3123
+ writeConfig(file.file, reverted.text);
3124
+ lines.push(`reverted artifacts${opts.label} in ${file.file}`);
3125
+ }
3126
+ lines.push(...reverted.notes.map((note) => ` note: ${note}`));
3127
+ return lines;
3000
3128
  }
3001
- function looksWebCapable(name, entry) {
3002
- if (WEB_TOOL_HINT.test(name))
3003
- return true;
3004
- if (!isPlainObject(entry))
3005
- return false;
3006
- const command = typeof entry.command === "string" ? entry.command : "";
3007
- const url = typeof entry.url === "string" ? entry.url : "";
3008
- const args = Array.isArray(entry.args) ? entry.args.filter((arg) => typeof arg === "string").join(" ") : "";
3009
- return WEB_TOOL_HINT.test(`${command} ${args} ${url}`);
3129
+ var init_revert = __esm(() => {
3130
+ init_formats();
3131
+ });
3132
+
3133
+ // src/installer/paths.ts
3134
+ import { join as pathJoin } from "node:path";
3135
+ function join2(...parts) {
3136
+ const nonEmpty = parts.filter((part) => part.length > 0);
3137
+ return nonEmpty.length === 0 ? "" : pathJoin(...nonEmpty);
3010
3138
  }
3011
- function findCompetitorSteering(host, file) {
3012
- if (!existsSync4(file))
3013
- return [];
3139
+ function configHome(roots) {
3140
+ return roots.configHome ?? join2(roots.home, ".config");
3141
+ }
3142
+ function stateDir(roots, scope) {
3143
+ return scope === "global" ? join2(configHome(roots), "deepseek-web-retrieval") : join2(roots.cwd, ".deepseek-web-retrieval");
3144
+ }
3145
+ var init_paths = () => {};
3146
+
3147
+ // src/installer/state.ts
3148
+ import { existsSync as existsSync5, lstatSync as lstatSync2, readdirSync, readFileSync as readFileSync6, rmSync as rmSync2 } from "node:fs";
3149
+ import { isAbsolute, join as join3 } from "node:path";
3150
+ import { z as z2 } from "zod/v4";
3151
+ function statePathFor(input) {
3152
+ return join3(stateDir(input, input.scope), `install.${input.host}.json`);
3153
+ }
3154
+ function readRecordFile(file, host, scope) {
3014
3155
  let text;
3015
3156
  try {
3016
- text = readFileSync4(file, "utf8");
3157
+ text = readFileSync6(file, "utf8");
3158
+ } catch (error) {
3159
+ if (error.code === "ENOENT") {
3160
+ try {
3161
+ lstatSync2(file);
3162
+ } catch (statError) {
3163
+ if (statError.code === "ENOENT")
3164
+ return;
3165
+ }
3166
+ }
3167
+ throw new InstallRecordError(file, "unreadable", error.code ?? "read failed");
3168
+ }
3169
+ let value;
3170
+ try {
3171
+ value = JSON.parse(text);
3017
3172
  } catch {
3173
+ throw new InstallRecordError(file, "invalid", "malformed JSON");
3174
+ }
3175
+ const result = recordSchema.safeParse(value);
3176
+ if (!result.success) {
3177
+ throw new InstallRecordError(file, "invalid", "unsupported version or malformed record structure");
3178
+ }
3179
+ if (result.data.host !== host || result.data.scope !== scope) {
3180
+ throw new InstallRecordError(file, "invalid", "host or scope does not match its location");
3181
+ }
3182
+ return result.data;
3183
+ }
3184
+ function readRecord(input) {
3185
+ return readRecordFile(statePathFor(input), input.host, input.scope);
3186
+ }
3187
+ function claimedByOtherScope(input, files) {
3188
+ const other = input.scope === "global" ? "project" : "global";
3189
+ const record = readRecord({ ...input, scope: other });
3190
+ if (record === undefined)
3191
+ return [];
3192
+ return files.filter((file) => record.files.some((candidate) => candidate.file === file));
3193
+ }
3194
+ function writeRecord(input, record) {
3195
+ const file = statePathFor(input);
3196
+ atomicWrite(file, `${JSON.stringify(record, null, 2)}
3197
+ `, 384);
3198
+ }
3199
+ function appendedByPrevious(record, path) {
3200
+ if (record === undefined)
3018
3201
  return [];
3202
+ const entries = [];
3203
+ for (const file of record.files) {
3204
+ for (const op of file.ops) {
3205
+ if (op.op !== "append-array" || op.path.join("\x00") !== path.join("\x00"))
3206
+ continue;
3207
+ if (Array.isArray(op.applied))
3208
+ entries.push(...op.applied);
3209
+ }
3019
3210
  }
3020
- return removeBlock(host, text).split(`
3021
- `).map((line) => line.trim()).filter((line) => line.length > 0 && STEERING_HINT.test(line) && !line.includes("deepseek")).slice(0, 2);
3211
+ return entries;
3022
3212
  }
3023
- function competitorSteeringNote(file, lines) {
3024
- const quoted = lines.map((line) => `"${line}"`).join(", ");
3025
- return `Existing rules in ${file} steer toward other web tools (${quoted}); the plan surfaces this conflict — it does not resolve it.`;
3213
+ function deleteRecord(input) {
3214
+ const file = statePathFor(input);
3215
+ if (existsSync5(file))
3216
+ rmSync2(file);
3026
3217
  }
3027
- function splitCompetitors(servers) {
3028
- const competitors = [];
3029
- const bystanders = [];
3030
- for (const [name, entry] of Object.entries(servers)) {
3031
- if (name === "deepseek")
3032
- continue;
3033
- (looksWebCapable(name, entry) ? competitors : bystanders).push(name);
3218
+ function listRecords(roots) {
3219
+ const records = [];
3220
+ for (const scope of ["global", "project"]) {
3221
+ const dir = stateDir(roots, scope);
3222
+ let names;
3223
+ try {
3224
+ names = readdirSync(dir);
3225
+ } catch (error) {
3226
+ if (error.code === "ENOENT")
3227
+ continue;
3228
+ throw new InstallRecordError(dir, "unreadable", error.code ?? "list failed");
3229
+ }
3230
+ for (const name of names) {
3231
+ if (!name.startsWith("install.") || !name.endsWith(".json"))
3232
+ continue;
3233
+ const record = readRecordFile(join3(dir, name), name.slice("install.".length, -".json".length), scope);
3234
+ if (record !== undefined)
3235
+ records.push(record);
3236
+ }
3034
3237
  }
3035
- return { competitors, bystanders };
3238
+ return records;
3036
3239
  }
3037
- var BOTH_CAPABILITIES_OFF = "Both capabilities are off; nothing would be installed.", FORCED_COST_NOTE, WEB_TOOL_HINT, STEERING_HINT;
3038
- var init_detect = __esm(() => {
3039
- init_rules();
3040
- FORCED_COST_NOTE = "Forced routing is deterministic, and Search and Fetch are billed per successful request — " + `see the README's "Privacy and cost" section before applying.`;
3041
- WEB_TOOL_HINT = /(web[_ -]?(search|fetch)|search|fetch|browse|scrape|crawl|tavily|brave|exa|perplexity|firecrawl|jina|serp|duckduckgo|reader)/i;
3042
- STEERING_HINT = /(web[_ ]?(search|fetch)|websearch|webfetch|tavily|exa|brave|perplexity|firecrawl|jina|serp|duckduckgo|google|bing)/i;
3240
+ var keyPath, tier, operation, recordSchema, InstallRecordError;
3241
+ var init_state = __esm(() => {
3242
+ init_paths();
3243
+ init_persistence();
3244
+ keyPath = z2.array(z2.string().min(1)).min(1);
3245
+ tier = z2.enum(["optional", "preferred", "forced", "off"]);
3246
+ operation = z2.discriminatedUnion("op", [
3247
+ z2.object({ op: z2.literal("set"), path: keyPath, applied: z2.unknown(), previous: z2.unknown().optional() }),
3248
+ z2.object({
3249
+ op: z2.literal("append-array"),
3250
+ path: keyPath,
3251
+ applied: z2.array(z2.unknown()),
3252
+ previous: z2.unknown().optional()
3253
+ })
3254
+ ]);
3255
+ recordSchema = z2.object({
3256
+ version: z2.literal(1),
3257
+ host: z2.enum(["opencode", "pi", "codex"]),
3258
+ scope: z2.enum(["global", "project"]),
3259
+ tiers: z2.object({ search: tier, fetch: tier }),
3260
+ createdAt: z2.string().refine((value) => Number.isFinite(Date.parse(value))),
3261
+ files: z2.array(z2.object({
3262
+ file: z2.string().refine(isAbsolute),
3263
+ format: z2.enum(["json", "toml", "rules"]),
3264
+ created: z2.boolean(),
3265
+ shared: z2.literal(true).optional(),
3266
+ ops: z2.array(operation),
3267
+ blockApplied: z2.string().optional(),
3268
+ createdParents: z2.array(keyPath).optional()
3269
+ }))
3270
+ });
3271
+ InstallRecordError = class InstallRecordError extends Error {
3272
+ file;
3273
+ kind;
3274
+ constructor(file, kind, detail) {
3275
+ super(`${kind === "invalid" ? "Invalid install record" : "Cannot read install record"} at ${file} (${detail}). ` + "Restore a valid record from backup or repair its permissions before retrying; configuration was not changed. Do not delete undo state to bypass this check.");
3276
+ this.file = file;
3277
+ this.kind = kind;
3278
+ this.name = "InstallRecordError";
3279
+ }
3280
+ };
3043
3281
  });
3044
3282
 
3045
- // src/installer/paths.ts
3046
- function join(...parts) {
3047
- return parts.filter((part) => part.length > 0).join("/");
3283
+ // src/installer/ownership.ts
3284
+ import { existsSync as existsSync6, mkdirSync as mkdirSync2, readFileSync as readFileSync7, realpathSync as realpathSync2, rmSync as rmSync3, writeFileSync as writeFileSync2 } from "node:fs";
3285
+ import { basename as basename2, dirname as dirname3, join as join4, resolve } from "node:path";
3286
+ function canonicalPath(file) {
3287
+ const absolute = resolve(file);
3288
+ if (existsSync6(absolute))
3289
+ return realpathSync2(absolute);
3290
+ return join4(canonicalPath(dirname3(absolute)), basename2(absolute));
3291
+ }
3292
+ function ownerOf(input) {
3293
+ return { recordPath: canonicalPath(statePathFor(input)), scope: input.scope, cwd: canonicalPath(input.cwd) };
3294
+ }
3295
+ function claimPaths(input, files) {
3296
+ return [
3297
+ ...new Set(files.filter((file) => file.format !== "rules").map((file) => {
3298
+ const target = canonicalPath(file.file);
3299
+ return join4(dirname3(target), `.${basename2(target)}.deepseek-web-retrieval.${input.host}.owner.json`);
3300
+ }))
3301
+ ];
3048
3302
  }
3049
- function configHome(roots) {
3050
- return roots.configHome ?? join(roots.home, ".config");
3303
+ function readOwner(file) {
3304
+ if (!existsSync6(file))
3305
+ return;
3306
+ try {
3307
+ const value = JSON.parse(readFileSync7(file, "utf8"));
3308
+ if (isPlainObject(value) && typeof value.recordPath === "string" && typeof value.cwd === "string" && (value.scope === "project" || value.scope === "global")) {
3309
+ return { recordPath: value.recordPath, cwd: value.cwd, scope: value.scope };
3310
+ }
3311
+ } catch {}
3312
+ throw new Error(`Cannot read config ownership in ${file}; repair the ownership record before retrying.`);
3051
3313
  }
3052
- function stateDir(roots, scope) {
3053
- return scope === "global" ? join(configHome(roots), "deepseek-web-retrieval") : join(roots.cwd, ".deepseek-web-retrieval");
3314
+ function assertOwner(input, claim) {
3315
+ const owner = readOwner(claim);
3316
+ if (owner === undefined || owner.recordPath === ownerOf(input).recordPath)
3317
+ return;
3318
+ throw new Error(`A ${input.host} install is already recorded at the ${owner.scope} scope (${owner.recordPath}) and claims the same config file. ` + `Uninstall it first: deepseek-web-retrieval uninstall --host ${input.host} --scope ${owner.scope} --cwd ${JSON.stringify(owner.cwd)}. ` + "Two install records must not edit one file.");
3054
3319
  }
3055
-
3056
- // src/installer/rulesContent.ts
3057
- function rulesLines(ids, search, fetch) {
3058
- const lines = ["DeepSeek retrieval guidance:"];
3059
- if (search !== "off") {
3060
- lines.push(`- Use ${ids.search} when the task needs fresh external information: current docs, version numbers,`, " changelogs, or anything you cannot know from the repository alone. Do not search for facts already", " present locally.");
3061
- }
3062
- if (fetch !== "off") {
3063
- lines.push(`- Use ${ids.fetch} to read a specific public web page as Markdown when its content is needed.`);
3320
+ function assertFileOwnership(input, files) {
3321
+ for (const claim of claimPaths(input, files))
3322
+ assertOwner(input, claim);
3323
+ }
3324
+ function fileOwnershipNotes(input, files) {
3325
+ return claimPaths(input, files.filter((file) => file.shared)).map((claim) => `Shared config ownership is recorded in ${claim}; uninstall releases it. Keep this local metadata with the config.`);
3326
+ }
3327
+ function acquireFileOwnership(input, files) {
3328
+ const acquired = [];
3329
+ const owner = ownerOf(input);
3330
+ const releaseNew = () => {
3331
+ for (const claim of acquired.reverse())
3332
+ releaseClaim(input, claim);
3333
+ };
3334
+ try {
3335
+ for (const claim of claimPaths(input, files.filter((file) => file.shared))) {
3336
+ assertOwner(input, claim);
3337
+ mkdirSync2(dirname3(claim), { recursive: true });
3338
+ try {
3339
+ writeFileSync2(claim, `${JSON.stringify(owner)}
3340
+ `, { flag: "wx", mode: 384 });
3341
+ acquired.push(claim);
3342
+ } catch (error) {
3343
+ if (error.code !== "EEXIST")
3344
+ throw error;
3345
+ assertOwner(input, claim);
3346
+ }
3347
+ }
3348
+ return releaseNew;
3349
+ } catch (error) {
3350
+ releaseNew();
3351
+ throw error;
3064
3352
  }
3065
- lines.push("- Never send localhost or private-network URLs to the DeepSeek tools; use local tools for those (the", " server blocks private hosts by default).", "- DeepSeek requests are billed per successful call; retrieve only what the task needs.");
3066
- return lines;
3067
3353
  }
3068
- var MCP_TOOL_IDS;
3069
- var init_rulesContent = __esm(() => {
3070
- MCP_TOOL_IDS = { search: "mcp__deepseek__deepseek_web_search", fetch: "mcp__deepseek__deepseek_fetch" };
3354
+ function releaseClaim(input, claim) {
3355
+ const owner = readOwner(claim);
3356
+ if (owner === undefined)
3357
+ return;
3358
+ assertOwner(input, claim);
3359
+ rmSync3(claim);
3360
+ pruneEmptyDir(dirname3(claim));
3361
+ }
3362
+ function releaseFileOwnership(input, files) {
3363
+ for (const claim of claimPaths(input, files))
3364
+ releaseClaim(input, claim);
3365
+ }
3366
+ var init_ownership = __esm(() => {
3367
+ init_revert();
3368
+ init_state();
3071
3369
  });
3072
3370
 
3073
- // src/installer/hosts/shared.ts
3074
- function planHeader(input, paths, loaded, dialect) {
3075
- const notes = [];
3076
- const errors = [];
3077
- if (loaded.parseError) {
3078
- errors.push({ message: `${paths.config} exists but does not parse as ${dialect}: ${loaded.parseError}` });
3079
- return { notes, errors, abort: true };
3371
+ // src/installer/operation-lock.ts
3372
+ import { mkdirSync as mkdirSync3, rmSync as rmSync4, writeFileSync as writeFileSync3 } from "node:fs";
3373
+ import { dirname as dirname4 } from "node:path";
3374
+ function withOperationLock(files, run) {
3375
+ const locks = [...new Set(files.map((file) => `${canonicalPath(file)}.deepseek-web-retrieval.lock`))].sort();
3376
+ const acquired = [];
3377
+ let outcome;
3378
+ try {
3379
+ for (const lock of locks) {
3380
+ mkdirSync3(dirname4(lock), { recursive: true });
3381
+ try {
3382
+ mkdirSync3(lock, { mode: 448 });
3383
+ } catch (error) {
3384
+ if (error.code !== "EEXIST")
3385
+ throw error;
3386
+ throw new Error(`Installer operation is locked at ${lock}. Wait for the other init/uninstall to finish. ` + "If it was interrupted, verify the config and install record and ensure no installer is running before removing this lock directory; retry with a new plan.");
3387
+ }
3388
+ acquired.push(lock);
3389
+ writeFileSync3(`${lock}/owner.json`, JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }), {
3390
+ mode: 384
3391
+ });
3392
+ }
3393
+ outcome = { ok: true, value: run() };
3394
+ } catch (error) {
3395
+ outcome = { ok: false, error };
3080
3396
  }
3081
- if (input.search === "off" && input.fetch === "off") {
3082
- errors.push({ message: BOTH_CAPABILITIES_OFF });
3397
+ {
3398
+ const errors = [];
3399
+ for (const lock of acquired.reverse()) {
3400
+ try {
3401
+ rmSync4(lock, { recursive: true });
3402
+ pruneEmptyDir(dirname4(lock));
3403
+ } catch (error) {
3404
+ errors.push(`${lock}: ${String(error)}`);
3405
+ }
3406
+ }
3407
+ if (errors.length > 0) {
3408
+ throw new Error(`${outcome.ok ? "Operation finished" : String(outcome.error)}; could not release operation lock(s): ${errors.join("; ")}`, { cause: outcome.ok ? undefined : outcome.error });
3409
+ }
3083
3410
  }
3084
- const steering = findCompetitorSteering(input.host, paths.rules);
3085
- if (steering.length > 0)
3086
- notes.push(competitorSteeringNote(paths.rules, steering));
3087
- return { notes, errors, abort: false };
3411
+ if (!outcome.ok)
3412
+ throw outcome.error;
3413
+ return outcome.value;
3088
3414
  }
3089
- var init_shared = __esm(() => {
3090
- init_detect();
3415
+ var init_operation_lock = __esm(() => {
3416
+ init_ownership();
3417
+ init_revert();
3091
3418
  });
3092
3419
 
3093
- // src/installer/hosts/codex.ts
3094
- function codexPaths(input) {
3095
- return {
3096
- config: join(input.home, ".codex/config.toml"),
3097
- rules: input.scope === "global" ? join(input.home, ".codex/AGENTS.md") : join(input.cwd, "AGENTS.md")
3098
- };
3099
- }
3100
- function planCodex(input, _previous) {
3101
- const paths = codexPaths(input);
3102
- const loaded = codecFor("toml", input.host).load(paths.config);
3103
- const tiers = { search: input.search, fetch: input.fetch };
3104
- const header = planHeader(input, paths, loaded, "TOML");
3105
- if (header.abort) {
3106
- return { host: "codex", scope: input.scope, tiers, edits: [], notes: header.notes, errors: header.errors };
3420
+ // src/installer/apply.ts
3421
+ import { existsSync as existsSync7, readFileSync as readFileSync8, rmSync as rmSync5 } from "node:fs";
3422
+ import { dirname as dirname5 } from "node:path";
3423
+ function applyPlan(plan, input) {
3424
+ if (plan.errors.length > 0) {
3425
+ throw new Error(`refusing to apply a plan with errors: ${plan.errors.map((error) => error.message).join("; ")}`);
3107
3426
  }
3108
- const notes = [...header.notes];
3109
- const errors = [...header.errors];
3110
- const edits = [];
3111
- const tools = loaded.value.tools;
3112
- if (isPlainObject(tools) && "web_search" in tools) {
3113
- errors.push({
3114
- message: `${paths.config} sets [tools] web_search, which is a silent no-op at Codex 0.163.0 (only the top-level ` + "web_search key controls the tool). Remove it first so the config cannot mislead."
3115
- });
3427
+ return withOperationLock([statePathFor(input)], () => withOperationLock([...plan.edits, ...plan.reverts ?? []].map((file) => file.file), () => applyLocked(plan, input)));
3428
+ }
3429
+ function applyLocked(plan, input) {
3430
+ const touched = [...plan.edits, ...plan.reverts ?? []];
3431
+ assertFileOwnership(input, touched);
3432
+ assertNoDrift(plan);
3433
+ const previous = readRecord(input);
3434
+ if (Object.hasOwn(plan, "previousRecord") && !deepEqual(previous, plan.previousRecord)) {
3435
+ throw new Error("The install record changed since the plan was rendered. Re-run init to plan against the current install.");
3116
3436
  }
3117
- const servers = loaded.value.mcp_servers;
3118
- if (isPlainObject(servers)) {
3119
- if ("deepseek" in servers)
3120
- notes.push(replacingExistingEntryNote("mcp_servers.deepseek"));
3121
- const { competitors, bystanders } = splitCompetitors(servers);
3437
+ const prepared = plan.edits.map((edit2) => prepareEdit(edit2, plan.host));
3438
+ const snapshots = snapshotTouchedFiles(plan);
3439
+ const files = [];
3440
+ const lines = [];
3441
+ const releaseNewClaims = acquireFileOwnership(input, touched);
3442
+ try {
3443
+ for (const item of prepared) {
3444
+ item.write();
3445
+ const desired = item.edit.format === "rules" ? [] : item.edit.ops;
3446
+ files.push(mergeRecorded(item.recorded, previous?.files.find((file) => file.file === item.edit.file), desired));
3447
+ lines.push(`${item.recorded.created ? "created" : "updated"} ${item.edit.file}`);
3448
+ }
3449
+ if ((plan.reverts ?? []).length > 0) {
3450
+ lines.push(...applyReverts(plan));
3451
+ }
3452
+ const verification = verifyEdits(plan.edits, plan.host);
3453
+ if (!verification.ok) {
3454
+ throw new Error(`verification failed: ${verification.lines.filter((line) => line.startsWith("FAIL")).join("; ")}`);
3455
+ }
3456
+ lines.push(...verification.lines);
3457
+ const record = {
3458
+ version: 1,
3459
+ host: plan.host,
3460
+ scope: plan.scope,
3461
+ tiers: plan.tiers,
3462
+ createdAt: new Date().toISOString(),
3463
+ files
3464
+ };
3465
+ writeRecord(input, record);
3466
+ } catch (error) {
3467
+ const rollback = rollbackApply(input, previous, snapshots);
3468
+ if (rollback.complete) {
3469
+ try {
3470
+ releaseNewClaims();
3471
+ } catch (releaseError) {
3472
+ rollback.complete = false;
3473
+ rollback.report.push(`could not release new config ownership: ${errorMessage(releaseError)}`);
3474
+ }
3475
+ }
3476
+ const status = rollback.complete ? "rolled_back" : "recovery_required";
3477
+ const outcome = rollback.complete ? "was rolled back" : "recovery is required";
3478
+ const guidance = rollback.complete ? "Fix the cause and re-run init." : "Restore the reported files and ownership metadata before re-running init.";
3479
+ throw new ApplyError(status, `apply failed and ${outcome} (${errorMessage(error)}). Rollback report: ${rollback.report.join("; ")}. ${guidance}`, error);
3480
+ }
3481
+ try {
3482
+ releaseFileOwnership(input, (previous?.files ?? []).filter((file) => !files.some((current) => current.file === file.file)));
3483
+ } catch (error) {
3484
+ throw new ApplyError("recovery_required", `apply was written and verified, but old config ownership could not be released (${errorMessage(error)}). ` + "The new install record is retained; repair the reported ownership metadata before re-running init.", error);
3485
+ }
3486
+ return { lines, ok: true };
3487
+ }
3488
+ function errorMessage(error) {
3489
+ return error instanceof Error ? error.message : String(error);
3490
+ }
3491
+ function assertNoDrift(plan) {
3492
+ const drifts = [];
3493
+ for (const edit2 of plan.edits) {
3494
+ if (edit2.format === "rules")
3495
+ continue;
3496
+ const loaded = codecFor(edit2.format, plan.host).load(edit2.file);
3497
+ if (loaded.parseError) {
3498
+ drifts.push(`${edit2.file} no longer parses (${loaded.parseError})`);
3499
+ continue;
3500
+ }
3501
+ for (const op of edit2.ops) {
3502
+ if (op.op !== "set")
3503
+ continue;
3504
+ const current = valueAt(loaded.value, op.path);
3505
+ if (!deepEqual(current, op.previous)) {
3506
+ drifts.push(`${edit2.file}: ${op.path.join(".")} changed since the plan was rendered`);
3507
+ }
3508
+ }
3509
+ }
3510
+ if (drifts.length > 0) {
3511
+ throw new Error(`refusing to apply — the config changed since the plan was rendered: ${drifts.join("; ")}. ` + "Re-run init to re-plan against the current files.");
3512
+ }
3513
+ }
3514
+ function snapshotTouchedFiles(plan) {
3515
+ const snapshots = new Map;
3516
+ const touched = [...plan.edits.map((edit2) => edit2.file), ...(plan.reverts ?? []).map((revert) => revert.file)];
3517
+ for (const file of new Set(touched)) {
3518
+ snapshots.set(file, existsSync7(file) ? { exists: true, text: readFileSync8(file, "utf8") } : { exists: false, text: "" });
3519
+ }
3520
+ return snapshots;
3521
+ }
3522
+ function rollbackApply(input, previous, snapshots) {
3523
+ const report = [];
3524
+ let complete = true;
3525
+ for (const [file, snapshot] of [...snapshots.entries()].reverse()) {
3526
+ try {
3527
+ if (snapshot.exists) {
3528
+ if (existsSync7(file) && readFileSync8(file, "utf8") === snapshot.text)
3529
+ continue;
3530
+ writeConfig(file, snapshot.text);
3531
+ if (readFileSync8(file, "utf8") !== snapshot.text)
3532
+ throw new Error("restored contents differ from the snapshot");
3533
+ } else {
3534
+ if (!existsSync7(file))
3535
+ continue;
3536
+ rmSync5(file);
3537
+ if (existsSync7(file))
3538
+ throw new Error("created file still exists after removal");
3539
+ pruneEmptyDir(dirname5(file));
3540
+ }
3541
+ report.push(`restored ${file}`);
3542
+ } catch (restoreError) {
3543
+ complete = false;
3544
+ report.push(`could not restore ${file}: ${errorMessage(restoreError)}`);
3545
+ }
3546
+ }
3547
+ try {
3548
+ if (previous !== undefined)
3549
+ writeRecord(input, previous);
3550
+ else
3551
+ deleteRecord(input);
3552
+ if (previous === undefined ? existsSync7(statePathFor(input)) : !deepEqual(readRecord(input), previous)) {
3553
+ throw new Error("restored install record differs from its previous state");
3554
+ }
3555
+ } catch (restoreError) {
3556
+ complete = false;
3557
+ report.push(`could not restore the install record: ${errorMessage(restoreError)}`);
3558
+ }
3559
+ return { complete, report };
3560
+ }
3561
+ function prepareEdit(edit2, host) {
3562
+ const codec = codecFor(edit2.format, host);
3563
+ const loaded = codec.load(edit2.file);
3564
+ if (loaded.parseError) {
3565
+ throw new Error(`${edit2.file} does not parse: ${loaded.parseError}`);
3566
+ }
3567
+ const applied = codec.apply(loaded.text, edit2);
3568
+ const recorded = edit2.format === "rules" ? { file: edit2.file, format: "rules", created: !loaded.exists, ops: [], blockApplied: edit2.block } : {
3569
+ file: edit2.file,
3570
+ format: edit2.format,
3571
+ created: !loaded.exists,
3572
+ ...edit2.shared ? { shared: true } : {},
3573
+ ops: applied.recorded,
3574
+ createdParents: applied.createdParents
3575
+ };
3576
+ return {
3577
+ edit: edit2,
3578
+ recorded,
3579
+ write: () => writeConfig(edit2.file, applied.text)
3580
+ };
3581
+ }
3582
+ function applyReverts(plan) {
3583
+ const lines = [];
3584
+ for (const revert of plan.reverts ?? []) {
3585
+ const whole = !plan.edits.some((edit2) => edit2.file === revert.file);
3586
+ lines.push(...revertRecordedFile(revert, plan.host, { label: " (previous init)", whole }));
3587
+ }
3588
+ return lines;
3589
+ }
3590
+ function mergeRecorded(current, before, desired) {
3591
+ if (before === undefined)
3592
+ return current;
3593
+ const ops = current.ops.map((op) => {
3594
+ const old = before.ops.find((candidate) => candidate.op === op.op && candidate.path.join("\x00") === op.path.join("\x00"));
3595
+ if (old === undefined)
3596
+ return op;
3597
+ if (op.op === "set" && old.op === "set" && deepEqual(op.previous, old.applied)) {
3598
+ return { ...op, previous: old.previous };
3599
+ }
3600
+ if (op.op === "append-array" && old.op === "append-array") {
3601
+ const wanted = desired.find((candidate) => candidate.op === "append-array" && candidate.path.join("\x00") === op.path.join("\x00"));
3602
+ const kept = (Array.isArray(old.applied) ? old.applied : []).filter((entry) => (wanted?.entries ?? []).some((candidate) => deepEqual(candidate, entry)));
3603
+ return { ...op, applied: [...kept, ...Array.isArray(op.applied) ? op.applied : []] };
3604
+ }
3605
+ return op;
3606
+ });
3607
+ return {
3608
+ ...current,
3609
+ created: before.created,
3610
+ ops,
3611
+ createdParents: [...before.createdParents ?? [], ...current.createdParents ?? []]
3612
+ };
3613
+ }
3614
+ function verifyEdits(edits, host) {
3615
+ const lines = [];
3616
+ let ok = true;
3617
+ for (const edit2 of edits) {
3618
+ if (!existsSync7(edit2.file)) {
3619
+ lines.push(`FAIL ${edit2.file} does not exist after apply`);
3620
+ ok = false;
3621
+ continue;
3622
+ }
3623
+ const codec = codecFor(edit2.format, host);
3624
+ const text = readFileSync8(edit2.file, "utf8");
3625
+ if (edit2.format === "rules") {
3626
+ const good = codec.verify(text, edit2);
3627
+ lines.push(`${good ? "pass" : "FAIL"} ${edit2.file}: rules block ${good ? "present" : "missing"}`);
3628
+ ok = ok && good;
3629
+ continue;
3630
+ }
3631
+ for (const op of edit2.ops) {
3632
+ const good = codec.verify(text, edit2, op);
3633
+ lines.push(`${good ? "pass" : "FAIL"} ${edit2.file}: ${describeOp(op)} ${good ? "holds" : "missing"}`);
3634
+ ok = ok && good;
3635
+ }
3636
+ }
3637
+ return { lines, ok };
3638
+ }
3639
+ function describeOp(op) {
3640
+ if (op.op === "set")
3641
+ return `set ${op.path.join(".")}`;
3642
+ return `append ${op.path.join(".")}`;
3643
+ }
3644
+ var ApplyError;
3645
+ var init_apply = __esm(() => {
3646
+ init_formats();
3647
+ init_value();
3648
+ init_operation_lock();
3649
+ init_ownership();
3650
+ init_revert();
3651
+ init_state();
3652
+ ApplyError = class ApplyError extends Error {
3653
+ status;
3654
+ constructor(status, message, cause) {
3655
+ super(message, { cause });
3656
+ this.status = status;
3657
+ this.name = "ApplyError";
3658
+ }
3659
+ };
3660
+ });
3661
+
3662
+ // src/installer/detect.ts
3663
+ import { existsSync as existsSync8, readFileSync as readFileSync9 } from "node:fs";
3664
+ function replacingExistingEntryNote(path) {
3665
+ return `An existing ${path} entry is being replaced; its previous form is recorded for uninstall.`;
3666
+ }
3667
+ function othersStayRegisteredNote(others) {
3668
+ return `Other MCP servers stay registered (${others.join(", ")}); routing to DeepSeek is not exclusive while they offer web tools.`;
3669
+ }
3670
+ function looksWebCapable(name, entry) {
3671
+ if (WEB_TOOL_HINT.test(name))
3672
+ return true;
3673
+ if (!isPlainObject(entry))
3674
+ return false;
3675
+ const command = typeof entry.command === "string" ? entry.command : "";
3676
+ const url = typeof entry.url === "string" ? entry.url : "";
3677
+ const args = Array.isArray(entry.args) ? entry.args.filter((arg) => typeof arg === "string").join(" ") : "";
3678
+ return WEB_TOOL_HINT.test(`${command} ${args} ${url}`);
3679
+ }
3680
+ function looksLikeOwnPackage(source) {
3681
+ return /(^|[/:@])deepseek-web-retrieval($|[@/]|-\d)/.test(source);
3682
+ }
3683
+ function findCompetitorSteering(host, file) {
3684
+ if (!existsSync8(file))
3685
+ return [];
3686
+ let text;
3687
+ try {
3688
+ text = readFileSync9(file, "utf8");
3689
+ } catch {
3690
+ return [];
3691
+ }
3692
+ return removeBlock(host, text).split(`
3693
+ `).map((line) => line.trim()).filter((line) => line.length > 0 && STEERING_HINT.test(line) && !line.includes("deepseek")).slice(0, 2);
3694
+ }
3695
+ function competitorSteeringNote(file, lines) {
3696
+ const quoted = lines.map((line) => `"${line}"`).join(", ");
3697
+ return `Existing rules in ${file} steer toward other web tools (${quoted}); the plan surfaces this conflict — it does not resolve it.`;
3698
+ }
3699
+ function splitCompetitors(servers) {
3700
+ const competitors = [];
3701
+ const bystanders = [];
3702
+ for (const [name, entry] of Object.entries(servers)) {
3703
+ if (name === "deepseek")
3704
+ continue;
3705
+ (looksWebCapable(name, entry) ? competitors : bystanders).push(name);
3706
+ }
3707
+ return { competitors, bystanders };
3708
+ }
3709
+ var BOTH_CAPABILITIES_OFF = "Both capabilities are off; nothing would be installed.", FORCED_COST_NOTE, WEB_TOOL_HINT, STEERING_HINT;
3710
+ var init_detect = __esm(() => {
3711
+ init_rules();
3712
+ init_value();
3713
+ FORCED_COST_NOTE = "Forced routing is deterministic, and Search and Fetch are billed per successful request — " + `see the README's "Privacy and cost" section before applying.`;
3714
+ WEB_TOOL_HINT = /(web[_ -]?(search|fetch)|search|fetch|browse|scrape|crawl|tavily|brave|exa|perplexity|firecrawl|jina|serp|duckduckgo|reader)/i;
3715
+ STEERING_HINT = /(web[_ ]?(search|fetch)|websearch|webfetch|tavily|exa|brave|perplexity|firecrawl|jina|serp|duckduckgo|google|bing)/i;
3716
+ });
3717
+
3718
+ // src/installer/rulesContent.ts
3719
+ function rulesLines(ids, search, fetch) {
3720
+ const lines = ["DeepSeek retrieval guidance:"];
3721
+ if (search === "preferred") {
3722
+ lines.push(`- Use ${ids.search} when the task needs fresh external information: current docs, version numbers,`, " changelogs, or anything you cannot know from the repository alone. Do not search for facts already", " present locally.");
3723
+ }
3724
+ if (fetch === "preferred") {
3725
+ lines.push(`- Use ${ids.fetch} to read a specific public web page as Markdown when its content is needed.`);
3726
+ }
3727
+ lines.push("- Never send localhost or private-network URLs to the DeepSeek tools; use local tools for those (the", " server blocks private hosts by default).", "- DeepSeek requests are billed per successful call; retrieve only what the task needs.");
3728
+ return lines;
3729
+ }
3730
+ var MCP_TOOL_IDS, RAW_MCP_TOOL_IDS;
3731
+ var init_rulesContent = __esm(() => {
3732
+ MCP_TOOL_IDS = {
3733
+ search: "mcp__deepseek__deepseek_web_search",
3734
+ fetch: "mcp__deepseek__deepseek_fetch"
3735
+ };
3736
+ RAW_MCP_TOOL_IDS = { search: "deepseek_web_search", fetch: "deepseek_fetch" };
3737
+ });
3738
+
3739
+ // src/installer/hosts/shared.ts
3740
+ function planHeader(input, paths, loaded, dialect) {
3741
+ const notes = [];
3742
+ const errors = [];
3743
+ if (loaded.parseError) {
3744
+ errors.push({ message: `${paths.config} exists but does not parse as ${dialect}: ${loaded.parseError}` });
3745
+ return { notes, errors, abort: true };
3746
+ }
3747
+ if (input.search === "off" && input.fetch === "off") {
3748
+ errors.push({ message: BOTH_CAPABILITIES_OFF });
3749
+ }
3750
+ const rules = codecFor("rules", input.host).load(paths.rules);
3751
+ if (rules.parseError) {
3752
+ errors.push({ message: `${paths.rules}: ${rules.parseError}` });
3753
+ return { notes, errors, abort: true };
3754
+ }
3755
+ const steering = findCompetitorSteering(input.host, paths.rules);
3756
+ if (steering.length > 0)
3757
+ notes.push(competitorSteeringNote(paths.rules, steering));
3758
+ return { notes, errors, abort: false };
3759
+ }
3760
+ var init_shared = __esm(() => {
3761
+ init_detect();
3762
+ init_formats();
3763
+ });
3764
+
3765
+ // src/installer/hosts/codex.ts
3766
+ import { spawnSync } from "node:child_process";
3767
+ function probeCodexVersion(err) {
3768
+ const result = spawnSync("codex", ["--version"], { timeout: 5000, encoding: "utf8", env: process.env });
3769
+ const version = result.status === 0 ? parseCodexVersion(result.stdout ?? "") : undefined;
3770
+ if (version === undefined) {
3771
+ err("note: codex was not found on PATH or printed no version; the version-drift check was skipped");
3772
+ }
3773
+ return version;
3774
+ }
3775
+ function parseCodexVersion(output) {
3776
+ return /\bcodex(?:-cli)?\s+(\d+\.\d+\.\d+)\b/.exec(output)?.[1];
3777
+ }
3778
+ function codexPaths(input) {
3779
+ return {
3780
+ config: join2(input.home, ".codex/config.toml"),
3781
+ rules: input.scope === "global" ? join2(input.home, ".codex/AGENTS.md") : join2(input.cwd, "AGENTS.md")
3782
+ };
3783
+ }
3784
+ function planCodex(input, _previous) {
3785
+ const paths = codexPaths(input);
3786
+ const loaded = codecFor("toml", input.host).load(paths.config);
3787
+ const tiers = { search: input.search, fetch: input.fetch };
3788
+ const header = planHeader(input, paths, loaded, "TOML");
3789
+ if (header.abort) {
3790
+ return { host: "codex", scope: input.scope, tiers, edits: [], notes: header.notes, errors: header.errors };
3791
+ }
3792
+ const notes = [...header.notes];
3793
+ const errors = [...header.errors];
3794
+ const edits = [];
3795
+ if (input.hostVersion !== undefined && input.hostVersion !== CODEX_TESTED_VERSION) {
3796
+ notes.push(`Codex ${input.hostVersion} is installed, but this installer's Codex facts are verified against ` + `${CODEX_TESTED_VERSION} (the docs/hosts.jsonc lock). Kill-switch and trap behavior may have drifted ` + "between the two — re-verify the tiers if routing misbehaves.");
3797
+ }
3798
+ const tools = loaded.value.tools;
3799
+ if (isPlainObject(tools) && "web_search" in tools) {
3800
+ errors.push({
3801
+ message: `${paths.config} sets [tools] web_search, which is a silent no-op at Codex 0.163.0 (only the top-level ` + "web_search key controls the tool). Remove it first so the config cannot mislead."
3802
+ });
3803
+ }
3804
+ const servers = loaded.value.mcp_servers;
3805
+ if (isPlainObject(servers)) {
3806
+ if ("deepseek" in servers)
3807
+ notes.push(replacingExistingEntryNote("mcp_servers.deepseek"));
3808
+ const { competitors, bystanders } = splitCompetitors(servers);
3122
3809
  if ((input.search === "forced" || input.fetch === "forced") && competitors.length > 0) {
3123
3810
  errors.push({
3124
3811
  message: `Forced cannot deliver exclusive routing: [mcp_servers.${competitors.join("], [mcp_servers.")}] look(s) web-capable, ` + "and Codex offers no per-server kill switch. Remove the server(s), disable their tools by hand via their " + "disabled_tools key, or choose a lower tier."
@@ -3128,27 +3815,29 @@ function planCodex(input, _previous) {
3128
3815
  if (stayRegistered.length > 0)
3129
3816
  notes.push(othersStayRegisteredNote(stayRegistered));
3130
3817
  }
3131
- const env = {};
3132
- if (input.search === "off")
3133
- env[ENABLE_SEARCH_ENV] = "false";
3134
- if (input.fetch === "off")
3135
- env[ENABLE_FETCH_ENV] = "false";
3818
+ const existingEntry = isPlainObject(servers) ? servers.deepseek : undefined;
3819
+ const rawToolIds = Object.values(RAW_MCP_TOOL_IDS);
3820
+ const keptDisabled = isPlainObject(existingEntry) && Array.isArray(existingEntry.disabled_tools) ? existingEntry.disabled_tools.filter((name) => typeof name === "string" && !rawToolIds.includes(name)) : [];
3821
+ const offDisabled = [
3822
+ ...input.search === "off" ? [RAW_MCP_TOOL_IDS.search] : [],
3823
+ ...input.fetch === "off" ? [RAW_MCP_TOOL_IDS.fetch] : []
3824
+ ];
3136
3825
  const entry = {
3137
3826
  command: "npx",
3138
3827
  args: ["-y", input.packageRef],
3139
- ...Object.keys(env).length > 0 ? { env } : {},
3828
+ ...keptDisabled.length > 0 || offDisabled.length > 0 ? { disabled_tools: [...keptDisabled, ...offDisabled] } : {},
3140
3829
  env_vars: [API_KEY_ENV]
3141
3830
  };
3142
- const existingEntry = isPlainObject(servers) ? servers.deepseek : undefined;
3143
- const ops = [
3144
- { op: "set", path: ["mcp_servers", "deepseek"], value: entry, previous: existingEntry }
3145
- ];
3831
+ const ops = [{ op: "set", path: ["mcp_servers", "deepseek"], value: entry, previous: existingEntry }];
3832
+ if (offDisabled.length > 0) {
3833
+ notes.push(`Off capabilities use Codex's own disabled_tools key (${offDisabled.join(", ")}): the tools are removed from ` + "the model's context while the server keeps running and stays re-enableable by deleting the entries.");
3834
+ }
3146
3835
  if (input.search === "forced") {
3147
3836
  const previous = typeof loaded.value.web_search === "string" ? loaded.value.web_search : undefined;
3148
3837
  ops.push({ op: "set", path: ["web_search"], value: "disabled", previous });
3149
3838
  notes.push(previous === undefined ? 'web_search is being set to "disabled" (no previous value existed; the default is "cached").' : `web_search is being set to "disabled"; the previous value "${previous}" is snapshotted for uninstall.`);
3150
3839
  }
3151
- edits.push({ file: paths.config, format: "toml", ops });
3840
+ edits.push({ file: paths.config, format: "toml", shared: true, ops });
3152
3841
  notes.push(`Codex stdio children do not inherit the parent environment, so the entry forwards ${API_KEY_ENV} explicitly ` + "via env_vars; the key must be set in the shell that launches Codex.");
3153
3842
  if (input.scope === "project") {
3154
3843
  notes.push("Codex config lives in ~/.codex/config.toml only (user level), so the server registration and kill switch are " + "written there even for a project-scope install; the rules file stays project-local.");
@@ -3157,7 +3846,7 @@ function planCodex(input, _previous) {
3157
3846
  notes.push(`${paths.config} contains comments; the TOML rewrite will drop them.`);
3158
3847
  }
3159
3848
  if (input.search === "forced" || input.fetch === "forced") {
3160
- notes.push('Forced search is exclusion: the built-in web_search tool is disabled ("disabled" mode). ' + FORCED_COST_NOTE);
3849
+ notes.push(`Forced search is exclusion: the built-in web_search tool is disabled ("disabled" mode). ${FORCED_COST_NOTE}`);
3161
3850
  }
3162
3851
  if (input.search === "optional" || input.fetch === "optional") {
3163
3852
  notes.push('Note: Codex ships a built-in web_search tool (default "cached" mode, an OpenAI index) that stays available ' + "alongside the DeepSeek tools under the optional tier.");
@@ -3172,18 +3861,24 @@ function planCodex(input, _previous) {
3172
3861
  }
3173
3862
  return { host: "codex", scope: input.scope, tiers, edits, notes, errors };
3174
3863
  }
3864
+ var CODEX_TESTED_VERSION = "0.163.0";
3175
3865
  var init_codex = __esm(() => {
3176
- init_formats();
3177
- init_detect();
3178
3866
  init_constants();
3867
+ init_detect();
3868
+ init_formats();
3179
3869
  init_rules();
3870
+ init_value();
3871
+ init_paths();
3180
3872
  init_rulesContent();
3181
3873
  init_shared();
3182
3874
  });
3183
3875
 
3184
3876
  // src/installer/hosts/opencode.ts
3185
3877
  function opencodePaths(input) {
3186
- return input.scope === "global" ? { config: join(configHome(input), "opencode/opencode.jsonc"), rules: join(configHome(input), "opencode/AGENTS.md") } : { config: join(input.cwd, "opencode.jsonc"), rules: join(input.cwd, "AGENTS.md") };
3878
+ return input.scope === "global" ? {
3879
+ config: join2(configHome(input), "opencode/opencode.jsonc"),
3880
+ rules: join2(configHome(input), "opencode/AGENTS.md")
3881
+ } : { config: join2(input.cwd, "opencode.jsonc"), rules: join2(input.cwd, "AGENTS.md") };
3187
3882
  }
3188
3883
  function planOpenCode(input, previous) {
3189
3884
  const paths = opencodePaths(input);
@@ -3202,14 +3897,17 @@ function planOpenCode(input, previous) {
3202
3897
  });
3203
3898
  }
3204
3899
  const plugins = Array.isArray(loaded.value.plugins) ? loaded.value.plugins : [];
3205
- const alreadyInstalled = plugins.some((entry) => isPlainObject(entry) && typeof entry.package === "string" && entry.package.includes("deepseek-web-retrieval"));
3206
- if (alreadyInstalled && previous === undefined) {
3900
+ const trackedPackages = new Set(appendedByPrevious(previous, ["plugins"]).filter((entry) => isPlainObject(entry) && typeof entry.package === "string").map((entry) => entry.package));
3901
+ const pluginPackages = plugins.filter((entry) => isPlainObject(entry) && typeof entry.package === "string");
3902
+ const excused = previous === undefined ? new Set : new Set([...trackedPackages, input.packageRef]);
3903
+ const foreignOwn = pluginPackages.filter((entry) => looksLikeOwnPackage(entry.package) && !excused.has(entry.package));
3904
+ if (foreignOwn.length > 0) {
3207
3905
  errors.push({
3208
3906
  message: `${paths.config} already has a plugins entry referencing deepseek-web-retrieval that this installer did not ` + "write. Remove it (or run uninstall if it came from a previous init), then retry."
3209
3907
  });
3210
3908
  }
3211
- if (alreadyInstalled && errors.length === 0) {
3212
- notes.push("A previous init wrote the plugins entry; re-applying updates it and carries the original snapshots over.");
3909
+ if (previous !== undefined && pluginPackages.some((entry) => excused.has(entry.package))) {
3910
+ notes.push("A previous init wrote the plugins entry; re-applying keeps it the only entry (a changed package ref or tier " + "options replace it, an unchanged one is a no-op) and the original snapshots carry over.");
3213
3911
  }
3214
3912
  if (errors.length > 0) {
3215
3913
  return { host: "opencode", scope: input.scope, tiers, edits, notes, errors };
@@ -3253,7 +3951,7 @@ function planOpenCode(input, previous) {
3253
3951
  notes.push("Search tier optional: the deepseek provider is registered but the built-in provider stays the default engine; " + "set websearch.provider to deepseek (or rerun with --search forced) to route search through DeepSeek.");
3254
3952
  }
3255
3953
  if (input.search === "forced" || input.fetch === "forced") {
3256
- notes.push("Forced tier replaces built-in routing. " + FORCED_COST_NOTE);
3954
+ notes.push(`Forced tier replaces built-in routing. ${FORCED_COST_NOTE}`);
3257
3955
  }
3258
3956
  if (input.fetch === "preferred") {
3259
3957
  edits.push({
@@ -3267,19 +3965,164 @@ function planOpenCode(input, previous) {
3267
3965
  }
3268
3966
  var OPENCODE_TOOL_IDS;
3269
3967
  var init_opencode = __esm(() => {
3270
- init_formats();
3271
3968
  init_detect();
3969
+ init_formats();
3272
3970
  init_rules();
3971
+ init_value();
3972
+ init_paths();
3273
3973
  init_rulesContent();
3974
+ init_state();
3274
3975
  init_shared();
3275
3976
  OPENCODE_TOOL_IDS = { search: "websearch (provider deepseek)", fetch: "deepseek_fetch" };
3276
3977
  });
3277
3978
 
3979
+ // src/pi/settings.ts
3980
+ var SETTINGS_NAMESPACE = "deepseekWebRetrieval";
3981
+ var init_settings = () => {};
3982
+
3278
3983
  // src/installer/hosts/pi.ts
3279
3984
  function piPaths(input) {
3280
- return input.scope === "global" ? { config: join(input.home, ".pi/agent/mcp.json"), rules: join(input.home, ".pi/agent/AGENTS.md") } : { config: join(input.cwd, ".pi/mcp.json"), rules: join(input.cwd, "AGENTS.md") };
3985
+ const mcp = input.scope === "global" ? join2(input.home, ".pi/agent/mcp.json") : join2(input.cwd, ".pi/mcp.json");
3986
+ const settings = input.scope === "global" ? join2(input.home, ".pi/agent/settings.json") : join2(input.cwd, ".pi/settings.json");
3987
+ const rules = input.scope === "global" ? join2(input.home, ".pi/agent/AGENTS.md") : join2(input.cwd, "AGENTS.md");
3988
+ return { config: input.path === "mcp" ? mcp : settings, rules };
3989
+ }
3990
+ function piSourceRef(packageRef) {
3991
+ return packageRef.startsWith("npm:") ? packageRef : `npm:${packageRef}`;
3992
+ }
3993
+ function planPi(input, previous) {
3994
+ return input.path === "mcp" ? planPiMcp(input) : planPiExtension(input, previous);
3995
+ }
3996
+ function planPiExtension(input, previous) {
3997
+ const paths = piPaths(input);
3998
+ const loaded = codecFor("json", input.host).load(paths.config);
3999
+ const tiers = { search: input.search, fetch: input.fetch };
4000
+ const header = planHeader(input, paths, loaded, "JSON");
4001
+ if (header.abort) {
4002
+ return { host: "pi", scope: input.scope, tiers, edits: [], notes: header.notes, errors: header.errors };
4003
+ }
4004
+ const notes = [...header.notes];
4005
+ const errors = [...header.errors];
4006
+ const edits = [];
4007
+ const packages = Array.isArray(loaded.value.packages) ? loaded.value.packages : [];
4008
+ const source = piSourceRef(input.packageRef);
4009
+ const trackedSources = appendedByPrevious(previous, ["packages"]).filter((entry) => typeof entry === "string");
4010
+ const excused = previous === undefined ? new Set : new Set([...trackedSources, source]);
4011
+ const sources = packages.filter((entry) => typeof entry === "string");
4012
+ const foreignOwn = sources.filter((entry) => looksLikeOwnPackage(entry) && !excused.has(entry));
4013
+ if (foreignOwn.length > 0) {
4014
+ errors.push({
4015
+ message: `${paths.config} already lists ${foreignOwn.join(", ")} — this package (or a fork of it) that this installer did not ` + "write. Remove it (pi remove <source>, or uninstall if it came from a previous init), then retry."
4016
+ });
4017
+ }
4018
+ if (previous !== undefined && sources.some((entry) => excused.has(entry))) {
4019
+ notes.push("A previous init wrote the packages entry; re-applying reuses it (an unchanged source deduplicates, a changed " + "--package ref replaces the old source) and the original snapshots carry over.");
4020
+ }
4021
+ const forced = input.search === "forced" || input.fetch === "forced";
4022
+ if (forced) {
4023
+ const ownSources = new Set([...trackedSources, source]);
4024
+ const unexcludable = [...collectSources(loaded.value.packages), ...collectSources(loaded.value.extensions)].filter((entry) => !ownSources.has(entry) && looksWebCapable(entry, undefined));
4025
+ if (unexcludable.length > 0) {
4026
+ errors.push({
4027
+ message: `Forced cannot deliver exclusive routing: settings sources ${unexcludable.join(", ")} look web-capable, ` + "and Pi offers no per-package kill switch. Remove them (pi remove <source>) or choose a lower tier."
4028
+ });
4029
+ }
4030
+ }
4031
+ if (errors.length > 0) {
4032
+ return { host: "pi", scope: input.scope, tiers, edits: [], notes, errors };
4033
+ }
4034
+ const ops = [
4035
+ { op: "append-array", path: ["packages"], entries: [source], existingCount: packages.length }
4036
+ ];
4037
+ const namespace = isPlainObject(loaded.value[SETTINGS_NAMESPACE]) ? loaded.value[SETTINGS_NAMESPACE] : {};
4038
+ if (input.search === "off") {
4039
+ ops.push({
4040
+ op: "set",
4041
+ path: [SETTINGS_NAMESPACE, "enableSearch"],
4042
+ value: false,
4043
+ previous: typeof namespace.enableSearch === "boolean" ? namespace.enableSearch : undefined
4044
+ });
4045
+ }
4046
+ if (input.fetch === "off") {
4047
+ ops.push({
4048
+ op: "set",
4049
+ path: [SETTINGS_NAMESPACE, "enableFetch"],
4050
+ value: false,
4051
+ previous: typeof namespace.enableFetch === "boolean" ? namespace.enableFetch : undefined
4052
+ });
4053
+ }
4054
+ edits.push({ file: paths.config, format: "json", ops });
4055
+ notes.push("Pi resolves the packages source at session start; the first launch after apply fetches the package from npm " + "(pi update --extensions refreshes it later).");
4056
+ notes.push(`The extension reads ${API_KEY_ENV} from the Pi process environment; no env forwarding is needed. ` + "Set it in the shell that launches Pi.");
4057
+ if (input.search === "off" || input.fetch === "off") {
4058
+ notes.push(`Off capability switches are written into the "${SETTINGS_NAMESPACE}" settings namespace; the extension ` + "reads them at load (they outrank the DEEPSEEK_ENABLE_* environment variables).");
4059
+ }
4060
+ notes.push(`Extension tools are declared to the model with exposure "direct" by Pi's own default; no exposure key is needed.`);
4061
+ if (forced) {
4062
+ edits.push(...planMcpCompetitorDisables(input, notes, errors));
4063
+ }
4064
+ if (errors.length > 0) {
4065
+ return { host: "pi", scope: input.scope, tiers, edits: [], notes, errors };
4066
+ }
4067
+ if (input.search === "preferred" || input.fetch === "preferred") {
4068
+ edits.push({
4069
+ file: paths.rules,
4070
+ format: "rules",
4071
+ block: buildRulesBlock(input.host, rulesLines(RAW_MCP_TOOL_IDS, input.search, input.fetch))
4072
+ });
4073
+ notes.push(`Rules block upserted in ${paths.rules}; Pi loads AGENTS.md context files from the working directory and its parents.`);
4074
+ }
4075
+ if (forced) {
4076
+ notes.push("Forced tier: Pi 1.3.0 ships no built-in web tool, so the registered tools are the only web path (de-facto). " + FORCED_COST_NOTE);
4077
+ }
4078
+ return { host: "pi", scope: input.scope, tiers, edits, notes, errors };
4079
+ }
4080
+ function planCompetitorDisables(servers, competitors, notes, errors) {
4081
+ const ops = [];
4082
+ const excluded = [];
4083
+ const unexcludable = [];
4084
+ for (const name of competitors) {
4085
+ const current = servers[name];
4086
+ if (!isPlainObject(current)) {
4087
+ unexcludable.push(name);
4088
+ continue;
4089
+ }
4090
+ const previous = typeof current.enabled === "boolean" ? current.enabled : undefined;
4091
+ ops.push({ op: "set", path: ["mcpServers", name, "enabled"], value: false, previous });
4092
+ excluded.push(name);
4093
+ }
4094
+ if (excluded.length > 0) {
4095
+ notes.push(`Forced tier: web-capable MCP servers are disabled so DeepSeek routing is exclusive (${excluded.join(", ")}; ` + "prior enabled values are snapshotted for uninstall).");
4096
+ }
4097
+ if (unexcludable.length > 0) {
4098
+ errors.push({
4099
+ message: `Forced cannot deliver exclusive routing: mcpServers entries ${unexcludable.join(", ")} look web-capable ` + "but are not objects, so the plan cannot set their enabled key. Fix or remove them, or choose a lower tier."
4100
+ });
4101
+ }
4102
+ return ops;
4103
+ }
4104
+ function planMcpCompetitorDisables(input, notes, errors) {
4105
+ const mcpFile = input.scope === "global" ? join2(input.home, ".pi/agent/mcp.json") : join2(input.cwd, ".pi/mcp.json");
4106
+ const loaded = codecFor("json", input.host).load(mcpFile);
4107
+ if (loaded.parseError) {
4108
+ errors.push({ message: `${mcpFile} does not parse: ${loaded.parseError}` });
4109
+ return [];
4110
+ }
4111
+ const servers = isPlainObject(loaded.value.mcpServers) ? loaded.value.mcpServers : undefined;
4112
+ if (!isPlainObject(servers))
4113
+ return [];
4114
+ const { competitors, bystanders } = splitCompetitors(servers);
4115
+ const edits = [];
4116
+ if (competitors.length > 0) {
4117
+ const ops = planCompetitorDisables(servers, competitors, notes, errors);
4118
+ if (ops.length > 0)
4119
+ edits.push({ file: mcpFile, format: "json", ops });
4120
+ }
4121
+ if (bystanders.length > 0)
4122
+ notes.push(othersStayRegisteredNote(bystanders));
4123
+ return edits;
3281
4124
  }
3282
- function planPi(input, _previous) {
4125
+ function planPiMcp(input) {
3283
4126
  const paths = piPaths(input);
3284
4127
  const loaded = codecFor("json", input.host).load(paths.config);
3285
4128
  const tiers = { search: input.search, fetch: input.fetch };
@@ -3304,9 +4147,7 @@ function planPi(input, _previous) {
3304
4147
  };
3305
4148
  const servers = loaded.value.mcpServers;
3306
4149
  const existingEntry = isPlainObject(servers) ? servers.deepseek : undefined;
3307
- const ops = [
3308
- { op: "set", path: ["mcpServers", "deepseek"], value: entry, previous: existingEntry }
3309
- ];
4150
+ const ops = [{ op: "set", path: ["mcpServers", "deepseek"], value: entry, previous: existingEntry }];
3310
4151
  edits.push({ file: paths.config, format: "json", ops });
3311
4152
  if (isPlainObject(servers)) {
3312
4153
  if (servers.deepseek !== undefined)
@@ -3314,28 +4155,7 @@ function planPi(input, _previous) {
3314
4155
  const { competitors, bystanders } = splitCompetitors(servers);
3315
4156
  const forced = input.search === "forced" || input.fetch === "forced";
3316
4157
  if (forced) {
3317
- const excluded = [];
3318
- const unexcludable = [];
3319
- for (const name of competitors) {
3320
- const current = servers[name];
3321
- if (!isPlainObject(current)) {
3322
- unexcludable.push(name);
3323
- continue;
3324
- }
3325
- const previous = typeof current.enabled === "boolean" ? current.enabled : undefined;
3326
- if (previous === false)
3327
- continue;
3328
- ops.push({ op: "set", path: ["mcpServers", name, "enabled"], value: false, previous });
3329
- excluded.push(name);
3330
- }
3331
- if (excluded.length > 0) {
3332
- notes.push(`Forced tier: web-capable MCP servers are disabled so DeepSeek routing is exclusive (${excluded.join(", ")}; ` + "prior enabled values are snapshotted for uninstall).");
3333
- }
3334
- if (unexcludable.length > 0) {
3335
- errors.push({
3336
- message: `Forced cannot deliver exclusive routing: mcpServers entries ${unexcludable.join(", ")} look web-capable ` + "but are not objects, so the plan cannot set their enabled key. Fix or remove them, or choose a lower tier."
3337
- });
3338
- }
4158
+ ops.push(...planCompetitorDisables(servers, competitors, notes, errors));
3339
4159
  }
3340
4160
  const stayRegistered = forced ? bystanders : [...competitors, ...bystanders];
3341
4161
  if (stayRegistered.length > 0)
@@ -3343,9 +4163,6 @@ function planPi(input, _previous) {
3343
4163
  }
3344
4164
  notes.push('"exposure": "direct" is required: Pi 1.3.0 defaults MCP tools to codemode, which hides them from the model.');
3345
4165
  notes.push(`\`${API_KEY_ENV}\` is written as "\${${API_KEY_ENV}}" so the key expands from the shell that launches Pi and stays out of the file.`);
3346
- if (loaded.lossy) {
3347
- notes.push(`${paths.config} contains comments; the JSON edit preserves them.`);
3348
- }
3349
4166
  if (input.search === "preferred" || input.fetch === "preferred") {
3350
4167
  edits.push({
3351
4168
  file: paths.rules,
@@ -3359,81 +4176,56 @@ function planPi(input, _previous) {
3359
4176
  }
3360
4177
  return { host: "pi", scope: input.scope, tiers, edits, notes, errors };
3361
4178
  }
4179
+ function collectSources(value) {
4180
+ return Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
4181
+ }
3362
4182
  var init_pi = __esm(() => {
3363
- init_formats();
3364
- init_detect();
3365
4183
  init_constants();
4184
+ init_settings();
4185
+ init_detect();
4186
+ init_formats();
3366
4187
  init_rules();
4188
+ init_value();
4189
+ init_paths();
3367
4190
  init_rulesContent();
4191
+ init_state();
3368
4192
  init_shared();
3369
4193
  });
3370
4194
 
3371
- // src/installer/state.ts
3372
- import { chmodSync, existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync5, readdirSync, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
3373
- import { join as join2 } from "node:path";
3374
- function statePathFor(input) {
3375
- return join2(stateDir(input, input.scope), `install.${input.host}.json`);
3376
- }
3377
- function readRecord(input) {
3378
- const file = statePathFor(input);
3379
- if (!existsSync5(file))
3380
- return;
3381
- try {
3382
- return JSON.parse(readFileSync5(file, "utf8"));
3383
- } catch {
3384
- return;
3385
- }
3386
- }
3387
- function claimedByOtherScope(input, files) {
3388
- const other = input.scope === "global" ? "project" : "global";
3389
- const record = readRecord({ ...input, scope: other });
3390
- if (record === undefined)
3391
- return [];
3392
- return files.filter((file) => record.files.some((candidate) => candidate.file === file));
3393
- }
3394
- function writeRecord(input, record) {
3395
- const file = statePathFor(input);
3396
- mkdirSync2(join2(file, ".."), { recursive: true });
3397
- writeFileSync2(file, `${JSON.stringify(record, null, 2)}
3398
- `, { mode: 384 });
3399
- chmodSync(file, 384);
3400
- }
3401
- function deleteRecord(input) {
3402
- const file = statePathFor(input);
3403
- if (existsSync5(file))
3404
- rmSync(file);
3405
- }
3406
- function listRecords(roots) {
3407
- const records = [];
3408
- for (const scope of ["global", "project"]) {
3409
- const dir = stateDir(roots, scope);
3410
- if (!existsSync5(dir))
3411
- continue;
3412
- for (const name of readdirSync(dir)) {
3413
- if (!name.startsWith("install.") || !name.endsWith(".json"))
3414
- continue;
3415
- try {
3416
- records.push(JSON.parse(readFileSync5(join2(dir, name), "utf8")));
3417
- } catch {}
3418
- }
3419
- }
3420
- return records;
3421
- }
3422
- var init_state = () => {};
3423
-
3424
4195
  // src/installer/hosts/index.ts
3425
4196
  function planForHost(host, input) {
3426
- const previous = readRecord(input);
3427
- const planned = withCrossScopeGuard(HOST_PLANNERS[host](input, previous), input);
3428
- return withSecretGuard(withOrphanReverts(planned, input, previous));
4197
+ try {
4198
+ const previous = readRecord(input);
4199
+ const planned = withCrossScopeGuard(HOST_PLANNERS[host](input, previous), input);
4200
+ return {
4201
+ ...withSecretGuard(withOrphanReverts(withLegacySharedConfigGuard(planned, previous), previous)),
4202
+ previousRecord: previous
4203
+ };
4204
+ } catch (error) {
4205
+ if (!(error instanceof InstallRecordError))
4206
+ throw error;
4207
+ return {
4208
+ host,
4209
+ scope: input.scope,
4210
+ tiers: { search: input.search, fetch: input.fetch },
4211
+ edits: [],
4212
+ notes: [],
4213
+ errors: [{ message: error.message }]
4214
+ };
4215
+ }
3429
4216
  }
3430
4217
  function withCrossScopeGuard(plan, input) {
3431
4218
  if (plan.errors.length > 0)
3432
4219
  return plan;
4220
+ try {
4221
+ assertFileOwnership(input, plan.edits);
4222
+ } catch (error) {
4223
+ return { ...plan, edits: [], reverts: undefined, errors: [{ message: String(error) }] };
4224
+ }
3433
4225
  const other = input.scope === "global" ? "project" : "global";
3434
4226
  const overlap = claimedByOtherScope(input, plan.edits.map((edit2) => edit2.file));
3435
4227
  if (overlap.length === 0)
3436
- return plan;
4228
+ return { ...plan, notes: [...plan.notes, ...fileOwnershipNotes(input, plan.edits)] };
3437
4229
  return {
3438
4230
  ...plan,
3439
4231
  edits: [],
@@ -3445,6 +4237,30 @@ function withCrossScopeGuard(plan, input) {
3445
4237
  ]
3446
4238
  };
3447
4239
  }
4240
+ function withLegacySharedConfigGuard(plan, previous) {
4241
+ if (plan.errors.length > 0 || previous !== undefined)
4242
+ return plan;
4243
+ for (const edit2 of plan.edits) {
4244
+ if (edit2.format === "rules" || !edit2.shared)
4245
+ continue;
4246
+ for (const op of edit2.ops) {
4247
+ const old = op.previous;
4248
+ if (op.op !== "set" || !isPlainObject(old) || old.command !== "npx" || !Array.isArray(old.args) || old.args[0] !== "-y" || !Array.isArray(old.env_vars) || !old.env_vars.includes(API_KEY_ENV))
4249
+ continue;
4250
+ return {
4251
+ ...plan,
4252
+ edits: [],
4253
+ reverts: undefined,
4254
+ errors: [
4255
+ {
4256
+ message: `${edit2.file} contains an installer-style registration without an install record for this location. ` + "It may belong to an older install in another project. Re-run init or uninstall from that original project and scope first; " + "if the registration was created manually, remove it before retrying. No existing registration was adopted."
4257
+ }
4258
+ ]
4259
+ };
4260
+ }
4261
+ }
4262
+ return plan;
4263
+ }
3448
4264
  function withSecretGuard(plan) {
3449
4265
  for (const edit2 of plan.edits) {
3450
4266
  if (edit2.format === "rules")
@@ -3483,224 +4299,52 @@ function findSecretLike(value, at) {
3483
4299
  }
3484
4300
  return;
3485
4301
  }
3486
- function withOrphanReverts(plan, input, previous) {
4302
+ function withOrphanReverts(plan, previous) {
3487
4303
  if (plan.errors.length > 0)
3488
4304
  return plan;
3489
4305
  if (previous === undefined)
3490
4306
  return plan;
3491
4307
  const reverts = [];
3492
4308
  for (const file of previous.files) {
3493
- const edit2 = plan.edits.find((candidate) => candidate.file === file.file);
3494
- if (edit2 === undefined) {
3495
- reverts.push(file);
3496
- continue;
3497
- }
3498
- if (edit2.format === "rules" || file.format === "rules")
3499
- continue;
3500
- const orphanOps = file.ops.filter((op) => !edit2.ops.some((candidate) => candidate.op === op.op && candidate.path.join("\x00") === op.path.join("\x00")));
3501
- if (orphanOps.length > 0)
3502
- reverts.push({ ...file, ops: orphanOps });
3503
- }
3504
- if (reverts.length === 0)
3505
- return plan;
3506
- return { ...plan, reverts };
3507
- }
3508
- var HOST_PLANNERS;
3509
- var init_hosts = __esm(() => {
3510
- init_codex();
3511
- init_opencode();
3512
- init_pi();
3513
- init_constants();
3514
- init_state();
3515
- HOST_PLANNERS = {
3516
- opencode: planOpenCode,
3517
- pi: planPi,
3518
- codex: planCodex
3519
- };
3520
- });
3521
-
3522
- // src/installer/revert.ts
3523
- import { existsSync as existsSync6, readFileSync as readFileSync6, rmdirSync, unlinkSync } from "node:fs";
3524
- import { dirname as dirname2 } from "node:path";
3525
- function pruneEmptyDir(dir) {
3526
- try {
3527
- rmdirSync(dir);
3528
- } catch {}
3529
- }
3530
- function revertRecordedFile(file, host, opts) {
3531
- const lines = [];
3532
- if (!existsSync6(file.file)) {
3533
- lines.push(`already absent: ${file.file}${opts.label}`);
3534
- return lines;
3535
- }
3536
- const codec = codecFor(file.format, host);
3537
- const text = readFileSync6(file.file, "utf8");
3538
- if (file.format === "rules") {
3539
- const edit2 = { file: file.file, format: "rules", block: file.blockApplied ?? "" };
3540
- if (!codec.verify(text, edit2)) {
3541
- lines.push(`no rules block${opts.label} in ${file.file}; left untouched`);
3542
- return lines;
3543
- }
3544
- const updated = codec.revert(text, file).text;
3545
- if (opts.whole && codec.isEffectivelyEmpty(updated)) {
3546
- unlinkSync(file.file);
3547
- pruneEmptyDir(dirname2(file.file));
3548
- lines.push(`deleted ${file.file} (installer-created, now empty)`);
3549
- } else {
3550
- writeConfig(file.file, updated);
3551
- lines.push(`removed rules block${opts.label} from ${file.file}`);
3552
- }
3553
- return lines;
3554
- }
3555
- const reverted = codec.revert(text, file);
3556
- if (opts.whole && file.created && codec.isEffectivelyEmpty(reverted.text)) {
3557
- unlinkSync(file.file);
3558
- pruneEmptyDir(dirname2(file.file));
3559
- lines.push(`deleted ${file.file} (installer-created, now empty)`);
3560
- } else {
3561
- writeConfig(file.file, reverted.text);
3562
- lines.push(`reverted artifacts${opts.label} in ${file.file}`);
3563
- }
3564
- lines.push(...reverted.notes.map((note) => ` note: ${note}`));
3565
- return lines;
3566
- }
3567
- var init_revert = __esm(() => {
3568
- init_formats();
3569
- });
3570
-
3571
- // src/installer/apply.ts
3572
- import { existsSync as existsSync7, readFileSync as readFileSync7 } from "node:fs";
3573
- function applyPlan(plan, input) {
3574
- if (plan.errors.length > 0) {
3575
- throw new Error(`refusing to apply a plan with errors: ${plan.errors.map((error) => error.message).join("; ")}`);
3576
- }
3577
- assertNoDrift(plan);
3578
- const previous = readRecord(input);
3579
- const prepared = plan.edits.map((edit2) => prepareEdit(edit2, plan.host));
3580
- const files = [];
3581
- const lines = [];
3582
- for (const item of prepared) {
3583
- item.write();
3584
- files.push(mergeRecorded(item.recorded, previous?.files.find((file) => file.file === item.edit.file)));
3585
- lines.push(`${item.recorded.created ? "created" : "updated"} ${item.edit.file}`);
3586
- }
3587
- if ((plan.reverts ?? []).length > 0) {
3588
- lines.push(...applyReverts(plan));
3589
- }
3590
- const record = {
3591
- version: 1,
3592
- host: plan.host,
3593
- scope: plan.scope,
3594
- tiers: plan.tiers,
3595
- createdAt: new Date().toISOString(),
3596
- files
3597
- };
3598
- writeRecord(input, record);
3599
- const verification = verifyEdits(plan.edits, plan.host);
3600
- lines.push(...verification.lines);
3601
- return { lines, ok: verification.ok };
3602
- }
3603
- function assertNoDrift(plan) {
3604
- const drifts = [];
3605
- for (const edit2 of plan.edits) {
3606
- if (edit2.format === "rules")
3607
- continue;
3608
- const loaded = codecFor(edit2.format, plan.host).load(edit2.file);
3609
- if (loaded.parseError) {
3610
- drifts.push(`${edit2.file} no longer parses (${loaded.parseError})`);
3611
- continue;
3612
- }
3613
- for (const op of edit2.ops) {
3614
- if (op.op !== "set")
3615
- continue;
3616
- const current = valueAt(loaded.value, op.path);
3617
- if (!deepEqual(current, op.previous)) {
3618
- drifts.push(`${edit2.file}: ${op.path.join(".")} changed since the plan was rendered`);
3619
- }
3620
- }
3621
- }
3622
- if (drifts.length > 0) {
3623
- throw new Error(`refusing to apply — the config changed since the plan was rendered: ${drifts.join("; ")}. ` + "Re-run init to re-plan against the current files.");
3624
- }
3625
- }
3626
- function prepareEdit(edit2, host) {
3627
- const codec = codecFor(edit2.format, host);
3628
- const loaded = codec.load(edit2.file);
3629
- if (loaded.parseError) {
3630
- throw new Error(`${edit2.file} does not parse: ${loaded.parseError}`);
3631
- }
3632
- const applied = codec.apply(loaded.text, edit2);
3633
- const recorded = edit2.format === "rules" ? { file: edit2.file, format: "rules", created: !loaded.exists, ops: [], blockApplied: edit2.block } : { file: edit2.file, format: edit2.format, created: !loaded.exists, ops: applied.recorded, createdParents: applied.createdParents };
3634
- return {
3635
- edit: edit2,
3636
- recorded,
3637
- write: () => writeConfig(edit2.file, applied.text)
3638
- };
3639
- }
3640
- function applyReverts(plan) {
3641
- const lines = [];
3642
- for (const revert of plan.reverts ?? []) {
3643
- const whole = !plan.edits.some((edit2) => edit2.file === revert.file);
3644
- lines.push(...revertRecordedFile(revert, plan.host, { label: " (previous init)", whole }));
3645
- }
3646
- return lines;
3647
- }
3648
- function mergeRecorded(current, before) {
3649
- if (before === undefined)
3650
- return current;
3651
- const ops = current.ops.map((op) => {
3652
- const old = before.ops.find((candidate) => candidate.op === op.op && candidate.path.join("\x00") === op.path.join("\x00"));
3653
- if (old === undefined)
3654
- return op;
3655
- if (op.op === "set" && old.op === "set" && deepEqual(op.previous, old.applied)) {
3656
- return { ...op, previous: old.previous };
3657
- }
3658
- if (op.op === "append-array" && old.op === "append-array") {
3659
- return { ...op, applied: [...old.applied, ...op.applied] };
3660
- }
3661
- return op;
3662
- });
3663
- return {
3664
- ...current,
3665
- created: before.created,
3666
- ops,
3667
- createdParents: [...before.createdParents ?? [], ...current.createdParents ?? []]
3668
- };
3669
- }
3670
- function verifyEdits(edits, host) {
3671
- const lines = [];
3672
- let ok = true;
3673
- for (const edit2 of edits) {
3674
- if (!existsSync7(edit2.file)) {
3675
- lines.push(`FAIL ${edit2.file} does not exist after apply`);
3676
- ok = false;
3677
- continue;
3678
- }
3679
- const codec = codecFor(edit2.format, host);
3680
- const text = readFileSync7(edit2.file, "utf8");
3681
- if (edit2.format === "rules") {
3682
- const good = codec.verify(text, edit2);
3683
- lines.push(`${good ? "pass" : "FAIL"} ${edit2.file}: rules block ${good ? "present" : "missing"}`);
3684
- ok = ok && good;
3685
- continue;
3686
- }
3687
- for (const op of edit2.ops) {
3688
- const good = codec.verify(text, edit2, op);
3689
- lines.push(`${good ? "pass" : "FAIL"} ${edit2.file}: ${describeOp(op)} ${good ? "holds" : "missing"}`);
3690
- ok = ok && good;
4309
+ const edit2 = plan.edits.find((candidate) => candidate.file === file.file);
4310
+ if (edit2 === undefined) {
4311
+ reverts.push(file);
4312
+ continue;
3691
4313
  }
4314
+ if (edit2.format === "rules" || file.format === "rules")
4315
+ continue;
4316
+ const orphanOps = file.ops.flatMap((op) => {
4317
+ const claimed = edit2.ops.find((candidate) => candidate.op === op.op && candidate.path.join("\x00") === op.path.join("\x00"));
4318
+ if (op.op !== "append-array" || claimed === undefined || claimed.op !== "append-array") {
4319
+ return claimed === undefined ? [op] : [];
4320
+ }
4321
+ const dropped = (Array.isArray(op.applied) ? op.applied : []).filter((entry) => !(claimed.entries ?? []).some((wanted) => deepEqual(wanted, entry)));
4322
+ return dropped.length > 0 ? [{ ...op, applied: dropped }] : [];
4323
+ });
4324
+ if (orphanOps.length > 0)
4325
+ reverts.push({ ...file, ops: orphanOps });
3692
4326
  }
3693
- return { lines, ok };
3694
- }
3695
- function describeOp(op) {
3696
- if (op.op === "set")
3697
- return `set ${op.path.join(".")}`;
3698
- return `append ${op.path.join(".")}`;
4327
+ if (reverts.length === 0)
4328
+ return plan;
4329
+ return { ...plan, reverts };
3699
4330
  }
3700
- var init_apply = __esm(() => {
3701
- init_formats();
3702
- init_revert();
4331
+ var HOST_PLANNERS, HOST_VERSION_PROBES;
4332
+ var init_hosts = __esm(() => {
4333
+ init_constants();
4334
+ init_value();
4335
+ init_ownership();
3703
4336
  init_state();
4337
+ init_codex();
4338
+ init_opencode();
4339
+ init_pi();
4340
+ HOST_PLANNERS = {
4341
+ opencode: planOpenCode,
4342
+ pi: planPi,
4343
+ codex: planCodex
4344
+ };
4345
+ HOST_VERSION_PROBES = {
4346
+ codex: probeCodexVersion
4347
+ };
3704
4348
  });
3705
4349
 
3706
4350
  // src/installer/render.ts
@@ -3764,25 +4408,43 @@ function preview(value) {
3764
4408
 
3765
4409
  // src/installer/uninstall.ts
3766
4410
  function uninstall(input) {
3767
- const record = readRecord(input);
3768
- if (record === undefined) {
3769
- throw new Error(`No ${input.host} install is recorded for the ${input.scope} scope.`);
3770
- }
4411
+ return withOperationLock([statePathFor(input)], () => {
4412
+ const record = readRecord(input);
4413
+ if (record === undefined) {
4414
+ throw new Error(`No ${input.host} install is recorded for the ${input.scope} scope.`);
4415
+ }
4416
+ return withOperationLock(record.files.map((file) => file.file), () => uninstallLocked(input, record));
4417
+ });
4418
+ }
4419
+ function uninstallLocked(input, record) {
4420
+ assertFileOwnership(input, record.files);
3771
4421
  const other = input.scope === "global" ? "project" : "global";
3772
4422
  const overlap = claimedByOtherScope(input, record.files.map((file) => file.file));
3773
4423
  if (overlap.length > 0) {
3774
4424
  throw new Error(`A ${input.host} install is also recorded at the ${other} scope and claims the same file(s): ${overlap.join(", ")}. ` + `Uninstall the ${other}-scope install first so this rollback cannot clobber its entries.`);
3775
4425
  }
3776
4426
  const lines = [];
4427
+ for (const file of record.files) {
4428
+ if (file.format !== "rules")
4429
+ continue;
4430
+ const loaded = codecFor("rules", record.host).load(file.file);
4431
+ if (loaded.parseError)
4432
+ throw new Error(`${file.file}: ${loaded.parseError}`);
4433
+ }
3777
4434
  for (const file of [...record.files].reverse()) {
3778
4435
  lines.push(...revertRecordedFile(file, record.host, { label: "", whole: true }));
3779
4436
  }
3780
4437
  deleteRecord(input);
4438
+ releaseFileOwnership(input, record.files);
3781
4439
  pruneEmptyDir(stateDir(input, input.scope));
3782
4440
  lines.push(`removed install record (${input.host}, ${input.scope})`);
3783
4441
  return lines;
3784
4442
  }
3785
4443
  var init_uninstall = __esm(() => {
4444
+ init_formats();
4445
+ init_operation_lock();
4446
+ init_ownership();
4447
+ init_paths();
3786
4448
  init_revert();
3787
4449
  init_state();
3788
4450
  });
@@ -3793,7 +4455,7 @@ __export(exports_cli, {
3793
4455
  runInstallerCli: () => runInstallerCli
3794
4456
  });
3795
4457
  import { homedir } from "node:os";
3796
- import { resolve } from "node:path";
4458
+ import { resolve as resolve2 } from "node:path";
3797
4459
  function parseFlags(args) {
3798
4460
  const values = new Map;
3799
4461
  const booleans = new Set;
@@ -3807,6 +4469,10 @@ function parseFlags(args) {
3807
4469
  const eq = arg.indexOf("=");
3808
4470
  const name = eq === -1 ? arg.slice(2) : arg.slice(2, eq);
3809
4471
  if (BOOLEAN_FLAGS.has(name)) {
4472
+ if (eq !== -1) {
4473
+ problems.push(`--${name} does not accept a value; use --${name} to enable it or omit it`);
4474
+ continue;
4475
+ }
3810
4476
  booleans.add(name);
3811
4477
  continue;
3812
4478
  }
@@ -3844,11 +4510,17 @@ async function runInstallerCli(argv, io = {}) {
3844
4510
  ${USAGE}`);
3845
4511
  return 1;
3846
4512
  }
3847
- const cwd = resolve(flags.values.get("cwd") ?? process.cwd());
4513
+ const cwd = resolve2(flags.values.get("cwd") ?? process.cwd());
3848
4514
  const home = process.env.HOME ?? homedir();
3849
4515
  const configHome2 = resolveConfigHome(home);
3850
4516
  if (command === "list") {
3851
- const records = listRecords({ home, cwd, configHome: configHome2 });
4517
+ let records;
4518
+ try {
4519
+ records = listRecords({ home, cwd, configHome: configHome2 });
4520
+ } catch (error) {
4521
+ err(error instanceof Error ? error.message : String(error));
4522
+ return 1;
4523
+ }
3852
4524
  if (records.length === 0) {
3853
4525
  out("No installs recorded.");
3854
4526
  return 0;
@@ -3883,7 +4555,9 @@ ${USAGE}`);
3883
4555
  if (!required.ok)
3884
4556
  return usageError(required.problems, err);
3885
4557
  const { input } = required;
3886
- const plan = planForHost(input.host, input);
4558
+ const probe = HOST_VERSION_PROBES[input.host];
4559
+ const hostVersion = probe?.(err);
4560
+ const plan = planForHost(input.host, hostVersion === undefined ? input : { ...input, hostVersion });
3887
4561
  if (plan.errors.length > 0) {
3888
4562
  for (const error of plan.errors)
3889
4563
  err(`error: ${error.message}`);
@@ -3925,6 +4599,7 @@ function requireInput(flags, cwd, home, configHome2, needsTiers) {
3925
4599
  const scope = flags.values.get("scope");
3926
4600
  const search = needsTiers ? flags.values.get("search") ?? "" : "optional";
3927
4601
  const fetch = needsTiers ? flags.values.get("fetch") ?? "" : "optional";
4602
+ const path = flags.values.get("path");
3928
4603
  if (host === undefined)
3929
4604
  problems.push(`--host is required (one of: ${HOSTS.join(", ")})`);
3930
4605
  else if (!HOSTS.includes(host))
@@ -3933,6 +4608,13 @@ function requireInput(flags, cwd, home, configHome2, needsTiers) {
3933
4608
  problems.push(`--scope is required (one of: ${SCOPES.join(", ")})`);
3934
4609
  else if (!SCOPES.includes(scope))
3935
4610
  problems.push(`invalid --scope value "${scope}" (one of: ${SCOPES.join(", ")})`);
4611
+ if (path !== undefined) {
4612
+ if (host !== "pi") {
4613
+ problems.push(`--path is only supported for host pi (${host ?? "no host"} has a single integration path)`);
4614
+ } else if (!PATH_CHOICES.includes(path)) {
4615
+ problems.push(`invalid --path value "${path}" (one of: ${PATH_CHOICES.join(", ")})`);
4616
+ }
4617
+ }
3936
4618
  if (needsTiers) {
3937
4619
  if (!TIERS.includes(search)) {
3938
4620
  problems.push(flags.values.has("search") ? `invalid --search value "${search}" (one of: ${TIERS.join(", ")})` : `--search is required (one of: ${TIERS.join(", ")})`);
@@ -3955,14 +4637,15 @@ function requireInput(flags, cwd, home, configHome2, needsTiers) {
3955
4637
  packageRef: flags.values.get("package") ?? "deepseek-web-retrieval",
3956
4638
  home,
3957
4639
  cwd,
3958
- configHome: configHome2
4640
+ configHome: configHome2,
4641
+ ...path !== undefined ? { path } : {}
3959
4642
  }
3960
4643
  };
3961
4644
  }
3962
- var HOSTS, HOST_CHOICES, SCOPES, TIERS, USAGE, VALUE_FLAGS, BOOLEAN_FLAGS;
4645
+ var HOSTS, HOST_CHOICES, SCOPES, TIERS, USAGE, VALUE_FLAGS, BOOLEAN_FLAGS, PATH_CHOICES;
3963
4646
  var init_cli = __esm(() => {
3964
- init_hosts();
3965
4647
  init_apply();
4648
+ init_hosts();
3966
4649
  init_state();
3967
4650
  init_uninstall();
3968
4651
  HOSTS = Object.keys(HOST_PLANNERS);
@@ -3970,14 +4653,20 @@ var init_cli = __esm(() => {
3970
4653
  SCOPES = ["project", "global"];
3971
4654
  TIERS = ["optional", "preferred", "forced", "off"];
3972
4655
  USAGE = `Usage:
3973
- deepseek-web-retrieval init --host ${HOST_CHOICES} --scope <project|global> --search <optional|preferred|forced|off> --fetch <optional|preferred|forced|off> [--package <npm ref>] [--cwd <dir>] [--apply]
4656
+ deepseek-web-retrieval init --host ${HOST_CHOICES} --scope <project|global> --search <optional|preferred|forced|off> --fetch <optional|preferred|forced|off> [--path <extension|mcp>] [--package <npm ref>] [--cwd <dir>] [--apply]
3974
4657
  deepseek-web-retrieval uninstall --host ${HOST_CHOICES} --scope <project|global> [--cwd <dir>]
3975
4658
  deepseek-web-retrieval list [--cwd <dir>]
3976
4659
 
3977
4660
  init prints the full plan (every file, old value → new value) and writes nothing
3978
- until --apply is passed. uninstall restores what the plan applied.`;
3979
- VALUE_FLAGS = new Set(["host", "scope", "search", "fetch", "package", "cwd"]);
4661
+ until --apply is passed. uninstall restores what the plan applied. --path picks
4662
+ the integration mechanism for hosts with more than one (Pi: extension by
4663
+ default, mcp for the stdio MCP server). --package overrides the package ref
4664
+ written into host config; the Pi extension path turns it into the packages
4665
+ source (a bare name is prefixed with npm:, a ref that already starts with npm:
4666
+ is written as-is).`;
4667
+ VALUE_FLAGS = new Set(["host", "scope", "search", "fetch", "path", "package", "cwd"]);
3980
4668
  BOOLEAN_FLAGS = new Set(["apply"]);
4669
+ PATH_CHOICES = ["extension", "mcp"];
3981
4670
  });
3982
4671
 
3983
4672
  // src/mcp/cli.ts
@@ -3990,17 +4679,7 @@ function apiKeyFromEnv(env = process.env) {
3990
4679
  return key ? key : undefined;
3991
4680
  }
3992
4681
 
3993
- // src/mcp/auth.ts
3994
- init_constants();
3995
- var MISSING_API_KEY_MESSAGE = `DeepSeek API key not found. Set ${API_KEY_ENV} in the MCP server's env configuration.`;
3996
- function requireApiKey(env) {
3997
- const apiKey = apiKeyFromEnv(env);
3998
- if (!apiKey)
3999
- throw new Error(MISSING_API_KEY_MESSAGE);
4000
- return apiKey;
4001
- }
4002
-
4003
- // src/mcp/config.ts
4682
+ // src/core/env.ts
4004
4683
  init_constants();
4005
4684
 
4006
4685
  // src/core/options.ts
@@ -4128,7 +4807,7 @@ function resolveSearchOptions(input) {
4128
4807
  maxResults: clampInt(input?.maxResults, DEFAULT_MAX_RESULTS, 0, 50),
4129
4808
  minRerank: finiteNumber(input?.minRerank, DEFAULT_MIN_RERANK),
4130
4809
  minAuthority: finiteNumber(input?.minAuthority, DEFAULT_MIN_AUTHORITY),
4131
- allowUnknownAuthority: input?.allowUnknownAuthority === true,
4810
+ allowUnknownAuthority: input?.allowUnknownAuthority !== false,
4132
4811
  blockedDomains: normalizeDomains(input?.blockedDomains),
4133
4812
  includeDomains: normalizeDomains(input?.includeDomains),
4134
4813
  language: optionalText(input?.language),
@@ -4173,7 +4852,7 @@ function clampInt(value, fallback, min, max) {
4173
4852
  return Math.min(max, Math.max(min, number));
4174
4853
  }
4175
4854
 
4176
- // src/mcp/config.ts
4855
+ // src/core/env.ts
4177
4856
  var NUMBER_ENV = {
4178
4857
  count: "DEEPSEEK_SEARCH_COUNT",
4179
4858
  maxResults: "DEEPSEEK_SEARCH_MAX_RESULTS",
@@ -4195,10 +4874,8 @@ var DOMAINS_ENV = {
4195
4874
  fetchAllowedDomains: "DEEPSEEK_FETCH_ALLOWED_DOMAINS",
4196
4875
  fetchBlockedDomains: "DEEPSEEK_FETCH_BLOCKED_DOMAINS"
4197
4876
  };
4198
- var FLAG_ENV = {
4199
- allowUnknownAuthority: "DEEPSEEK_SEARCH_ALLOW_UNKNOWN_AUTHORITY"
4200
- };
4201
4877
  var FALSE_ONLY_ENV = {
4878
+ allowUnknownAuthority: "DEEPSEEK_SEARCH_ALLOW_UNKNOWN_AUTHORITY",
4202
4879
  fetchBlockPrivateHosts: "DEEPSEEK_FETCH_BLOCK_PRIVATE_HOSTS",
4203
4880
  searchEnabled: ENABLE_SEARCH_ENV,
4204
4881
  fetchEnabled: ENABLE_FETCH_ENV
@@ -4212,7 +4889,7 @@ function pluginOptionsFromEnv(env) {
4212
4889
  putNumber(options, "maxResults", env);
4213
4890
  putNumber(options, "minRerank", env);
4214
4891
  putNumber(options, "minAuthority", env);
4215
- putBoolean(options, "allowUnknownAuthority", env);
4892
+ putFalseOnly(options, "allowUnknownAuthority", env);
4216
4893
  putDomains(options, "blockedDomains", env);
4217
4894
  putDomains(options, "includeDomains", env);
4218
4895
  putText(options, "language", env);
@@ -4248,10 +4925,6 @@ function putText(target, key, env) {
4248
4925
  if (trimmed)
4249
4926
  target[key] = trimmed;
4250
4927
  }
4251
- function putBoolean(target, key, env) {
4252
- if (isTrueValue(env[FLAG_ENV[key]]))
4253
- target[key] = true;
4254
- }
4255
4928
  function putFalseOnly(target, key, env) {
4256
4929
  if (isFalseValue(env[FALSE_ONLY_ENV[key]]))
4257
4930
  target[key] = false;
@@ -4264,18 +4937,145 @@ function putDomains(target, key, env) {
4264
4937
  if (domains.length > 0)
4265
4938
  target[key] = domains;
4266
4939
  }
4267
- function isTrueValue(value) {
4268
- const trimmed = value?.trim().toLowerCase();
4269
- return trimmed === "true" || trimmed === "1" || trimmed === "yes" || trimmed === "on";
4270
- }
4271
4940
  function isFalseValue(value) {
4272
4941
  const trimmed = value?.trim().toLowerCase();
4273
4942
  return trimmed === "false" || trimmed === "0" || trimmed === "no" || trimmed === "off";
4274
4943
  }
4275
4944
 
4945
+ // src/mcp/auth.ts
4946
+ init_constants();
4947
+ var MISSING_API_KEY_MESSAGE = `DeepSeek API key not found. Set ${API_KEY_ENV} in the MCP server's env configuration.`;
4948
+ function requireApiKey(env) {
4949
+ const apiKey = apiKeyFromEnv(env);
4950
+ if (!apiKey)
4951
+ throw new Error(MISSING_API_KEY_MESSAGE);
4952
+ return apiKey;
4953
+ }
4954
+
4955
+ // src/mcp/client.ts
4956
+ import { CLIENT_INFO_META_KEY } from "@modelcontextprotocol/server";
4957
+ var JSON_REPLACING_CLIENT_NAMES = new Set(["codex-mcp-client"]);
4958
+ function clientNameOf(server, ctx) {
4959
+ const envelope = ctx.mcpReq.envelope;
4960
+ const name = envelope?.[CLIENT_INFO_META_KEY];
4961
+ if (typeof name === "object" && name !== null && "name" in name && typeof name.name === "string") {
4962
+ return name.name;
4963
+ }
4964
+ return server.server.getClientVersion()?.name;
4965
+ }
4966
+ function isJsonReplacingClient(name) {
4967
+ return name !== undefined && JSON_REPLACING_CLIENT_NAMES.has(name);
4968
+ }
4969
+ function omitStructuredContent(server, ctx) {
4970
+ return isJsonReplacingClient(clientNameOf(server, ctx));
4971
+ }
4972
+
4276
4973
  // src/mcp/server.ts
4277
4974
  import { McpServer } from "@modelcontextprotocol/server";
4278
4975
  import * as z from "zod/v4";
4976
+ // package.json
4977
+ var package_default = {
4978
+ $schema: "https://json.schemastore.org/package.json",
4979
+ name: "deepseek-web-retrieval",
4980
+ version: "0.5.0",
4981
+ description: "Token-efficient DeepSeek Search and Fetch for coding agents, starting with OpenCode v2.",
4982
+ type: "module",
4983
+ license: "MIT",
4984
+ repository: {
4985
+ type: "git",
4986
+ url: "git+https://github.com/gwokhou/deepseek-web-retrieval.git"
4987
+ },
4988
+ homepage: "https://github.com/gwokhou/deepseek-web-retrieval#readme",
4989
+ bugs: "https://github.com/gwokhou/deepseek-web-retrieval/issues",
4990
+ keywords: [
4991
+ "opencode",
4992
+ "opencode-plugin",
4993
+ "pi",
4994
+ "pi-extension",
4995
+ "deepseek",
4996
+ "websearch",
4997
+ "webfetch",
4998
+ "coding-agent",
4999
+ "mcp"
5000
+ ],
5001
+ exports: {
5002
+ ".": "./index.ts",
5003
+ "./core": "./src/core/index.ts",
5004
+ "./mcp": "./src/mcp/server.ts",
5005
+ "./pi": "./src/pi/extension.ts"
5006
+ },
5007
+ pi: {
5008
+ extensions: [
5009
+ "./src/pi/extension.ts"
5010
+ ]
5011
+ },
5012
+ bin: {
5013
+ "deepseek-web-retrieval": "dist/cli.js"
5014
+ },
5015
+ files: [
5016
+ "index.ts",
5017
+ "src",
5018
+ "dist",
5019
+ "docs",
5020
+ "README.md",
5021
+ "LICENSE",
5022
+ "CHANGELOG.md",
5023
+ "examples"
5024
+ ],
5025
+ scripts: {
5026
+ lint: "biome check --error-on-warnings .",
5027
+ "lint:ci": "biome ci --error-on-warnings .",
5028
+ "lint:fix": "biome check --write --error-on-warnings .",
5029
+ format: "biome format --write .",
5030
+ typecheck: "tsc --noEmit",
5031
+ build: "bun build src/mcp/cli.ts --target=node --format=esm --outdir=dist --external @modelcontextprotocol/server --external @modelcontextprotocol/server/stdio --external zod --external zod/v4",
5032
+ prepublishOnly: "bun run check:release",
5033
+ test: "bun test",
5034
+ "test:package": "node scripts/test-package.mjs",
5035
+ "test:e2e:keyless": "bun run test:e2e pi pi-extension installer reinit upgrade pkg capability-off",
5036
+ "check:release": "bun run check && bun run test:package && bun run test:e2e:keyless",
5037
+ "test:e2e": "bun run test/e2e/run.ts",
5038
+ "docs:matrix": "bun run scripts/render-matrix.ts",
5039
+ "test:coverage": "bun test --coverage",
5040
+ check: "bun run lint && bun run typecheck && bun run docs:matrix --check && bun run build && bun test"
5041
+ },
5042
+ peerDependencies: {
5043
+ "@earendil-works/pi-ai": "^1.3.0",
5044
+ "@earendil-works/pi-coding-agent": "^1.3.0",
5045
+ "@opencode/plugin": "^2.0.32"
5046
+ },
5047
+ peerDependenciesMeta: {
5048
+ "@earendil-works/pi-ai": {
5049
+ optional: true
5050
+ },
5051
+ "@earendil-works/pi-coding-agent": {
5052
+ optional: true
5053
+ },
5054
+ "@opencode/plugin": {
5055
+ optional: true
5056
+ }
5057
+ },
5058
+ devDependencies: {
5059
+ "@biomejs/biome": "2.5.15",
5060
+ "@earendil-works/pi-ai": "1.3.0",
5061
+ "@earendil-works/pi-coding-agent": "1.3.0",
5062
+ "@modelcontextprotocol/client": "^2.3.1",
5063
+ "@opencode/plugin": "2.0.32",
5064
+ "@types/bun": "1.4.0",
5065
+ "@types/node": "^26.6.5",
5066
+ typescript: "5.9.3"
5067
+ },
5068
+ engines: {
5069
+ bun: ">=1.3.0",
5070
+ node: ">=20"
5071
+ },
5072
+ dependencies: {
5073
+ "@modelcontextprotocol/server": "^2.3.1",
5074
+ "jsonc-parser": "^3.3.1",
5075
+ "smol-toml": "^1.9.1",
5076
+ zod: "^4"
5077
+ }
5078
+ };
4279
5079
 
4280
5080
  // src/core/client.ts
4281
5081
  init_constants();
@@ -4357,6 +5157,14 @@ function formatSearchResults(results) {
4357
5157
 
4358
5158
  `);
4359
5159
  }
5160
+ function toStructuredResult(result) {
5161
+ return {
5162
+ url: result.url,
5163
+ ...result.title ? { title: result.title } : {},
5164
+ ...result.content ? { content: result.content } : {},
5165
+ ...result.published !== undefined ? { published: result.published } : {}
5166
+ };
5167
+ }
4360
5168
  function escapeBrackets(value) {
4361
5169
  return value.replaceAll("[", "\\[").replaceAll("]", "\\]");
4362
5170
  }
@@ -4439,8 +5247,12 @@ function processResults(raw, options) {
4439
5247
  if (!url || seen.has(url))
4440
5248
  continue;
4441
5249
  const rerank = item.rerank_score;
4442
- if (typeof rerank !== "number" || !Number.isFinite(rerank) || rerank < options.minRerank)
5250
+ if (typeof rerank === "number" && Number.isFinite(rerank)) {
5251
+ if (rerank < options.minRerank)
5252
+ continue;
5253
+ } else if (options.minRerank > 0) {
4443
5254
  continue;
5255
+ }
4444
5256
  const authority = item.authority;
4445
5257
  const knownAuthority = typeof authority === "number" && Number.isFinite(authority) && authority >= 0;
4446
5258
  if (knownAuthority) {
@@ -4682,22 +5494,26 @@ async function searchWeb(query, options, request) {
4682
5494
  query: builtQuery,
4683
5495
  count: options.count,
4684
5496
  need_content: false
4685
- }, "DeepSeek Search", { signal: request.signal, auth: request.auth, retry: request.retry, timeoutMs: timeout });
5497
+ }, "DeepSeek Search", { ...request, timeoutMs: timeout });
4686
5498
  if (!Array.isArray(payload.results)) {
4687
5499
  throw new Error("DeepSeek Search returned a response without a results array");
4688
5500
  }
4689
5501
  return processResults(payload.results, options);
4690
5502
  }
4691
5503
  async function fetchPage(url, options, request) {
4692
- assertFetchHostPolicy(url, options);
5504
+ const target = parseFetchUrl(url.href);
5505
+ assertFetchHostPolicy(target, options);
4693
5506
  const timeout = effectiveTimeout(options.timeoutMs, request.timeoutMs);
4694
- const payload = await postJson(FETCH_ENDPOINT, { url: url.href }, "DeepSeek Fetch", { signal: request.signal, auth: request.auth, retry: request.retry, timeoutMs: timeout });
5507
+ const payload = await postJson(FETCH_ENDPOINT, { url: target.href }, "DeepSeek Fetch", {
5508
+ ...request,
5509
+ timeoutMs: timeout
5510
+ });
4695
5511
  const title = typeof payload.title === "string" ? payload.title.trim() : "";
4696
5512
  const content = typeof payload.content === "string" ? payload.content : "";
4697
5513
  if (!content.trim()) {
4698
5514
  throw new Error("DeepSeek Fetch returned no page content (the page may be empty or unavailable)");
4699
5515
  }
4700
- return { url: url.href, title, content };
5516
+ return { url: target.href, title, content };
4701
5517
  }
4702
5518
  async function fetchDocument(url, options, request) {
4703
5519
  if (typeof url !== "string" && !(url instanceof URL))
@@ -4711,6 +5527,15 @@ async function fetchDocument(url, options, request) {
4711
5527
  truncated: formatted.truncated
4712
5528
  };
4713
5529
  }
5530
+ function createRetrievalClient(dependencies = {}) {
5531
+ const bind = (request) => ({ ...request, ...dependencies });
5532
+ return {
5533
+ searchWeb: (query, options, request) => searchWeb(query, options, bind(request)),
5534
+ fetchPage: (url, options, request) => fetchPage(url, options, bind(request)),
5535
+ fetchDocument: (url, options, request) => fetchDocument(url, options, bind(request))
5536
+ };
5537
+ }
5538
+ var retrievalClient = createRetrievalClient();
4714
5539
 
4715
5540
  // src/mcp/server.ts
4716
5541
  init_constants();
@@ -4721,120 +5546,34 @@ var FETCH_OUTPUT_TRUNCATED = "Output is truncated to the configured size.";
4721
5546
  var SEARCH_RANKED_FILTERED = "Results are ranked by relevance and source authority, deduplicated by URL, and capped at the configured maximum.";
4722
5547
  var SEARCH_EMPTY_IS_NORMAL = "An empty result list is normal, not an error.";
4723
5548
  var SEARCH_QUERY_OPERATORS = "Use site:/-site: operators in the query to include or exclude domains; the full query must stay within 128 characters.";
4724
- // package.json
4725
- var package_default = {
4726
- $schema: "https://json.schemastore.org/package.json",
4727
- name: "deepseek-web-retrieval",
4728
- version: "0.3.0",
4729
- description: "Token-efficient DeepSeek Search and Fetch for coding agents, starting with OpenCode v2.",
4730
- type: "module",
4731
- license: "MIT",
4732
- repository: {
4733
- type: "git",
4734
- url: "git+https://github.com/gwokhou/deepseek-web-retrieval.git"
4735
- },
4736
- homepage: "https://github.com/gwokhou/deepseek-web-retrieval#readme",
4737
- bugs: "https://github.com/gwokhou/deepseek-web-retrieval/issues",
4738
- keywords: [
4739
- "opencode",
4740
- "opencode-plugin",
4741
- "deepseek",
4742
- "websearch",
4743
- "webfetch",
4744
- "coding-agent",
4745
- "mcp"
4746
- ],
4747
- exports: {
4748
- ".": "./index.ts",
4749
- "./core": "./src/core/index.ts",
4750
- "./mcp": "./src/mcp/server.ts"
4751
- },
4752
- bin: {
4753
- "deepseek-web-retrieval": "dist/cli.js"
4754
- },
4755
- files: [
4756
- "index.ts",
4757
- "src",
4758
- "dist",
4759
- "docs",
4760
- "README.md",
4761
- "LICENSE",
4762
- "CHANGELOG.md",
4763
- "examples"
4764
- ],
4765
- scripts: {
4766
- typecheck: "tsc --noEmit",
4767
- build: "bun build src/mcp/cli.ts --target=node --format=esm --outdir=dist --external @modelcontextprotocol/server --external @modelcontextprotocol/server/stdio --external zod --external zod/v4",
4768
- prepublishOnly: "bun run typecheck && bun run build",
4769
- test: "bun test",
4770
- "test:e2e": "bun run test/e2e/run.ts",
4771
- "docs:matrix": "bun run scripts/render-matrix.ts",
4772
- "test:coverage": "bun test --coverage",
4773
- check: "bun run typecheck && bun run docs:matrix --check && bun run build && bun test"
4774
- },
4775
- peerDependencies: {
4776
- "@opencode/plugin": "^2.0.32"
4777
- },
4778
- devDependencies: {
4779
- "@modelcontextprotocol/client": "^2.3.1",
4780
- "@opencode/plugin": "2.0.32",
4781
- "@types/bun": "1.4.0",
4782
- "@types/node": "^26.6.5",
4783
- typescript: "5.9.3"
4784
- },
4785
- engines: {
4786
- bun: ">=1.3.0",
4787
- node: ">=20"
4788
- },
4789
- dependencies: {
4790
- "@modelcontextprotocol/server": "^2.3.1",
4791
- "jsonc-parser": "^3.3.1",
4792
- "smol-toml": "^1.9.1",
4793
- zod: "^4"
4794
- }
4795
- };
5549
+ var SEARCH_TOOL_DESCRIPTION = "Search the web with DeepSeek's in-house search engine and return a short, filtered result " + "list (title, URL, summary). " + `${SEARCH_RANKED_FILTERED} ` + `${SEARCH_EMPTY_IS_NORMAL} ` + `${SEARCH_QUERY_OPERATORS} ` + "To read a full page, pass a result URL to the deepseek_fetch tool.";
5550
+ var FETCH_TOOL_DESCRIPTION = "Fetch one web page as clean Markdown through DeepSeek's Fetch API. " + `${FETCH_SERVICE_LIMITS} ` + `${FETCH_OUTPUT_TRUNCATED}`;
5551
+ var SEARCH_QUERY_PARAMETER = "Search query, 128 characters or fewer including operators (site:, -site:, quoted phrases, lang:).";
5552
+ var FETCH_URL_PARAMETER = "Absolute http:// or https:// URL to fetch. Credentials are rejected.";
4796
5553
 
4797
5554
  // src/mcp/server.ts
4798
5555
  var MCP_SERVER_NAME = "deepseek-web-retrieval";
4799
5556
  var SEARCH_INPUT = z.object({
4800
- query: z.string().min(1).describe("Search query, 128 characters or fewer including operators (site:, -site:, quoted phrases, lang:).")
4801
- });
4802
- var SEARCH_OUTPUT = z.object({
4803
- results: z.array(z.object({
4804
- url: z.string(),
4805
- title: z.string().optional(),
4806
- content: z.string().optional(),
4807
- published: z.number().optional()
4808
- }))
5557
+ query: z.string().min(1).describe(SEARCH_QUERY_PARAMETER)
4809
5558
  });
4810
5559
  var FETCH_INPUT = z.object({
4811
- url: z.string().min(1).describe("Absolute http:// or https:// URL to fetch. Credentials are rejected.")
4812
- });
4813
- var FETCH_OUTPUT = z.object({
4814
- url: z.string(),
4815
- title: z.string(),
4816
- content: z.string(),
4817
- truncated: z.boolean()
5560
+ url: z.string().min(1).describe(FETCH_URL_PARAMETER)
4818
5561
  });
4819
- var SEARCH_DESCRIPTION = "Search the web with DeepSeek's in-house search engine and return a short, filtered result " + "list (title, URL, summary). " + `${SEARCH_RANKED_FILTERED} ` + `${SEARCH_EMPTY_IS_NORMAL} ` + `${SEARCH_QUERY_OPERATORS} ` + "To read a full page, pass a result URL to the deepseek_fetch tool.";
4820
- var FETCH_DESCRIPTION = "Fetch one web page as clean Markdown through DeepSeek's Fetch API. " + `${FETCH_SERVICE_LIMITS} ` + `${FETCH_OUTPUT_TRUNCATED}`;
4821
- function createDeepSeekServer(config, env = process.env) {
5562
+ function createDeepSeekServer(config, env = process.env, client = retrievalClient) {
4822
5563
  const server = new McpServer({ name: MCP_SERVER_NAME, version: package_default.version });
4823
5564
  if (config.search.enabled) {
4824
5565
  server.registerTool("deepseek_web_search", {
4825
5566
  title: "DeepSeek Web Search",
4826
- description: SEARCH_DESCRIPTION,
5567
+ description: SEARCH_TOOL_DESCRIPTION,
4827
5568
  inputSchema: SEARCH_INPUT,
4828
- outputSchema: SEARCH_OUTPUT,
4829
5569
  annotations: { readOnlyHint: true }
4830
5570
  }, async ({ query }, ctx) => {
4831
5571
  try {
4832
5572
  const request = requestContext(config, env, ctx.mcpReq.signal);
4833
- const results = await searchWeb(query, config.search, request);
4834
- return {
4835
- content: [{ type: "text", text: formatSearchResults(results) }],
4836
- structuredContent: { results: results.map(toStructuredResult) }
4837
- };
5573
+ const results = await client.searchWeb(query, config.search, request);
5574
+ return callResult(server, ctx, formatSearchResults(results), {
5575
+ results: results.map(toStructuredResult)
5576
+ });
4838
5577
  } catch (error) {
4839
5578
  return toErrorResult(error);
4840
5579
  }
@@ -4843,23 +5582,19 @@ function createDeepSeekServer(config, env = process.env) {
4843
5582
  if (config.fetch.enabled) {
4844
5583
  server.registerTool("deepseek_fetch", {
4845
5584
  title: "DeepSeek Fetch",
4846
- description: FETCH_DESCRIPTION,
5585
+ description: FETCH_TOOL_DESCRIPTION,
4847
5586
  inputSchema: FETCH_INPUT,
4848
- outputSchema: FETCH_OUTPUT,
4849
5587
  annotations: { readOnlyHint: true }
4850
5588
  }, async ({ url }, ctx) => {
4851
5589
  try {
4852
5590
  const request = requestContext(config, env, ctx.mcpReq.signal);
4853
- const page = await fetchDocument(url, config.fetch, request);
4854
- return {
4855
- content: [{ type: "text", text: page.content }],
4856
- structuredContent: {
4857
- url: page.url,
4858
- title: page.title,
4859
- content: page.content,
4860
- truncated: page.truncated
4861
- }
4862
- };
5591
+ const page = await client.fetchDocument(url, config.fetch, request);
5592
+ return callResult(server, ctx, page.content, {
5593
+ url: page.url,
5594
+ title: page.title,
5595
+ content: page.content,
5596
+ truncated: page.truncated
5597
+ });
4863
5598
  } catch (error) {
4864
5599
  return toErrorResult(error);
4865
5600
  }
@@ -4870,13 +5605,10 @@ function createDeepSeekServer(config, env = process.env) {
4870
5605
  function requestContext(config, env, signal) {
4871
5606
  return { signal, auth: { apiKey: requireApiKey(env) }, retry: config.retry };
4872
5607
  }
4873
- function toStructuredResult(result) {
4874
- return {
4875
- url: result.url,
4876
- ...result.title ? { title: result.title } : {},
4877
- ...result.content ? { content: result.content } : {},
4878
- ...result.published !== undefined ? { published: result.published } : {}
4879
- };
5608
+ function callResult(server, ctx, text, structured) {
5609
+ if (omitStructuredContent(server, ctx))
5610
+ return { content: [{ type: "text", text }] };
5611
+ return { content: [{ type: "text", text }], structuredContent: structured };
4880
5612
  }
4881
5613
  function servePrecheck(config) {
4882
5614
  if (config.search.enabled || config.fetch.enabled)
@@ -4905,7 +5637,16 @@ if (precheck) {
4905
5637
  console.error(precheck);
4906
5638
  process.exit(1);
4907
5639
  }
4908
- var handle = serveStdio(() => createDeepSeekServer(config, env));
5640
+ var handle = serveStdio(() => {
5641
+ const server = createDeepSeekServer(config, env);
5642
+ server.server.oninitialized = () => {
5643
+ const info = server.server.getClientVersion();
5644
+ if (info === undefined)
5645
+ return;
5646
+ console.error(`mcp client: ${info.name} ${info.version ?? "unknown"} — ` + (isJsonReplacingClient(info.name) ? "text-only results (this client replaces content with serialized structuredContent)" : "text + structuredContent results"));
5647
+ };
5648
+ return server;
5649
+ });
4909
5650
  var tools = [
4910
5651
  config.search.enabled ? `search (max ${config.search.maxResults} results)` : null,
4911
5652
  config.fetch.enabled ? `fetch (max ${config.fetch.maxChars} chars)` : null