deepseek-web-retrieval 0.4.0 → 0.5.1
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 +99 -244
- package/README.md +16 -11
- package/dist/cli.js +1366 -615
- package/docs/agents/codex.md +28 -7
- package/docs/agents/opencode-v2.md +1 -1
- package/docs/agents/pi.md +88 -86
- package/docs/design/installer-design.md +72 -9
- package/docs/hosts.jsonc +2 -2
- package/docs/integration.md +14 -9
- package/docs/research/control-matrix.md +48 -13
- package/docs/research/mcp-compatibility.md +9 -4
- package/examples/pi.settings.jsonc +17 -0
- package/index.ts +2 -2
- package/package.json +35 -4
- package/src/core/auth.ts +4 -6
- package/src/core/client.ts +65 -53
- package/src/core/constants.ts +24 -24
- package/src/core/descriptions.ts +30 -6
- package/src/core/domains.ts +56 -67
- package/src/core/env.ts +112 -0
- package/src/core/format.ts +53 -50
- package/src/core/index.ts +9 -9
- package/src/core/jsonc.ts +6 -6
- package/src/core/object.ts +4 -0
- package/src/core/options.ts +20 -25
- package/src/core/search.ts +45 -44
- package/src/core/transport.ts +109 -113
- package/src/core/types.ts +47 -47
- package/src/core/url.ts +20 -20
- package/src/installer/apply.ts +256 -81
- package/src/installer/cli.ts +135 -104
- package/src/installer/detect.ts +46 -31
- package/src/installer/formats/index.ts +26 -28
- package/src/installer/formats/json.ts +81 -77
- package/src/installer/formats/rules.ts +55 -28
- package/src/installer/formats/toml.ts +88 -81
- package/src/installer/formats/value.ts +34 -30
- package/src/installer/hosts/codex.ts +100 -54
- package/src/installer/hosts/index.ts +126 -41
- package/src/installer/hosts/opencode.ts +70 -51
- package/src/installer/hosts/pi.ts +269 -75
- package/src/installer/hosts/shared.ts +19 -14
- package/src/installer/operation-lock.ts +54 -0
- package/src/installer/ownership.ts +129 -0
- package/src/installer/paths.ts +7 -5
- package/src/installer/persistence.ts +66 -0
- package/src/installer/render.ts +29 -29
- package/src/installer/revert.ts +33 -29
- package/src/installer/rulesContent.ts +24 -13
- package/src/installer/state.ts +116 -31
- package/src/installer/types.ts +74 -56
- package/src/installer/uninstall.ts +40 -17
- package/src/mcp/auth.ts +7 -8
- package/src/mcp/cli.ts +38 -20
- package/src/mcp/client.ts +35 -0
- package/src/mcp/server.ts +68 -97
- package/src/opencode/auth.ts +24 -26
- package/src/opencode/plugin.ts +99 -87
- package/src/pi/extension.ts +164 -0
- package/src/pi/settings.ts +49 -0
- package/src/mcp/config.ts +0 -112
|
@@ -1,33 +1,37 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { rulesLines } from "../rulesContent"
|
|
7
|
-
import {
|
|
8
|
-
import type { ConfigOp, FileEdit, HostPaths,
|
|
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
|
-
? {
|
|
17
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
)
|
|
45
|
-
|
|
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 (
|
|
53
|
-
notes.push(
|
|
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(
|
|
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 {
|
|
2
|
-
import {
|
|
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 {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
29
|
-
if (
|
|
30
|
-
|
|
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
|
}
|