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
@@ -1,33 +1,37 @@
1
- import { codecFor } from "../formats"
2
- import { isPlainObject } from "../formats/value"
3
- import { FORCED_COST_NOTE } from "../detect"
4
- import { configHome, join } from "../paths"
5
- import { buildRulesBlock } from "../formats/rules"
6
- import { rulesLines } from "../rulesContent"
7
- import { planHeader } from "./shared"
8
- import type { ConfigOp, FileEdit, HostPaths, InstallRecord, InstallerInput, InstallPlan } from "../types"
1
+ import { FORCED_COST_NOTE, looksLikeOwnPackage } from "../detect";
2
+ import { codecFor } from "../formats";
3
+ import { buildRulesBlock } from "../formats/rules";
4
+ import { isPlainObject } from "../formats/value";
5
+ import { configHome, join } from "../paths";
6
+ import { rulesLines } from "../rulesContent";
7
+ import { appendedByPrevious } from "../state";
8
+ import type { ConfigOp, FileEdit, HostPaths, InstallerInput, InstallPlan, InstallRecord } from "../types";
9
+ import { planHeader } from "./shared";
9
10
 
10
11
  /** OpenCode's tools are named differently from the raw MCP ids. */
11
- const OPENCODE_TOOL_IDS = { search: "websearch (provider deepseek)", fetch: "deepseek_fetch" } as const
12
+ const OPENCODE_TOOL_IDS = { search: "websearch (provider deepseek)", fetch: "deepseek_fetch" } as const;
12
13
 
13
14
  /** OpenCode config and rules files per scope; the host module owns this mapping. */
14
15
  function opencodePaths(input: InstallerInput): HostPaths {
15
16
  return input.scope === "global"
16
- ? { config: join(configHome(input), "opencode/opencode.jsonc"), rules: join(configHome(input), "opencode/AGENTS.md") }
17
- : { config: join(input.cwd, "opencode.jsonc"), rules: join(input.cwd, "AGENTS.md") }
17
+ ? {
18
+ config: join(configHome(input), "opencode/opencode.jsonc"),
19
+ rules: join(configHome(input), "opencode/AGENTS.md"),
20
+ }
21
+ : { config: join(input.cwd, "opencode.jsonc"), rules: join(input.cwd, "AGENTS.md") };
18
22
  }
19
23
 
20
24
  export function planOpenCode(input: InstallerInput, previous: InstallRecord | undefined): InstallPlan {
21
- const paths = opencodePaths(input)
22
- const loaded = codecFor("json", input.host).load(paths.config)
23
- const tiers = { search: input.search, fetch: input.fetch }
24
- const header = planHeader(input, paths, loaded, "JSONC")
25
+ const paths = opencodePaths(input);
26
+ const loaded = codecFor("json", input.host).load(paths.config);
27
+ const tiers = { search: input.search, fetch: input.fetch };
28
+ const header = planHeader(input, paths, loaded, "JSONC");
25
29
  if (header.abort) {
26
- return { host: "opencode", scope: input.scope, tiers, edits: [], notes: header.notes, errors: header.errors }
30
+ return { host: "opencode", scope: input.scope, tiers, edits: [], notes: header.notes, errors: header.errors };
27
31
  }
28
- const notes = [...header.notes]
29
- const errors = [...header.errors]
30
- const edits: FileEdit[] = []
32
+ const notes = [...header.notes];
33
+ const errors = [...header.errors];
34
+ const edits: FileEdit[] = [];
31
35
 
32
36
  if (input.search === "preferred") {
33
37
  errors.push({
@@ -35,32 +39,47 @@ export function planOpenCode(input: InstallerInput, previous: InstallRecord | un
35
39
  "OpenCode search has no preferred tier: the websearch provider is host-level config, so the model never " +
36
40
  "chooses between engines. Use --search optional (provider registered, built-in stays default), " +
37
41
  "--search forced (replace the engine), or --search off.",
38
- })
42
+ });
39
43
  }
40
44
 
41
- const plugins = Array.isArray(loaded.value.plugins) ? loaded.value.plugins : []
42
- const alreadyInstalled = plugins.some(
43
- (entry) => isPlainObject(entry) && typeof entry.package === "string" && entry.package.includes("deepseek-web-retrieval"),
44
- )
45
- if (alreadyInstalled && previous === undefined) {
45
+ const plugins = Array.isArray(loaded.value.plugins) ? loaded.value.plugins : [];
46
+ // Precisely ours: plugin packages a tracked init appended, plus this init's ref.
47
+ // Without a record nothing is excused — an untracked same-name plugin (a fork
48
+ // registers the same tool ids and would collide) must be removed first.
49
+ const trackedPackages = new Set(
50
+ appendedByPrevious(previous, ["plugins"])
51
+ .filter((entry): entry is { package: string } => isPlainObject(entry) && typeof entry.package === "string")
52
+ .map((entry) => entry.package),
53
+ );
54
+ const pluginPackages = plugins.filter(
55
+ (entry): entry is { package: string } => isPlainObject(entry) && typeof entry.package === "string",
56
+ );
57
+ const excused = previous === undefined ? new Set<string>() : new Set<string>([...trackedPackages, input.packageRef]);
58
+ const foreignOwn = pluginPackages.filter(
59
+ (entry) => looksLikeOwnPackage(entry.package) && !excused.has(entry.package),
60
+ );
61
+ if (foreignOwn.length > 0) {
46
62
  errors.push({
47
63
  message:
48
64
  `${paths.config} already has a plugins entry referencing deepseek-web-retrieval that this installer did not ` +
49
65
  "write. Remove it (or run uninstall if it came from a previous init), then retry.",
50
- })
66
+ });
51
67
  }
52
- if (alreadyInstalled && errors.length === 0) {
53
- notes.push("A previous init wrote the plugins entry; re-applying updates it and carries the original snapshots over.")
68
+ if (previous !== undefined && pluginPackages.some((entry) => excused.has(entry.package))) {
69
+ notes.push(
70
+ "A previous init wrote the plugins entry; re-applying keeps it the only entry (a changed package ref or tier " +
71
+ "options replace it, an unchanged one is a no-op) and the original snapshots carry over.",
72
+ );
54
73
  }
55
74
  if (errors.length > 0) {
56
- return { host: "opencode", scope: input.scope, tiers, edits, notes, errors }
75
+ return { host: "opencode", scope: input.scope, tiers, edits, notes, errors };
57
76
  }
58
77
 
59
- const options: Record<string, unknown> = {}
60
- if (input.search === "forced") options.setDefaultWebsearch = true
61
- if (input.search === "off") options.searchEnabled = false
62
- if (input.fetch === "forced") options.fetchOverrideWebfetch = true
63
- if (input.fetch === "off") options.fetchEnabled = false
78
+ const options: Record<string, unknown> = {};
79
+ if (input.search === "forced") options.setDefaultWebsearch = true;
80
+ if (input.search === "off") options.searchEnabled = false;
81
+ if (input.fetch === "forced") options.fetchOverrideWebfetch = true;
82
+ if (input.fetch === "off") options.fetchEnabled = false;
64
83
 
65
84
  const ops: ConfigOp[] = [
66
85
  {
@@ -69,40 +88,40 @@ export function planOpenCode(input: InstallerInput, previous: InstallRecord | un
69
88
  entries: [{ package: input.packageRef, options }],
70
89
  existingCount: plugins.length,
71
90
  },
72
- ]
91
+ ];
73
92
 
74
- const permissions: Record<string, unknown>[] = []
75
- if (input.search !== "off") permissions.push({ action: "websearch", resource: "*", effect: "allow" })
76
- if (input.fetch !== "off") permissions.push({ action: "deepseek_fetch", resource: "*", effect: "allow" })
77
- if (input.fetch === "forced") permissions.push({ action: "webfetch", resource: "*", effect: "allow" })
78
- const existingPermissions = Array.isArray(loaded.value.permissions) ? loaded.value.permissions.length : 0
93
+ const permissions: Record<string, unknown>[] = [];
94
+ if (input.search !== "off") permissions.push({ action: "websearch", resource: "*", effect: "allow" });
95
+ if (input.fetch !== "off") permissions.push({ action: "deepseek_fetch", resource: "*", effect: "allow" });
96
+ if (input.fetch === "forced") permissions.push({ action: "webfetch", resource: "*", effect: "allow" });
97
+ const existingPermissions = Array.isArray(loaded.value.permissions) ? loaded.value.permissions.length : 0;
79
98
  if (permissions.length > 0) {
80
- ops.push({ op: "append-array", path: ["permissions"], entries: permissions, existingCount: existingPermissions })
99
+ ops.push({ op: "append-array", path: ["permissions"], entries: permissions, existingCount: existingPermissions });
81
100
  }
82
101
 
83
102
  if (input.search === "forced") {
84
- const currentProvider = isPlainObject(loaded.value.websearch) ? loaded.value.websearch.provider : undefined
85
- ops.push({ op: "set", path: ["websearch", "provider"], value: "deepseek", previous: currentProvider })
103
+ const currentProvider = isPlainObject(loaded.value.websearch) ? loaded.value.websearch.provider : undefined;
104
+ ops.push({ op: "set", path: ["websearch", "provider"], value: "deepseek", previous: currentProvider });
86
105
  notes.push(
87
106
  currentProvider === undefined
88
107
  ? "websearch.provider is being set to deepseek (no previous value existed)."
89
108
  : `websearch.provider is being set to deepseek; the previous value "${String(currentProvider)}" is snapshotted for uninstall.`,
90
- )
109
+ );
91
110
  }
92
111
 
93
- edits.push({ file: paths.config, format: "json", ops })
112
+ edits.push({ file: paths.config, format: "json", ops });
94
113
 
95
114
  notes.push(
96
115
  "The plugin entry uses the object form so options are honored; auto-discovered plugins cannot receive options.",
97
- )
116
+ );
98
117
  if (input.search === "optional") {
99
118
  notes.push(
100
119
  "Search tier optional: the deepseek provider is registered but the built-in provider stays the default engine; " +
101
120
  "set websearch.provider to deepseek (or rerun with --search forced) to route search through DeepSeek.",
102
- )
121
+ );
103
122
  }
104
123
  if (input.search === "forced" || input.fetch === "forced") {
105
- notes.push("Forced tier replaces built-in routing. " + FORCED_COST_NOTE)
124
+ notes.push(`Forced tier replaces built-in routing. ${FORCED_COST_NOTE}`);
106
125
  }
107
126
 
108
127
  if (input.fetch === "preferred") {
@@ -110,9 +129,9 @@ export function planOpenCode(input: InstallerInput, previous: InstallRecord | un
110
129
  file: paths.rules,
111
130
  format: "rules",
112
131
  block: buildRulesBlock(input.host, rulesLines(OPENCODE_TOOL_IDS, input.search, input.fetch)),
113
- })
114
- notes.push(`Rules block upserted in ${paths.rules}; OpenCode loads AGENTS.md only (no CLAUDE.md fallback in v2).`)
132
+ });
133
+ notes.push(`Rules block upserted in ${paths.rules}; OpenCode loads AGENTS.md only (no CLAUDE.md fallback in v2).`);
115
134
  }
116
135
 
117
- return { host: "opencode", scope: input.scope, tiers, edits, notes, errors }
136
+ return { host: "opencode", scope: input.scope, tiers, edits, notes, errors };
118
137
  }
@@ -1,115 +1,309 @@
1
- import { codecFor } from "../formats"
2
- import { isPlainObject } from "../formats/value"
1
+ import { API_KEY_ENV, ENABLE_FETCH_ENV, ENABLE_SEARCH_ENV } from "../../core/constants";
2
+ import { SETTINGS_NAMESPACE } from "../../pi/settings";
3
3
  import {
4
4
  FORCED_COST_NOTE,
5
+ looksLikeOwnPackage,
6
+ looksWebCapable,
5
7
  othersStayRegisteredNote,
6
8
  replacingExistingEntryNote,
7
9
  splitCompetitors,
8
- } from "../detect"
9
- import { join } from "../paths"
10
- import { API_KEY_ENV, ENABLE_FETCH_ENV, ENABLE_SEARCH_ENV } from "../../core/constants"
11
- import { buildRulesBlock } from "../formats/rules"
12
- import { MCP_TOOL_IDS, rulesLines } from "../rulesContent"
13
- import { planHeader } from "./shared"
14
- import type { ConfigOp, FileEdit, HostPaths, InstallRecord, InstallerInput, InstallPlan } from "../types"
15
-
16
- /** Pi config and rules files per scope; the host module owns this mapping. */
10
+ } from "../detect";
11
+ import { codecFor } from "../formats";
12
+ import { buildRulesBlock } from "../formats/rules";
13
+ import { isPlainObject } from "../formats/value";
14
+ import { join } from "../paths";
15
+ import { MCP_TOOL_IDS, RAW_MCP_TOOL_IDS, rulesLines } from "../rulesContent";
16
+ import { appendedByPrevious } from "../state";
17
+ import type { ConfigOp, FileEdit, HostPaths, InstallerInput, InstallPlan, InstallRecord, PlanError } from "../types";
18
+ import { planHeader } from "./shared";
19
+
20
+ /** Pi config and rules files per scope and path; the host module owns this mapping. */
17
21
  function piPaths(input: InstallerInput): HostPaths {
18
- return input.scope === "global"
19
- ? { config: join(input.home, ".pi/agent/mcp.json"), rules: join(input.home, ".pi/agent/AGENTS.md") }
20
- : { config: join(input.cwd, ".pi/mcp.json"), rules: join(input.cwd, "AGENTS.md") }
22
+ const mcp = input.scope === "global" ? join(input.home, ".pi/agent/mcp.json") : join(input.cwd, ".pi/mcp.json");
23
+ const settings =
24
+ input.scope === "global" ? join(input.home, ".pi/agent/settings.json") : join(input.cwd, ".pi/settings.json");
25
+ const rules = input.scope === "global" ? join(input.home, ".pi/agent/AGENTS.md") : join(input.cwd, "AGENTS.md");
26
+ return { config: input.path === "mcp" ? mcp : settings, rules };
27
+ }
28
+
29
+ /**
30
+ * The packages source this init writes: a ref that already carries Pi's `npm:`
31
+ * source prefix goes through as-is (wrapping it again would produce the broken
32
+ * `npm:npm:…` form); anything else — bare names, name@spec, npm specs like
33
+ * `file:` or `github:` — becomes an npm source.
34
+ */
35
+ function piSourceRef(packageRef: string): string {
36
+ return packageRef.startsWith("npm:") ? packageRef : `npm:${packageRef}`;
37
+ }
38
+
39
+ export function planPi(input: InstallerInput, previous: InstallRecord | undefined): InstallPlan {
40
+ return input.path === "mcp" ? planPiMcp(input) : planPiExtension(input, previous);
21
41
  }
22
42
 
23
- export function planPi(input: InstallerInput, _previous: InstallRecord | undefined): InstallPlan {
24
- const paths = piPaths(input)
25
- const loaded = codecFor("json", input.host).load(paths.config)
26
- const tiers = { search: input.search, fetch: input.fetch }
27
- const header = planHeader(input, paths, loaded, "JSON")
43
+ /**
44
+ * The native-extension path (Pi's default): registers the package in
45
+ * settings.json's `packages` array; Pi resolves the source at session start
46
+ * and loads `src/pi/extension.ts` through the package's `pi` manifest.
47
+ */
48
+ function planPiExtension(input: InstallerInput, previous: InstallRecord | undefined): InstallPlan {
49
+ const paths = piPaths(input);
50
+ const loaded = codecFor("json", input.host).load(paths.config);
51
+ const tiers = { search: input.search, fetch: input.fetch };
52
+ const header = planHeader(input, paths, loaded, "JSON");
28
53
  if (header.abort) {
29
- return { host: "pi", scope: input.scope, tiers, edits: [], notes: header.notes, errors: header.errors }
54
+ return { host: "pi", scope: input.scope, tiers, edits: [], notes: header.notes, errors: header.errors };
55
+ }
56
+ const notes = [...header.notes];
57
+ const errors = [...header.errors];
58
+ const edits: FileEdit[] = [];
59
+
60
+ const packages = Array.isArray(loaded.value.packages) ? loaded.value.packages : [];
61
+ const source = piSourceRef(input.packageRef);
62
+ // Precisely ours: what a tracked init appended, plus what this init would write.
63
+ // Only a tracked init can excuse a same-name source — without a record, even an
64
+ // exact match is an untracked install this installer must not adopt silently.
65
+ const trackedSources = appendedByPrevious(previous, ["packages"]).filter(
66
+ (entry): entry is string => typeof entry === "string",
67
+ );
68
+ const excused = previous === undefined ? new Set<string>() : new Set<string>([...trackedSources, source]);
69
+ const sources = packages.filter((entry): entry is string => typeof entry === "string");
70
+ const foreignOwn = sources.filter((entry) => looksLikeOwnPackage(entry) && !excused.has(entry));
71
+ if (foreignOwn.length > 0) {
72
+ errors.push({
73
+ message:
74
+ `${paths.config} already lists ${foreignOwn.join(", ")} — this package (or a fork of it) that this installer did not ` +
75
+ "write. Remove it (pi remove <source>, or uninstall if it came from a previous init), then retry.",
76
+ });
77
+ }
78
+ if (previous !== undefined && sources.some((entry) => excused.has(entry))) {
79
+ notes.push(
80
+ "A previous init wrote the packages entry; re-applying reuses it (an unchanged source deduplicates, a changed " +
81
+ "--package ref replaces the old source) and the original snapshots carry over.",
82
+ );
83
+ }
84
+
85
+ const forced = input.search === "forced" || input.fetch === "forced";
86
+ if (forced) {
87
+ // Pi has no per-package kill switch in settings.json, so a web-capable
88
+ // package or extension source cannot be excluded from the plan — refuse the
89
+ // tier instead of promising routing that is not actually forced.
90
+ const ownSources = new Set<string>([...trackedSources, source]);
91
+ const unexcludable = [...collectSources(loaded.value.packages), ...collectSources(loaded.value.extensions)].filter(
92
+ (entry) => !ownSources.has(entry) && looksWebCapable(entry, undefined),
93
+ );
94
+ if (unexcludable.length > 0) {
95
+ errors.push({
96
+ message:
97
+ `Forced cannot deliver exclusive routing: settings sources ${unexcludable.join(", ")} look web-capable, ` +
98
+ "and Pi offers no per-package kill switch. Remove them (pi remove <source>) or choose a lower tier.",
99
+ });
100
+ }
101
+ }
102
+
103
+ if (errors.length > 0) {
104
+ return { host: "pi", scope: input.scope, tiers, edits: [], notes, errors };
105
+ }
106
+
107
+ const ops: ConfigOp[] = [
108
+ { op: "append-array", path: ["packages"], entries: [source], existingCount: packages.length },
109
+ ];
110
+ const namespace = isPlainObject(loaded.value[SETTINGS_NAMESPACE]) ? loaded.value[SETTINGS_NAMESPACE] : {};
111
+ if (input.search === "off") {
112
+ ops.push({
113
+ op: "set",
114
+ path: [SETTINGS_NAMESPACE, "enableSearch"],
115
+ value: false,
116
+ previous: typeof namespace.enableSearch === "boolean" ? namespace.enableSearch : undefined,
117
+ });
118
+ }
119
+ if (input.fetch === "off") {
120
+ ops.push({
121
+ op: "set",
122
+ path: [SETTINGS_NAMESPACE, "enableFetch"],
123
+ value: false,
124
+ previous: typeof namespace.enableFetch === "boolean" ? namespace.enableFetch : undefined,
125
+ });
126
+ }
127
+ edits.push({ file: paths.config, format: "json", ops });
128
+
129
+ notes.push(
130
+ "Pi resolves the packages source at session start; the first launch after apply fetches the package from npm " +
131
+ "(pi update --extensions refreshes it later).",
132
+ );
133
+ notes.push(
134
+ `The extension reads ${API_KEY_ENV} from the Pi process environment; no env forwarding is needed. ` +
135
+ "Set it in the shell that launches Pi.",
136
+ );
137
+ if (input.search === "off" || input.fetch === "off") {
138
+ notes.push(
139
+ `Off capability switches are written into the "${SETTINGS_NAMESPACE}" settings namespace; the extension ` +
140
+ "reads them at load (they outrank the DEEPSEEK_ENABLE_* environment variables).",
141
+ );
142
+ }
143
+ notes.push(
144
+ 'Extension tools are declared to the model with exposure "direct" by Pi\'s own default; no exposure key is needed.',
145
+ );
146
+
147
+ if (forced) {
148
+ edits.push(...planMcpCompetitorDisables(input, notes, errors));
149
+ }
150
+ if (errors.length > 0) {
151
+ return { host: "pi", scope: input.scope, tiers, edits: [], notes, errors };
30
152
  }
31
- const notes = [...header.notes]
32
- const errors = [...header.errors]
33
- const edits: FileEdit[] = []
34
153
 
35
- const env: Record<string, string> = { [API_KEY_ENV]: `\${${API_KEY_ENV}}` }
36
- if (input.search === "off") env[ENABLE_SEARCH_ENV] = "false"
37
- if (input.fetch === "off") env[ENABLE_FETCH_ENV] = "false"
154
+ if (input.search === "preferred" || input.fetch === "preferred") {
155
+ edits.push({
156
+ file: paths.rules,
157
+ format: "rules",
158
+ block: buildRulesBlock(input.host, rulesLines(RAW_MCP_TOOL_IDS, input.search, input.fetch)),
159
+ });
160
+ notes.push(
161
+ `Rules block upserted in ${paths.rules}; Pi loads AGENTS.md context files from the working directory and its parents.`,
162
+ );
163
+ }
164
+
165
+ if (forced) {
166
+ notes.push(
167
+ "Forced tier: Pi 1.3.0 ships no built-in web tool, so the registered tools are the only web path (de-facto). " +
168
+ FORCED_COST_NOTE,
169
+ );
170
+ }
171
+
172
+ return { host: "pi", scope: input.scope, tiers, edits, notes, errors };
173
+ }
174
+
175
+ /**
176
+ * Disables web-capable MCP competitors for the forced tier: one `enabled:
177
+ * false` op per server (Pi entries carry an `enabled` key), each snapshotting
178
+ * its prior value for uninstall. Shared by both Pi paths — the MCP path folds
179
+ * the ops into its own mcp.json edit, the extension path opens a separate one.
180
+ */
181
+ function planCompetitorDisables(
182
+ servers: Record<string, unknown>,
183
+ competitors: readonly string[],
184
+ notes: string[],
185
+ errors: PlanError[],
186
+ ): readonly ConfigOp[] {
187
+ const ops: ConfigOp[] = [];
188
+ const excluded: string[] = [];
189
+ const unexcludable: string[] = [];
190
+ for (const name of competitors) {
191
+ const current = servers[name];
192
+ if (!isPlainObject(current)) {
193
+ unexcludable.push(name);
194
+ continue;
195
+ }
196
+ const previous = typeof current.enabled === "boolean" ? current.enabled : undefined;
197
+ // Keep the op even when already disabled: a re-init must retain the claim
198
+ // and original snapshot, otherwise orphan reverts would re-enable it.
199
+ ops.push({ op: "set", path: ["mcpServers", name, "enabled"], value: false, previous });
200
+ excluded.push(name);
201
+ }
202
+ if (excluded.length > 0) {
203
+ notes.push(
204
+ `Forced tier: web-capable MCP servers are disabled so DeepSeek routing is exclusive (${excluded.join(", ")}; ` +
205
+ "prior enabled values are snapshotted for uninstall).",
206
+ );
207
+ }
208
+ if (unexcludable.length > 0) {
209
+ errors.push({
210
+ message:
211
+ `Forced cannot deliver exclusive routing: mcpServers entries ${unexcludable.join(", ")} look web-capable ` +
212
+ "but are not objects, so the plan cannot set their enabled key. Fix or remove them, or choose a lower tier.",
213
+ });
214
+ }
215
+ return ops;
216
+ }
217
+
218
+ /** Disables web-capable MCP servers for the forced tier; the extension path still shares the field with them. */
219
+ function planMcpCompetitorDisables(input: InstallerInput, notes: string[], errors: PlanError[]): FileEdit[] {
220
+ const mcpFile = input.scope === "global" ? join(input.home, ".pi/agent/mcp.json") : join(input.cwd, ".pi/mcp.json");
221
+ const loaded = codecFor("json", input.host).load(mcpFile);
222
+ if (loaded.parseError) {
223
+ errors.push({ message: `${mcpFile} does not parse: ${loaded.parseError}` });
224
+ return [];
225
+ }
226
+ const servers = isPlainObject(loaded.value.mcpServers) ? loaded.value.mcpServers : undefined;
227
+ if (!isPlainObject(servers)) return [];
228
+
229
+ const { competitors, bystanders } = splitCompetitors(servers);
230
+ const edits: FileEdit[] = [];
231
+ if (competitors.length > 0) {
232
+ const ops = planCompetitorDisables(servers, competitors, notes, errors);
233
+ if (ops.length > 0) edits.push({ file: mcpFile, format: "json", ops });
234
+ }
235
+ if (bystanders.length > 0) notes.push(othersStayRegisteredNote(bystanders));
236
+ return edits;
237
+ }
238
+
239
+ /** The MCP-server path (`--path mcp`): registers the stdio server in mcp.json. */
240
+ function planPiMcp(input: InstallerInput): InstallPlan {
241
+ const paths = piPaths(input);
242
+ const loaded = codecFor("json", input.host).load(paths.config);
243
+ const tiers = { search: input.search, fetch: input.fetch };
244
+ const header = planHeader(input, paths, loaded, "JSON");
245
+ if (header.abort) {
246
+ return { host: "pi", scope: input.scope, tiers, edits: [], notes: header.notes, errors: header.errors };
247
+ }
248
+ const notes = [...header.notes];
249
+ const errors = [...header.errors];
250
+ const edits: FileEdit[] = [];
251
+
252
+ const env: Record<string, string> = { [API_KEY_ENV]: `\${${API_KEY_ENV}}` };
253
+ if (input.search === "off") env[ENABLE_SEARCH_ENV] = "false";
254
+ if (input.fetch === "off") env[ENABLE_FETCH_ENV] = "false";
38
255
  const entry = {
39
256
  command: "npx",
40
257
  args: ["-y", input.packageRef],
41
258
  env,
42
259
  exposure: "direct",
43
260
  enabled: true,
44
- }
261
+ };
45
262
 
46
- const servers = loaded.value.mcpServers
47
- const existingEntry = isPlainObject(servers) ? servers.deepseek : undefined
48
- const ops: ConfigOp[] = [
49
- { op: "set", path: ["mcpServers", "deepseek"], value: entry, previous: existingEntry },
50
- ]
51
- edits.push({ file: paths.config, format: "json", ops })
263
+ const servers = loaded.value.mcpServers;
264
+ const existingEntry = isPlainObject(servers) ? servers.deepseek : undefined;
265
+ const ops: ConfigOp[] = [{ op: "set", path: ["mcpServers", "deepseek"], value: entry, previous: existingEntry }];
266
+ edits.push({ file: paths.config, format: "json", ops });
52
267
 
53
268
  if (isPlainObject(servers)) {
54
- if (servers.deepseek !== undefined) notes.push(replacingExistingEntryNote("mcpServers.deepseek"))
55
- const { competitors, bystanders } = splitCompetitors(servers)
56
- const forced = input.search === "forced" || input.fetch === "forced"
269
+ if (servers.deepseek !== undefined) notes.push(replacingExistingEntryNote("mcpServers.deepseek"));
270
+ const { competitors, bystanders } = splitCompetitors(servers);
271
+ const forced = input.search === "forced" || input.fetch === "forced";
57
272
  if (forced) {
58
- // Forced demands exclusivity: disable the web-capable competitors (Pi entries
59
- // carry an `enabled` key), snapshotting each prior value for uninstall.
60
- const excluded: string[] = []
61
- const unexcludable: string[] = []
62
- for (const name of competitors) {
63
- const current = servers[name]
64
- if (!isPlainObject(current)) {
65
- unexcludable.push(name)
66
- continue
67
- }
68
- const previous = typeof current.enabled === "boolean" ? current.enabled : undefined
69
- if (previous === false) continue // already excluded
70
- ops.push({ op: "set", path: ["mcpServers", name, "enabled"], value: false, previous })
71
- excluded.push(name)
72
- }
73
- if (excluded.length > 0) {
74
- notes.push(
75
- `Forced tier: web-capable MCP servers are disabled so DeepSeek routing is exclusive (${excluded.join(", ")}; ` +
76
- "prior enabled values are snapshotted for uninstall).",
77
- )
78
- }
79
- if (unexcludable.length > 0) {
80
- errors.push({
81
- message:
82
- `Forced cannot deliver exclusive routing: mcpServers entries ${unexcludable.join(", ")} look web-capable ` +
83
- "but are not objects, so the plan cannot set their enabled key. Fix or remove them, or choose a lower tier.",
84
- })
85
- }
273
+ ops.push(...planCompetitorDisables(servers, competitors, notes, errors));
86
274
  }
87
- const stayRegistered = forced ? bystanders : [...competitors, ...bystanders]
88
- if (stayRegistered.length > 0) notes.push(othersStayRegisteredNote(stayRegistered))
275
+ const stayRegistered = forced ? bystanders : [...competitors, ...bystanders];
276
+ if (stayRegistered.length > 0) notes.push(othersStayRegisteredNote(stayRegistered));
89
277
  }
90
- notes.push('"exposure": "direct" is required: Pi 1.3.0 defaults MCP tools to codemode, which hides them from the model.')
278
+ notes.push(
279
+ '"exposure": "direct" is required: Pi 1.3.0 defaults MCP tools to codemode, which hides them from the model.',
280
+ );
91
281
  notes.push(
92
282
  `\`${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.`,
93
- )
94
- if (loaded.lossy) {
95
- notes.push(`${paths.config} contains comments; the JSON edit preserves them.`)
96
- }
283
+ );
97
284
 
98
285
  if (input.search === "preferred" || input.fetch === "preferred") {
99
286
  edits.push({
100
287
  file: paths.rules,
101
288
  format: "rules",
102
289
  block: buildRulesBlock(input.host, rulesLines(MCP_TOOL_IDS, input.search, input.fetch)),
103
- })
104
- notes.push(`Rules block upserted in ${paths.rules}; Pi loads AGENTS.md context files from the working directory and its parents.`)
290
+ });
291
+ notes.push(
292
+ `Rules block upserted in ${paths.rules}; Pi loads AGENTS.md context files from the working directory and its parents.`,
293
+ );
105
294
  }
106
295
 
107
296
  if (input.search === "forced" || input.fetch === "forced") {
108
297
  notes.push(
109
298
  "Forced tier: Pi 1.3.0 ships no built-in web tool, so the registered tools are the only web path (de-facto). " +
110
299
  FORCED_COST_NOTE,
111
- )
300
+ );
112
301
  }
113
302
 
114
- return { host: "pi", scope: input.scope, tiers, edits, notes, errors }
303
+ return { host: "pi", scope: input.scope, tiers, edits, notes, errors };
304
+ }
305
+
306
+ /** Pulls the string sources out of a settings packages/extensions array. */
307
+ function collectSources(value: unknown): readonly string[] {
308
+ return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
115
309
  }
@@ -1,13 +1,13 @@
1
- import { BOTH_CAPABILITIES_OFF, competitorSteeringNote, findCompetitorSteering } from "../detect"
2
- import type { LoadedConfig } from "../formats"
3
- import type { HostPaths, InstallerInput, PlanError } from "../types"
1
+ import { BOTH_CAPABILITIES_OFF, competitorSteeringNote, findCompetitorSteering } from "../detect";
2
+ import { codecFor, type LoadedConfig } from "../formats";
3
+ import type { HostPaths, InstallerInput, PlanError } from "../types";
4
4
 
5
5
  /** What the shared plan header accumulated: notes and errors so far, and whether to stop. */
6
6
  export interface PlanHeader {
7
- readonly notes: string[]
8
- readonly errors: PlanError[]
7
+ readonly notes: string[];
8
+ readonly errors: PlanError[];
9
9
  /** True when the caller must return the (empty-edits) plan as-is. */
10
- readonly abort: boolean
10
+ readonly abort: boolean;
11
11
  }
12
12
 
13
13
  /**
@@ -16,16 +16,21 @@ export interface PlanHeader {
16
16
  * competitor steering in the rules file. Host-specific checks follow in the module.
17
17
  */
18
18
  export function planHeader(input: InstallerInput, paths: HostPaths, loaded: LoadedConfig, dialect: string): PlanHeader {
19
- const notes: string[] = []
20
- const errors: PlanError[] = []
19
+ const notes: string[] = [];
20
+ const errors: PlanError[] = [];
21
21
  if (loaded.parseError) {
22
- errors.push({ message: `${paths.config} exists but does not parse as ${dialect}: ${loaded.parseError}` })
23
- return { notes, errors, abort: true }
22
+ errors.push({ message: `${paths.config} exists but does not parse as ${dialect}: ${loaded.parseError}` });
23
+ return { notes, errors, abort: true };
24
24
  }
25
25
  if (input.search === "off" && input.fetch === "off") {
26
- errors.push({ message: BOTH_CAPABILITIES_OFF })
26
+ errors.push({ message: BOTH_CAPABILITIES_OFF });
27
27
  }
28
- const steering = findCompetitorSteering(input.host, paths.rules)
29
- if (steering.length > 0) notes.push(competitorSteeringNote(paths.rules, steering))
30
- return { notes, errors, abort: false }
28
+ const rules = codecFor("rules", input.host).load(paths.rules);
29
+ if (rules.parseError) {
30
+ errors.push({ message: `${paths.rules}: ${rules.parseError}` });
31
+ return { notes, errors, abort: true };
32
+ }
33
+ const steering = findCompetitorSteering(input.host, paths.rules);
34
+ if (steering.length > 0) notes.push(competitorSteeringNote(paths.rules, steering));
35
+ return { notes, errors, abort: false };
31
36
  }