deepseek-web-retrieval 0.2.0 → 0.3.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 (53) hide show
  1. package/CHANGELOG.md +187 -1
  2. package/README.md +13 -9
  3. package/dist/cli.js +4228 -178
  4. package/docs/adr/0001-refuse-drift-between-plan-and-apply.md +29 -0
  5. package/docs/agents/codex.md +193 -0
  6. package/docs/agents/domain.md +51 -0
  7. package/docs/agents/issue-tracker.md +45 -0
  8. package/docs/{opencode-v2.md → agents/opencode-v2.md} +30 -7
  9. package/docs/{pi.md → agents/pi.md} +35 -7
  10. package/docs/agents/triage-labels.md +15 -0
  11. package/docs/design/installer-design.md +184 -0
  12. package/docs/hosts.jsonc +59 -33
  13. package/docs/integration.md +45 -52
  14. package/docs/research/control-matrix.md +257 -0
  15. package/docs/{mcp-compatibility.md → research/mcp-compatibility.md} +39 -108
  16. package/package.json +5 -3
  17. package/src/core/auth.ts +1 -1
  18. package/src/core/client.ts +42 -249
  19. package/src/core/constants.ts +5 -0
  20. package/src/core/descriptions.ts +19 -0
  21. package/src/core/format.ts +31 -1
  22. package/src/core/jsonc.ts +16 -0
  23. package/src/core/options.ts +2 -3
  24. package/src/core/search.ts +5 -7
  25. package/src/core/transport.ts +253 -0
  26. package/src/core/types.ts +13 -11
  27. package/src/installer/apply.ts +176 -0
  28. package/src/installer/cli.ts +215 -0
  29. package/src/installer/detect.ts +85 -0
  30. package/src/installer/formats/index.ts +58 -0
  31. package/src/installer/formats/json.ts +151 -0
  32. package/src/installer/formats/rules.ts +62 -0
  33. package/src/installer/formats/toml.ts +163 -0
  34. package/src/installer/formats/value.ts +57 -0
  35. package/src/installer/hosts/codex.ts +128 -0
  36. package/src/installer/hosts/index.ts +134 -0
  37. package/src/installer/hosts/opencode.ts +118 -0
  38. package/src/installer/hosts/pi.ts +115 -0
  39. package/src/installer/hosts/shared.ts +31 -0
  40. package/src/installer/paths.ts +18 -0
  41. package/src/installer/render.ts +62 -0
  42. package/src/installer/revert.ts +61 -0
  43. package/src/installer/rulesContent.ts +32 -0
  44. package/src/installer/state.ts +63 -0
  45. package/src/installer/types.ts +126 -0
  46. package/src/installer/uninstall.ts +33 -0
  47. package/src/mcp/auth.ts +2 -1
  48. package/src/mcp/cli.ts +21 -5
  49. package/src/mcp/config.ts +70 -27
  50. package/src/mcp/server.ts +81 -65
  51. package/src/opencode/auth.ts +3 -2
  52. package/src/opencode/plugin.ts +93 -56
  53. package/src/mcp/format.ts +0 -31
@@ -0,0 +1,128 @@
1
+ import { codecFor } from "../formats"
2
+ import { isPlainObject } from "../formats/value"
3
+ import {
4
+ FORCED_COST_NOTE,
5
+ othersStayRegisteredNote,
6
+ replacingExistingEntryNote,
7
+ 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
+ /** Codex config is user-level only; project scope keeps the rules file project-local. */
17
+ function codexPaths(input: InstallerInput): HostPaths {
18
+ return {
19
+ config: join(input.home, ".codex/config.toml"),
20
+ rules: input.scope === "global" ? join(input.home, ".codex/AGENTS.md") : join(input.cwd, "AGENTS.md"),
21
+ }
22
+ }
23
+
24
+ export function planCodex(input: InstallerInput, _previous: InstallRecord | undefined): InstallPlan {
25
+ const paths = codexPaths(input)
26
+ const loaded = codecFor("toml", input.host).load(paths.config)
27
+ const tiers = { search: input.search, fetch: input.fetch }
28
+ const header = planHeader(input, paths, loaded, "TOML")
29
+ if (header.abort) {
30
+ return { host: "codex", scope: input.scope, tiers, edits: [], notes: header.notes, errors: header.errors }
31
+ }
32
+ const notes = [...header.notes]
33
+ const errors = [...header.errors]
34
+ const edits: FileEdit[] = []
35
+
36
+ const tools = loaded.value.tools
37
+ if (isPlainObject(tools) && "web_search" in tools) {
38
+ errors.push({
39
+ message:
40
+ `${paths.config} sets [tools] web_search, which is a silent no-op at Codex 0.163.0 (only the top-level ` +
41
+ "web_search key controls the tool). Remove it first so the config cannot mislead.",
42
+ })
43
+ }
44
+
45
+ const servers = loaded.value.mcp_servers
46
+ if (isPlainObject(servers)) {
47
+ if ("deepseek" in servers) notes.push(replacingExistingEntryNote("mcp_servers.deepseek"))
48
+ const { competitors, bystanders } = splitCompetitors(servers)
49
+ if ((input.search === "forced" || input.fetch === "forced") && competitors.length > 0) {
50
+ // Codex has no per-server kill switch (only per-tool enabled_tools/disabled_tools
51
+ // inside a server), so a web-capable competitor cannot be excluded from the plan —
52
+ // refuse the tier instead of promising routing that is not actually forced.
53
+ errors.push({
54
+ message:
55
+ `Forced cannot deliver exclusive routing: [mcp_servers.${competitors.join("], [mcp_servers.")}] look(s) web-capable, ` +
56
+ "and Codex offers no per-server kill switch. Remove the server(s), disable their tools by hand via their " +
57
+ "disabled_tools key, or choose a lower tier.",
58
+ })
59
+ }
60
+ const stayRegistered = input.search === "forced" || input.fetch === "forced" ? bystanders : [...competitors, ...bystanders]
61
+ if (stayRegistered.length > 0) notes.push(othersStayRegisteredNote(stayRegistered))
62
+ }
63
+
64
+ const env: Record<string, string> = {}
65
+ if (input.search === "off") env[ENABLE_SEARCH_ENV] = "false"
66
+ if (input.fetch === "off") env[ENABLE_FETCH_ENV] = "false"
67
+ const entry = {
68
+ command: "npx",
69
+ args: ["-y", input.packageRef],
70
+ ...(Object.keys(env).length > 0 ? { env } : {}),
71
+ env_vars: [API_KEY_ENV],
72
+ }
73
+
74
+ const existingEntry = isPlainObject(servers) ? servers.deepseek : undefined
75
+ const ops: ConfigOp[] = [
76
+ { op: "set", path: ["mcp_servers", "deepseek"], value: entry, previous: existingEntry },
77
+ ]
78
+ if (input.search === "forced") {
79
+ const previous = typeof loaded.value.web_search === "string" ? loaded.value.web_search : undefined
80
+ ops.push({ op: "set", path: ["web_search"], value: "disabled", previous })
81
+ notes.push(
82
+ previous === undefined
83
+ ? "web_search is being set to \"disabled\" (no previous value existed; the default is \"cached\")."
84
+ : `web_search is being set to "disabled"; the previous value "${previous}" is snapshotted for uninstall.`,
85
+ )
86
+ }
87
+ edits.push({ file: paths.config, format: "toml", ops })
88
+
89
+ notes.push(
90
+ `Codex stdio children do not inherit the parent environment, so the entry forwards ${API_KEY_ENV} explicitly ` +
91
+ "via env_vars; the key must be set in the shell that launches Codex.",
92
+ )
93
+ if (input.scope === "project") {
94
+ notes.push(
95
+ "Codex config lives in ~/.codex/config.toml only (user level), so the server registration and kill switch are " +
96
+ "written there even for a project-scope install; the rules file stays project-local.",
97
+ )
98
+ }
99
+ if (loaded.lossy) {
100
+ notes.push(`${paths.config} contains comments; the TOML rewrite will drop them.`)
101
+ }
102
+ if (input.search === "forced" || input.fetch === "forced") {
103
+ notes.push(
104
+ 'Forced search is exclusion: the built-in web_search tool is disabled ("disabled" mode). ' + FORCED_COST_NOTE,
105
+ )
106
+ }
107
+ if (input.search === "optional" || input.fetch === "optional") {
108
+ notes.push(
109
+ 'Note: Codex ships a built-in web_search tool (default "cached" mode, an OpenAI index) that stays available ' +
110
+ "alongside the DeepSeek tools under the optional tier.",
111
+ )
112
+ }
113
+
114
+ if (input.search === "preferred" || input.fetch === "preferred") {
115
+ edits.push({
116
+ file: paths.rules,
117
+ format: "rules",
118
+ block: buildRulesBlock(input.host, rulesLines(MCP_TOOL_IDS, input.search, input.fetch)),
119
+ })
120
+ notes.push(
121
+ input.scope === "global"
122
+ ? `Rules block upserted in ${paths.rules}; Codex reads ~/.codex/AGENTS.md plus every AGENTS.md from the project root to the cwd.`
123
+ : `Rules block upserted in ${paths.rules}; Codex reads every AGENTS.md from the project root to the cwd.`,
124
+ )
125
+ }
126
+
127
+ return { host: "codex", scope: input.scope, tiers, edits, notes, errors }
128
+ }
@@ -0,0 +1,134 @@
1
+ import { planCodex } from "./codex"
2
+ import { planOpenCode } from "./opencode"
3
+ import { planPi } from "./pi"
4
+ import { API_KEY_ENV } from "../../core/constants"
5
+ import { claimedByOtherScope, readRecord } from "../state"
6
+ import type { HostName, InstallRecord, InstallerInput, InstallPlan, RecordedFile } from "../types"
7
+
8
+ /**
9
+ * A host planner sees the previous install record for its scope, so it never reads
10
+ * install state itself; planForHost is the one place a record read happens per plan.
11
+ */
12
+ export type HostPlanner = (input: InstallerInput, previous: InstallRecord | undefined) => InstallPlan
13
+
14
+ /**
15
+ * The host registry. Key order feeds the CLI's usage string; a new host is one
16
+ * module, one line here, one HostName union member, and one hosts.jsonc row
17
+ * carrying the installer field — the two guards in test/hosts.test.ts hold all
18
+ * four in sync.
19
+ */
20
+ export const HOST_PLANNERS: Readonly<Record<HostName, HostPlanner>> = {
21
+ opencode: planOpenCode,
22
+ pi: planPi,
23
+ codex: planCodex,
24
+ }
25
+
26
+ export function planForHost(host: HostName, input: InstallerInput): InstallPlan {
27
+ const previous = readRecord(input)
28
+ const planned = withCrossScopeGuard(HOST_PLANNERS[host](input, previous), input)
29
+ return withSecretGuard(withOrphanReverts(planned, input, previous))
30
+ }
31
+
32
+ /**
33
+ * One file must never be claimed by two scopes of the same host: Codex project-scope
34
+ * config falls back to ~/.codex/config.toml, so a project install beside a global one
35
+ * would let either uninstall clobber the other's registration. Refuse with guidance
36
+ * instead. Hosts whose scope pairs map to disjoint files (Pi, OpenCode) pass through.
37
+ */
38
+ function withCrossScopeGuard(plan: InstallPlan, input: InstallerInput): InstallPlan {
39
+ if (plan.errors.length > 0) return plan
40
+ const other = input.scope === "global" ? "project" : "global"
41
+ const overlap = claimedByOtherScope(input, plan.edits.map((edit) => edit.file))
42
+ if (overlap.length === 0) return plan
43
+ return {
44
+ ...plan,
45
+ edits: [],
46
+ reverts: undefined,
47
+ errors: [
48
+ {
49
+ message:
50
+ `A ${plan.host} install is already recorded at the ${other} scope and claims the same file(s): ${overlap.join(", ")}. ` +
51
+ `Uninstall it first (deepseek-web-retrieval uninstall --host ${plan.host} --scope ${other}), ` +
52
+ `or re-run with --scope ${other} to update it in place — two scopes must not edit one file.`,
53
+ },
54
+ ],
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Refuses to snapshot values that look like credentials: replacing an existing entry
60
+ * that carries a literal API key would copy that secret into the install record, so
61
+ * the plan errors and asks the user to move the key to environment indirection first.
62
+ * A pure `${VAR}` expansion is that indirection — including the form this installer
63
+ * itself writes — so it is allowed through and re-init over our own output works.
64
+ */
65
+ function withSecretGuard(plan: InstallPlan): InstallPlan {
66
+ for (const edit of plan.edits) {
67
+ if (edit.format === "rules") continue // rules edits carry no values to snapshot
68
+ for (const op of edit.ops) {
69
+ if (op.op !== "set" || op.previous === undefined) continue
70
+ const offender = findSecretLike(op.previous, op.path.join("."))
71
+ if (offender !== undefined) {
72
+ return {
73
+ ...plan,
74
+ edits: [],
75
+ reverts: undefined,
76
+ errors: [
77
+ {
78
+ message:
79
+ `${edit.file}: the existing value at ${op.path.join(".")} contains a credential-looking key ` +
80
+ `(${offender}). The installer refuses to snapshot secrets into its state file. Move the key to ` +
81
+ `environment indirection ("\${${API_KEY_ENV}}" expansion or env_vars) and re-run.`,
82
+ },
83
+ ],
84
+ }
85
+ }
86
+ }
87
+ }
88
+ return plan
89
+ }
90
+
91
+ function findSecretLike(value: unknown, at: string): string | undefined {
92
+ if (typeof value !== "object" || value === null) return undefined
93
+ for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
94
+ const path = `${at}.${key}`
95
+ if (
96
+ /(api[_-]?key|token|secret|password|credential)/i.test(key) &&
97
+ typeof child === "string" &&
98
+ child.length > 0 &&
99
+ !/^\$\{[^}]+\}$/.test(child)
100
+ ) {
101
+ return path
102
+ }
103
+ const nested = findSecretLike(child, path)
104
+ if (nested !== undefined) return nested
105
+ }
106
+ return undefined
107
+ }
108
+
109
+ /**
110
+ * A re-init supersedes the previous record, so anything the previous init applied
111
+ * that the new plan no longer wants must be reverted explicitly — otherwise it is
112
+ * silently orphaned (and uninstall could never restore it). Whole files missing
113
+ * from the new plan revert entirely; files shared with the new plan revert only
114
+ * the ops whose paths the new plan does not touch.
115
+ */
116
+ function withOrphanReverts(plan: InstallPlan, input: InstallerInput, previous: InstallRecord | undefined): InstallPlan {
117
+ if (plan.errors.length > 0) return plan
118
+ if (previous === undefined) return plan
119
+ const reverts: RecordedFile[] = []
120
+ for (const file of previous.files) {
121
+ const edit = plan.edits.find((candidate) => candidate.file === file.file)
122
+ if (edit === undefined) {
123
+ reverts.push(file)
124
+ continue
125
+ }
126
+ if (edit.format === "rules" || file.format === "rules") continue // the new plan's block supersedes ours
127
+ const orphanOps = file.ops.filter(
128
+ (op) => !edit.ops.some((candidate) => candidate.op === op.op && candidate.path.join("\u0000") === op.path.join("\u0000")),
129
+ )
130
+ if (orphanOps.length > 0) reverts.push({ ...file, ops: orphanOps })
131
+ }
132
+ if (reverts.length === 0) return plan
133
+ return { ...plan, reverts }
134
+ }
@@ -0,0 +1,118 @@
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"
9
+
10
+ /** 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
+
13
+ /** OpenCode config and rules files per scope; the host module owns this mapping. */
14
+ function opencodePaths(input: InstallerInput): HostPaths {
15
+ 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") }
18
+ }
19
+
20
+ 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
+ if (header.abort) {
26
+ return { host: "opencode", scope: input.scope, tiers, edits: [], notes: header.notes, errors: header.errors }
27
+ }
28
+ const notes = [...header.notes]
29
+ const errors = [...header.errors]
30
+ const edits: FileEdit[] = []
31
+
32
+ if (input.search === "preferred") {
33
+ errors.push({
34
+ message:
35
+ "OpenCode search has no preferred tier: the websearch provider is host-level config, so the model never " +
36
+ "chooses between engines. Use --search optional (provider registered, built-in stays default), " +
37
+ "--search forced (replace the engine), or --search off.",
38
+ })
39
+ }
40
+
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) {
46
+ errors.push({
47
+ message:
48
+ `${paths.config} already has a plugins entry referencing deepseek-web-retrieval that this installer did not ` +
49
+ "write. Remove it (or run uninstall if it came from a previous init), then retry.",
50
+ })
51
+ }
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.")
54
+ }
55
+ if (errors.length > 0) {
56
+ return { host: "opencode", scope: input.scope, tiers, edits, notes, errors }
57
+ }
58
+
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
64
+
65
+ const ops: ConfigOp[] = [
66
+ {
67
+ op: "append-array",
68
+ path: ["plugins"],
69
+ entries: [{ package: input.packageRef, options }],
70
+ existingCount: plugins.length,
71
+ },
72
+ ]
73
+
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
79
+ if (permissions.length > 0) {
80
+ ops.push({ op: "append-array", path: ["permissions"], entries: permissions, existingCount: existingPermissions })
81
+ }
82
+
83
+ 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 })
86
+ notes.push(
87
+ currentProvider === undefined
88
+ ? "websearch.provider is being set to deepseek (no previous value existed)."
89
+ : `websearch.provider is being set to deepseek; the previous value "${String(currentProvider)}" is snapshotted for uninstall.`,
90
+ )
91
+ }
92
+
93
+ edits.push({ file: paths.config, format: "json", ops })
94
+
95
+ notes.push(
96
+ "The plugin entry uses the object form so options are honored; auto-discovered plugins cannot receive options.",
97
+ )
98
+ if (input.search === "optional") {
99
+ notes.push(
100
+ "Search tier optional: the deepseek provider is registered but the built-in provider stays the default engine; " +
101
+ "set websearch.provider to deepseek (or rerun with --search forced) to route search through DeepSeek.",
102
+ )
103
+ }
104
+ if (input.search === "forced" || input.fetch === "forced") {
105
+ notes.push("Forced tier replaces built-in routing. " + FORCED_COST_NOTE)
106
+ }
107
+
108
+ if (input.fetch === "preferred") {
109
+ edits.push({
110
+ file: paths.rules,
111
+ format: "rules",
112
+ 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).`)
115
+ }
116
+
117
+ return { host: "opencode", scope: input.scope, tiers, edits, notes, errors }
118
+ }
@@ -0,0 +1,115 @@
1
+ import { codecFor } from "../formats"
2
+ import { isPlainObject } from "../formats/value"
3
+ import {
4
+ FORCED_COST_NOTE,
5
+ othersStayRegisteredNote,
6
+ replacingExistingEntryNote,
7
+ 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. */
17
+ 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") }
21
+ }
22
+
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")
28
+ if (header.abort) {
29
+ return { host: "pi", scope: input.scope, tiers, edits: [], notes: header.notes, errors: header.errors }
30
+ }
31
+ const notes = [...header.notes]
32
+ const errors = [...header.errors]
33
+ const edits: FileEdit[] = []
34
+
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"
38
+ const entry = {
39
+ command: "npx",
40
+ args: ["-y", input.packageRef],
41
+ env,
42
+ exposure: "direct",
43
+ enabled: true,
44
+ }
45
+
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 })
52
+
53
+ 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"
57
+ 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
+ }
86
+ }
87
+ const stayRegistered = forced ? bystanders : [...competitors, ...bystanders]
88
+ if (stayRegistered.length > 0) notes.push(othersStayRegisteredNote(stayRegistered))
89
+ }
90
+ notes.push('"exposure": "direct" is required: Pi 1.3.0 defaults MCP tools to codemode, which hides them from the model.')
91
+ notes.push(
92
+ `\`${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
+ }
97
+
98
+ if (input.search === "preferred" || input.fetch === "preferred") {
99
+ edits.push({
100
+ file: paths.rules,
101
+ format: "rules",
102
+ 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.`)
105
+ }
106
+
107
+ if (input.search === "forced" || input.fetch === "forced") {
108
+ notes.push(
109
+ "Forced tier: Pi 1.3.0 ships no built-in web tool, so the registered tools are the only web path (de-facto). " +
110
+ FORCED_COST_NOTE,
111
+ )
112
+ }
113
+
114
+ return { host: "pi", scope: input.scope, tiers, edits, notes, errors }
115
+ }
@@ -0,0 +1,31 @@
1
+ import { BOTH_CAPABILITIES_OFF, competitorSteeringNote, findCompetitorSteering } from "../detect"
2
+ import type { LoadedConfig } from "../formats"
3
+ import type { HostPaths, InstallerInput, PlanError } from "../types"
4
+
5
+ /** What the shared plan header accumulated: notes and errors so far, and whether to stop. */
6
+ export interface PlanHeader {
7
+ readonly notes: string[]
8
+ readonly errors: PlanError[]
9
+ /** True when the caller must return the (empty-edits) plan as-is. */
10
+ readonly abort: boolean
11
+ }
12
+
13
+ /**
14
+ * The plan header every host planner starts with, so the three steps cannot drift:
15
+ * refuse an unparsable config (aborting), refuse both capabilities off, and surface
16
+ * competitor steering in the rules file. Host-specific checks follow in the module.
17
+ */
18
+ export function planHeader(input: InstallerInput, paths: HostPaths, loaded: LoadedConfig, dialect: string): PlanHeader {
19
+ const notes: string[] = []
20
+ const errors: PlanError[] = []
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 }
24
+ }
25
+ if (input.search === "off" && input.fetch === "off") {
26
+ errors.push({ message: BOTH_CAPABILITIES_OFF })
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 }
31
+ }
@@ -0,0 +1,18 @@
1
+ import type { FsRoots, Scope } from "./types"
2
+
3
+ /** Joins path parts with "/", skipping empty segments. */
4
+ export function join(...parts: readonly string[]): string {
5
+ return parts.filter((part) => part.length > 0).join("/")
6
+ }
7
+
8
+ /** Config home: an explicit root wins; otherwise ~/.config. The CLI resolves XDG at the edge. */
9
+ export function configHome(roots: FsRoots): string {
10
+ return roots.configHome ?? join(roots.home, ".config")
11
+ }
12
+
13
+ /** State records live with their scope: global installs under the config home, project installs in the checkout. */
14
+ export function stateDir(roots: FsRoots, scope: Scope): string {
15
+ return scope === "global"
16
+ ? join(configHome(roots), "deepseek-web-retrieval")
17
+ : join(roots.cwd, ".deepseek-web-retrieval")
18
+ }
@@ -0,0 +1,62 @@
1
+ import type { InstallPlan } from "./types"
2
+
3
+ /** Renders a plan for review: files, ops with old→new previews, and notes. */
4
+ export function renderPlan(plan: InstallPlan): string {
5
+ const lines: string[] = []
6
+ lines.push(`Install plan: ${plan.host} (${plan.scope} scope)`)
7
+ lines.push(`Tiers: search=${plan.tiers.search}, fetch=${plan.tiers.fetch}`)
8
+ lines.push("")
9
+ lines.push("Files:")
10
+ for (const edit of plan.edits) {
11
+ lines.push(` ${edit.file}`)
12
+ if (edit.format === "rules") {
13
+ lines.push(" upsert marked rules block")
14
+ continue
15
+ }
16
+ for (const op of edit.ops) {
17
+ if (op.op === "set") {
18
+ const was = op.previous === undefined ? "(absent)" : preview(op.previous)
19
+ lines.push(` set ${op.path.join(".")} = ${preview(op.value)} (was ${was})`)
20
+ } else {
21
+ const count = (op.entries ?? []).length
22
+ const kept = op.existingCount !== undefined ? ` (${op.existingCount} existing kept)` : ""
23
+ lines.push(` append ${count} entr${count === 1 ? "y" : "ies"} to ${op.path.join(".")}${kept}`)
24
+ for (const entry of op.entries ?? []) lines.push(` + ${preview(entry)}`)
25
+ }
26
+ }
27
+ }
28
+ if (plan.reverts && plan.reverts.length > 0) {
29
+ lines.push("")
30
+ lines.push("Reverts (artifacts from the previous init that this plan no longer wants):")
31
+ for (const revert of plan.reverts) {
32
+ lines.push(` ${revert.file}`)
33
+ if (revert.format === "rules") {
34
+ lines.push(" remove rules block")
35
+ continue
36
+ }
37
+ for (const op of revert.ops) {
38
+ if (op.op === "set") {
39
+ lines.push(
40
+ op.previous === undefined
41
+ ? ` remove ${op.path.join(".")} (was not set before this install)`
42
+ : ` restore ${op.path.join(".")} = ${preview(op.previous)}`,
43
+ )
44
+ } else {
45
+ const count = Array.isArray(op.applied) ? op.applied.length : 0
46
+ lines.push(` remove ${count} appended entr${count === 1 ? "y" : "ies"} from ${op.path.join(".")}`)
47
+ }
48
+ }
49
+ }
50
+ }
51
+ if (plan.notes.length > 0) {
52
+ lines.push("")
53
+ lines.push("Notes:")
54
+ for (const note of plan.notes) lines.push(` - ${note}`)
55
+ }
56
+ return lines.join("\n")
57
+ }
58
+
59
+ function preview(value: unknown): string {
60
+ const text = JSON.stringify(value) ?? String(value)
61
+ return text.length > 100 ? `${text.slice(0, 97)}...` : text
62
+ }
@@ -0,0 +1,61 @@
1
+ import { existsSync, readFileSync, rmdirSync, unlinkSync } from "node:fs"
2
+ import { dirname } from "node:path"
3
+ import { codecFor, writeConfig } from "./formats"
4
+ import type { HostName, RecordedFile, RulesEdit } from "./types"
5
+
6
+ /** Removes a directory only when it is empty, so pre-existing dirs with content stay. */
7
+ export function pruneEmptyDir(dir: string): void {
8
+ try {
9
+ rmdirSync(dir)
10
+ } catch {
11
+ // Not empty or already gone — nothing to prune.
12
+ }
13
+ }
14
+
15
+ /**
16
+ * The single per-file revert, shared by uninstall (whole files, no label) and by
17
+ * a superseding plan's orphan reverts (label names the previous init; `whole` is
18
+ * false while the new plan still claims the file, so partial reverts never delete).
19
+ * Per-format work belongs to the codecs; this skeleton owns the early-outs, the
20
+ * deletion decisions, the pruning, and the messages.
21
+ */
22
+ export function revertRecordedFile(file: RecordedFile, host: HostName, opts: { label: string; whole: boolean }): string[] {
23
+ const lines: string[] = []
24
+ if (!existsSync(file.file)) {
25
+ lines.push(`already absent: ${file.file}${opts.label}`)
26
+ return lines
27
+ }
28
+ const codec = codecFor(file.format, host)
29
+ const text = readFileSync(file.file, "utf8")
30
+ if (file.format === "rules") {
31
+ const edit: RulesEdit = { file: file.file, format: "rules", block: file.blockApplied ?? "" }
32
+ if (!codec.verify(text, edit)) {
33
+ lines.push(`no rules block${opts.label} in ${file.file}; left untouched`)
34
+ return lines
35
+ }
36
+ const updated = codec.revert(text, file).text
37
+ // Empty after removal proves every line in the file was an installer block;
38
+ // the whole-file gate keeps a still-claimed file safe even if a future
39
+ // planner ever produced a partial rules revert.
40
+ if (opts.whole && codec.isEffectivelyEmpty(updated)) {
41
+ unlinkSync(file.file)
42
+ pruneEmptyDir(dirname(file.file))
43
+ lines.push(`deleted ${file.file} (installer-created, now empty)`)
44
+ } else {
45
+ writeConfig(file.file, updated)
46
+ lines.push(`removed rules block${opts.label} from ${file.file}`)
47
+ }
48
+ return lines
49
+ }
50
+ const reverted = codec.revert(text, file)
51
+ if (opts.whole && file.created && codec.isEffectivelyEmpty(reverted.text)) {
52
+ unlinkSync(file.file)
53
+ pruneEmptyDir(dirname(file.file))
54
+ lines.push(`deleted ${file.file} (installer-created, now empty)`)
55
+ } else {
56
+ writeConfig(file.file, reverted.text)
57
+ lines.push(`reverted artifacts${opts.label} in ${file.file}`)
58
+ }
59
+ lines.push(...reverted.notes.map((note) => ` note: ${note}`))
60
+ return lines
61
+ }