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.
- package/CHANGELOG.md +187 -1
- package/README.md +13 -9
- package/dist/cli.js +4228 -178
- package/docs/adr/0001-refuse-drift-between-plan-and-apply.md +29 -0
- package/docs/agents/codex.md +193 -0
- package/docs/agents/domain.md +51 -0
- package/docs/agents/issue-tracker.md +45 -0
- package/docs/{opencode-v2.md → agents/opencode-v2.md} +30 -7
- package/docs/{pi.md → agents/pi.md} +35 -7
- package/docs/agents/triage-labels.md +15 -0
- package/docs/design/installer-design.md +184 -0
- package/docs/hosts.jsonc +59 -33
- package/docs/integration.md +45 -52
- package/docs/research/control-matrix.md +257 -0
- package/docs/{mcp-compatibility.md → research/mcp-compatibility.md} +39 -108
- package/package.json +5 -3
- package/src/core/auth.ts +1 -1
- package/src/core/client.ts +42 -249
- package/src/core/constants.ts +5 -0
- package/src/core/descriptions.ts +19 -0
- package/src/core/format.ts +31 -1
- package/src/core/jsonc.ts +16 -0
- package/src/core/options.ts +2 -3
- package/src/core/search.ts +5 -7
- package/src/core/transport.ts +253 -0
- package/src/core/types.ts +13 -11
- package/src/installer/apply.ts +176 -0
- package/src/installer/cli.ts +215 -0
- package/src/installer/detect.ts +85 -0
- package/src/installer/formats/index.ts +58 -0
- package/src/installer/formats/json.ts +151 -0
- package/src/installer/formats/rules.ts +62 -0
- package/src/installer/formats/toml.ts +163 -0
- package/src/installer/formats/value.ts +57 -0
- package/src/installer/hosts/codex.ts +128 -0
- package/src/installer/hosts/index.ts +134 -0
- package/src/installer/hosts/opencode.ts +118 -0
- package/src/installer/hosts/pi.ts +115 -0
- package/src/installer/hosts/shared.ts +31 -0
- package/src/installer/paths.ts +18 -0
- package/src/installer/render.ts +62 -0
- package/src/installer/revert.ts +61 -0
- package/src/installer/rulesContent.ts +32 -0
- package/src/installer/state.ts +63 -0
- package/src/installer/types.ts +126 -0
- package/src/installer/uninstall.ts +33 -0
- package/src/mcp/auth.ts +2 -1
- package/src/mcp/cli.ts +21 -5
- package/src/mcp/config.ts +70 -27
- package/src/mcp/server.ts +81 -65
- package/src/opencode/auth.ts +3 -2
- package/src/opencode/plugin.ts +93 -56
- 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
|
+
}
|