deepseek-web-retrieval 0.6.0 → 0.8.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 +23 -1
- package/README.md +113 -53
- package/dist/cli.js +2427 -1813
- package/docs/adr/0002-shared-config-base-layer.md +43 -0
- package/docs/agents/claude-code.md +273 -0
- package/docs/agents/codex.md +50 -27
- package/docs/agents/opencode-v2.md +59 -33
- package/docs/agents/pi.md +50 -27
- package/docs/configuration.md +109 -0
- package/docs/design/config-layering-design.md +180 -0
- package/docs/design/installer-design.md +24 -13
- package/docs/hosts.jsonc +5 -1
- package/docs/integration.md +15 -9
- package/docs/research/control-matrix.md +49 -11
- package/docs/research/mcp-compatibility.md +6 -3
- package/docs/research/ori-question-sourcing.md +336 -0
- package/docs/research/result-quality-passthrough.md +159 -0
- package/docs/research/search-api-defaults.md +18 -2
- package/docs/research/search-inline-content.md +244 -0
- package/examples/opencode.global.jsonc +3 -2
- package/package.json +2 -1
- package/src/core/auth.ts +3 -0
- package/src/core/client.ts +26 -4
- package/src/core/config-file.ts +168 -0
- package/src/core/config-summary.ts +82 -0
- package/src/core/constants.ts +15 -1
- package/src/core/descriptions.ts +6 -2
- package/src/core/env.ts +102 -32
- package/src/core/format.ts +19 -1
- package/src/core/options.ts +22 -2
- package/src/core/search.ts +6 -0
- package/src/core/types.ts +11 -0
- package/src/installer/cli.ts +50 -16
- package/src/installer/config-command.ts +116 -0
- package/src/installer/hosts/claude-code.ts +325 -0
- package/src/installer/hosts/codex.ts +9 -4
- package/src/installer/hosts/index.ts +3 -0
- package/src/installer/hosts/opencode.ts +9 -5
- package/src/installer/hosts/pi.ts +34 -9
- package/src/installer/ownership.ts +54 -1
- package/src/installer/paths.ts +7 -1
- package/src/installer/state.ts +73 -20
- package/src/installer/types.ts +11 -1
- package/src/installer/uninstall.ts +4 -1
- package/src/mcp/auth.ts +2 -3
- package/src/mcp/cli.ts +4 -3
- package/src/mcp/serve.ts +10 -1
- package/src/mcp/server.ts +7 -2
- package/src/opencode/plugin.ts +22 -2
- package/src/pi/extension.ts +78 -9
package/src/installer/cli.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
|
+
import { userConfigHome } from "../core/config-file";
|
|
4
|
+
import type { Env } from "../core/env";
|
|
3
5
|
import { TOOL_VERSION } from "../version";
|
|
4
6
|
import { ApplyError, applyPlan } from "./apply";
|
|
7
|
+
import { runConfigCommand } from "./config-command";
|
|
5
8
|
import { HOST_PLANNERS, HOST_VERSION_PROBES, planForHost } from "./hosts";
|
|
9
|
+
import { pinPackageRef } from "./ownership";
|
|
6
10
|
import { renderPlan } from "./render";
|
|
7
|
-
import { listRecords } from "./state";
|
|
11
|
+
import { legacyGlobalStateDir, listRecords, readingLegacyGlobalRecords } from "./state";
|
|
8
12
|
import type { HostName, InstallerInput, Scope, Tier } from "./types";
|
|
9
13
|
import { uninstall } from "./uninstall";
|
|
10
14
|
|
|
@@ -20,9 +24,10 @@ const EXIT_RECOVERY = 3;
|
|
|
20
24
|
const USAGE = `Usage:
|
|
21
25
|
deepseek-web-retrieval init --host ${HOST_CHOICES} --scope <project|global> \
|
|
22
26
|
--search <optional|preferred|forced|off> --fetch <optional|preferred|forced|off> \
|
|
23
|
-
[--path <extension|mcp>] [--package <npm ref>] [--cwd <dir>] [--apply]
|
|
27
|
+
[--path <extension|mcp>] [--package <npm ref>] [--pin] [--cwd <dir>] [--apply]
|
|
24
28
|
deepseek-web-retrieval uninstall --host ${HOST_CHOICES} --scope <project|global> [--cwd <dir>]
|
|
25
29
|
deepseek-web-retrieval list [--cwd <dir>]
|
|
30
|
+
deepseek-web-retrieval config [--search <query>]
|
|
26
31
|
deepseek-web-retrieval version
|
|
27
32
|
|
|
28
33
|
init prints the full plan (every file, old value → new value) and writes nothing
|
|
@@ -31,7 +36,17 @@ the integration mechanism for hosts with more than one (Pi: extension by
|
|
|
31
36
|
default, mcp for the stdio MCP server). --package overrides the package ref
|
|
32
37
|
written into host config; the Pi extension path turns it into the packages
|
|
33
38
|
source (a bare name is prefixed with npm:, a ref that already starts with npm:
|
|
34
|
-
is written as-is).
|
|
39
|
+
is written as-is). --pin appends the running CLI's version to the package ref
|
|
40
|
+
(a ref already ending in a version keeps it), so hosts resolve that exact
|
|
41
|
+
release instead of the latest publish; re-run init --pin to move the pin.
|
|
42
|
+
|
|
43
|
+
config prints the effective configuration — every knob with its value and its
|
|
44
|
+
source (default, shared file, or environment), resolved the way the MCP server
|
|
45
|
+
resolves it — and exits 1 when the config file or the DEEPSEEK_* environment
|
|
46
|
+
has problems (unknown or rejected keys, unparseable JSON, set-but-unusable
|
|
47
|
+
variable values; a missing file is fine). config --search <query>
|
|
48
|
+
runs one billed search through that configuration to verify the key, config,
|
|
49
|
+
and network end to end; it exits 1 when the key is missing or the search fails.
|
|
35
50
|
|
|
36
51
|
Exit codes: 0 success; 1 refused or failed with nothing written (a failed apply
|
|
37
52
|
is fully rolled back); 2 invalid command line; 3 recovery required — a failed
|
|
@@ -45,7 +60,7 @@ interface Flags {
|
|
|
45
60
|
}
|
|
46
61
|
|
|
47
62
|
const VALUE_FLAGS = new Set(["host", "scope", "search", "fetch", "path", "package", "cwd"]);
|
|
48
|
-
const BOOLEAN_FLAGS = new Set(["apply"]);
|
|
63
|
+
const BOOLEAN_FLAGS = new Set(["apply", "pin"]);
|
|
49
64
|
const PATH_CHOICES: readonly ["extension", "mcp"] = ["extension", "mcp"];
|
|
50
65
|
|
|
51
66
|
function parseFlags(args: readonly string[]): Flags {
|
|
@@ -99,6 +114,8 @@ function parseFlags(args: readonly string[]): Flags {
|
|
|
99
114
|
export interface CliIo {
|
|
100
115
|
readonly out?: (line: string) => void;
|
|
101
116
|
readonly err?: (line: string) => void;
|
|
117
|
+
/** Platform the CLI resolves paths for; tests pin "win32" on any host. */
|
|
118
|
+
readonly platform?: NodeJS.Platform;
|
|
102
119
|
}
|
|
103
120
|
|
|
104
121
|
/** CLI entry for the init/uninstall/list subcommands; returns the process exit code. */
|
|
@@ -126,12 +143,14 @@ export async function runInstallerCli(argv: readonly string[], io: CliIo = {}):
|
|
|
126
143
|
}
|
|
127
144
|
const cwd = resolve(flags.values.get("cwd") ?? process.cwd());
|
|
128
145
|
const home = process.env.HOME ?? homedir();
|
|
129
|
-
const
|
|
146
|
+
const platform = io.platform ?? process.platform;
|
|
147
|
+
const roots = { home, cwd, platform } as const;
|
|
148
|
+
const configHome = resolveConfigHome(process.env, home, platform);
|
|
130
149
|
|
|
131
150
|
if (command === "list") {
|
|
132
151
|
let records: ReturnType<typeof listRecords>;
|
|
133
152
|
try {
|
|
134
|
-
records = listRecords({
|
|
153
|
+
records = listRecords({ ...roots, configHome });
|
|
135
154
|
} catch (error) {
|
|
136
155
|
err(error instanceof Error ? error.message : String(error));
|
|
137
156
|
return 1;
|
|
@@ -140,6 +159,12 @@ export async function runInstallerCli(argv: readonly string[], io: CliIo = {}):
|
|
|
140
159
|
out("No installs recorded.");
|
|
141
160
|
return 0;
|
|
142
161
|
}
|
|
162
|
+
if (readingLegacyGlobalRecords({ ...roots, configHome })) {
|
|
163
|
+
out(
|
|
164
|
+
`Global install records are being read from the legacy ${legacyGlobalStateDir(roots)} ` +
|
|
165
|
+
"(written before the Windows config home moved to %APPDATA%).",
|
|
166
|
+
);
|
|
167
|
+
}
|
|
143
168
|
for (const record of records) {
|
|
144
169
|
const by = record.installerVersion ? ` by v${record.installerVersion}` : "";
|
|
145
170
|
out(
|
|
@@ -150,8 +175,12 @@ export async function runInstallerCli(argv: readonly string[], io: CliIo = {}):
|
|
|
150
175
|
return 0;
|
|
151
176
|
}
|
|
152
177
|
|
|
178
|
+
if (command === "config") {
|
|
179
|
+
return runConfigCommand(flags.values.get("search"), { out, err });
|
|
180
|
+
}
|
|
181
|
+
|
|
153
182
|
if (command === "uninstall") {
|
|
154
|
-
const required = requireInput(flags, cwd, home, configHome, false);
|
|
183
|
+
const required = requireInput(flags, cwd, home, configHome, platform, false);
|
|
155
184
|
if (!required.ok) return usageError(required.problems, err);
|
|
156
185
|
try {
|
|
157
186
|
for (const line of uninstall(required.input)) out(line);
|
|
@@ -167,7 +196,7 @@ export async function runInstallerCli(argv: readonly string[], io: CliIo = {}):
|
|
|
167
196
|
return EXIT_USAGE;
|
|
168
197
|
}
|
|
169
198
|
|
|
170
|
-
const required = requireInput(flags, cwd, home, configHome, true);
|
|
199
|
+
const required = requireInput(flags, cwd, home, configHome, platform, true);
|
|
171
200
|
if (!required.ok) return usageError(required.problems, err);
|
|
172
201
|
const { input } = required;
|
|
173
202
|
const probe = HOST_VERSION_PROBES[input.host];
|
|
@@ -205,13 +234,15 @@ function usageError(problems: readonly string[], err: (line: string) => void): n
|
|
|
205
234
|
}
|
|
206
235
|
|
|
207
236
|
/**
|
|
208
|
-
*
|
|
209
|
-
*
|
|
237
|
+
* The installer edge's one config-home resolution, built on the canonical
|
|
238
|
+
* helper (userConfigHome): an inherited XDG_CONFIG_HOME is honored only when it
|
|
239
|
+
* belongs to the real home — a rewritten HOME must not smuggle in someone
|
|
240
|
+
* else's config root — and every other case takes the helper's platform
|
|
241
|
+
* default (win32: %APPDATA% or `<home>\AppData\Roaming`; otherwise ~/.config).
|
|
210
242
|
*/
|
|
211
|
-
function resolveConfigHome(home: string): string
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
return undefined;
|
|
243
|
+
export function resolveConfigHome(env: Env, home: string, platform: NodeJS.Platform = process.platform): string {
|
|
244
|
+
const xdgTrusted = env.XDG_CONFIG_HOME !== undefined && env.XDG_CONFIG_HOME !== "" && home === (env.HOME ?? "");
|
|
245
|
+
return userConfigHome({ env: xdgTrusted ? env : { ...env, XDG_CONFIG_HOME: undefined }, home, platform });
|
|
215
246
|
}
|
|
216
247
|
|
|
217
248
|
type RequireInputResult =
|
|
@@ -222,7 +253,8 @@ function requireInput(
|
|
|
222
253
|
flags: Flags,
|
|
223
254
|
cwd: string,
|
|
224
255
|
home: string,
|
|
225
|
-
configHome: string
|
|
256
|
+
configHome: string,
|
|
257
|
+
platform: NodeJS.Platform,
|
|
226
258
|
needsTiers: boolean,
|
|
227
259
|
): RequireInputResult {
|
|
228
260
|
const problems: string[] = [];
|
|
@@ -262,6 +294,7 @@ function requireInput(
|
|
|
262
294
|
}
|
|
263
295
|
if (home === "") problems.push("HOME is not set and the user home cannot be resolved; cannot proceed");
|
|
264
296
|
if (problems.length > 0) return { ok: false, problems };
|
|
297
|
+
const packageRef = flags.values.get("package") ?? "deepseek-web-retrieval";
|
|
265
298
|
return {
|
|
266
299
|
ok: true,
|
|
267
300
|
input: {
|
|
@@ -269,10 +302,11 @@ function requireInput(
|
|
|
269
302
|
scope: scope as Scope,
|
|
270
303
|
search: search as Tier,
|
|
271
304
|
fetch: fetch as Tier,
|
|
272
|
-
packageRef: flags.
|
|
305
|
+
packageRef: flags.booleans.has("pin") ? pinPackageRef(packageRef, TOOL_VERSION) : packageRef,
|
|
273
306
|
home,
|
|
274
307
|
cwd,
|
|
275
308
|
configHome,
|
|
309
|
+
platform,
|
|
276
310
|
...(path !== undefined ? { path: path as "extension" | "mcp" } : {}),
|
|
277
311
|
},
|
|
278
312
|
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { apiKeyFromEnv, MISSING_API_KEY_MESSAGE } from "../core/auth";
|
|
2
|
+
import { createRetrievalClient, type RetrievalClient } from "../core/client";
|
|
3
|
+
import { configFilePath, readFileOptionsReport } from "../core/config-file";
|
|
4
|
+
import { configSummaryValues, formatConfigValue } from "../core/config-summary";
|
|
5
|
+
import type { Env } from "../core/env";
|
|
6
|
+
import { envOptionsReport, resolveEnvOptions } from "../core/env";
|
|
7
|
+
import { formatSearchResults } from "../core/format";
|
|
8
|
+
|
|
9
|
+
const EXIT_CONFIG_PROBLEMS = 1;
|
|
10
|
+
const EXIT_USAGE = 2;
|
|
11
|
+
|
|
12
|
+
const HOST_OVERRIDE_NOTE =
|
|
13
|
+
"Host-native layers (OpenCode options, Pi settings) may override file and env values per host.";
|
|
14
|
+
|
|
15
|
+
/** Where the config command writes; the installer CLI resolves its CliIo into this. */
|
|
16
|
+
export interface ConfigCommandIo {
|
|
17
|
+
readonly out: (line: string) => void;
|
|
18
|
+
readonly err: (line: string) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Injectable seams: the environment to resolve, and the client for --search. */
|
|
22
|
+
export interface ConfigCommandDeps {
|
|
23
|
+
readonly env?: Env;
|
|
24
|
+
readonly client?: RetrievalClient;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The `config` subcommand: the user-facing arm of configuration visibility.
|
|
29
|
+
* Prints the effective options exactly as the MCP server would resolve them
|
|
30
|
+
* (defaults < shared file < environment), each knob with its value and source,
|
|
31
|
+
* and replays the problems that would only whisper past on stderr at runtime —
|
|
32
|
+
* shared-file problems and set-but-unusable `DEEPSEEK_*` variables alike. With
|
|
33
|
+
* `query` set, runs one real billed search through that config — the
|
|
34
|
+
* end-to-end verifier for key, config, and network. Returns the exit code: 0
|
|
35
|
+
* normally, 1 for file or environment problems (plain form) or a failed
|
|
36
|
+
* search, 2 for a blank --search query. A missing file is not a problem.
|
|
37
|
+
*/
|
|
38
|
+
export async function runConfigCommand(
|
|
39
|
+
query: string | undefined,
|
|
40
|
+
io: ConfigCommandIo,
|
|
41
|
+
deps: ConfigCommandDeps = {},
|
|
42
|
+
): Promise<number> {
|
|
43
|
+
if (query !== undefined && !query.trim()) {
|
|
44
|
+
io.err('error: --search requires a query (for example: config --search "deepseek mcp")');
|
|
45
|
+
return EXIT_USAGE;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const env = deps.env ?? process.env;
|
|
49
|
+
const file = configFilePath({ env });
|
|
50
|
+
const report = readFileOptionsReport(file);
|
|
51
|
+
for (const warning of report.warnings) io.err(`warning: ${warning}`);
|
|
52
|
+
const envReport = envOptionsReport(env);
|
|
53
|
+
for (const warning of envReport.warnings) io.err(`warning: ${warning}`);
|
|
54
|
+
|
|
55
|
+
// The same layers, in the same order, as the MCP server's startup resolution.
|
|
56
|
+
const resolved = resolveEnvOptions(env, report.options);
|
|
57
|
+
|
|
58
|
+
if (query === undefined) {
|
|
59
|
+
printConfigReport(file, report.exists, report.options, envReport.options, resolved, io);
|
|
60
|
+
return report.warnings.length > 0 || envReport.warnings.length > 0 ? EXIT_CONFIG_PROBLEMS : 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const apiKey = apiKeyFromEnv(env);
|
|
64
|
+
if (!apiKey) {
|
|
65
|
+
io.err(MISSING_API_KEY_MESSAGE);
|
|
66
|
+
return EXIT_CONFIG_PROBLEMS;
|
|
67
|
+
}
|
|
68
|
+
const client = deps.client ?? createRetrievalClient();
|
|
69
|
+
const startedAt = Date.now();
|
|
70
|
+
try {
|
|
71
|
+
const results = await client.searchWeb(query, resolved.search, {
|
|
72
|
+
signal: new AbortController().signal,
|
|
73
|
+
auth: { apiKey },
|
|
74
|
+
retry: resolved.retry,
|
|
75
|
+
});
|
|
76
|
+
const elapsedMs = Date.now() - startedAt;
|
|
77
|
+
io.out(
|
|
78
|
+
`search "${query.trim()}" — ${results.length} ${results.length === 1 ? "result" : "results"} in ${elapsedMs} ms`,
|
|
79
|
+
);
|
|
80
|
+
io.out("");
|
|
81
|
+
for (const line of formatSearchResults(results).split("\n")) io.out(line);
|
|
82
|
+
return 0;
|
|
83
|
+
} catch (error) {
|
|
84
|
+
io.err(error instanceof Error ? error.message : String(error));
|
|
85
|
+
return EXIT_CONFIG_PROBLEMS;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The knob table, in the vocabulary order the startup deviation summary uses. */
|
|
90
|
+
function printConfigReport(
|
|
91
|
+
file: string,
|
|
92
|
+
exists: boolean,
|
|
93
|
+
fileOptions: Readonly<Record<string, unknown>>,
|
|
94
|
+
envOptions: Readonly<Record<string, unknown>>,
|
|
95
|
+
resolved: ReturnType<typeof resolveEnvOptions>,
|
|
96
|
+
io: ConfigCommandIo,
|
|
97
|
+
): void {
|
|
98
|
+
io.out(`Config file: ${file} (${exists ? "exists" : "missing"})`);
|
|
99
|
+
io.out("");
|
|
100
|
+
const effective = configSummaryValues(resolved);
|
|
101
|
+
const fileKeys = new Set(Object.keys(fileOptions));
|
|
102
|
+
const envKeys = new Set(Object.keys(envOptions));
|
|
103
|
+
for (const name of Object.keys(effective)) {
|
|
104
|
+
// The environment wins over the file for the same key, exactly as resolution does.
|
|
105
|
+
let source = envKeys.has(name) ? "env" : fileKeys.has(name) ? "file" : "default";
|
|
106
|
+
// maxResults follows the effective count when unset (options.ts), so when
|
|
107
|
+
// the count knob was configured its derived value is not "default" — its
|
|
108
|
+
// source is the count setting that drove it.
|
|
109
|
+
if (name === "maxResults" && source === "default" && (envKeys.has("count") || fileKeys.has("count"))) {
|
|
110
|
+
source = "count";
|
|
111
|
+
}
|
|
112
|
+
io.out(`${name} = ${formatConfigValue(effective[name], "join")} (${source})`);
|
|
113
|
+
}
|
|
114
|
+
io.out("");
|
|
115
|
+
io.out(HOST_OVERRIDE_NOTE);
|
|
116
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { API_KEY_ENV } from "../../core/constants";
|
|
4
|
+
import { FORCED_COST_NOTE, othersStayRegisteredNote, replacingExistingEntryNote, splitCompetitors } from "../detect";
|
|
5
|
+
import { codecFor, type LoadedConfig } from "../formats";
|
|
6
|
+
import { buildRulesBlock } from "../formats/rules";
|
|
7
|
+
import { isPlainObject } from "../formats/value";
|
|
8
|
+
import { join } from "../paths";
|
|
9
|
+
import { MCP_TOOL_IDS, rulesLines } from "../rulesContent";
|
|
10
|
+
import type { ConfigOp, FileEdit, HostPaths, InstallerInput, InstallPlan, InstallRecord } from "../types";
|
|
11
|
+
import { planHeader } from "./shared";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The Claude Code release this installer's facts are verified against — the lock in
|
|
15
|
+
* docs/hosts.jsonc; the drift note fires when the probed CLI differs.
|
|
16
|
+
*/
|
|
17
|
+
export const CLAUDE_CODE_TESTED_VERSION = "2.1.303";
|
|
18
|
+
|
|
19
|
+
/** The built-in web tools the forced tier denies; bare names remove them from context. */
|
|
20
|
+
const BUILTIN_TOOL_IDS = { search: "WebSearch", fetch: "WebFetch" } as const;
|
|
21
|
+
|
|
22
|
+
/** Human name for a wrong-typed config value in plan errors ("an array", "a string"). */
|
|
23
|
+
function valueKind(value: unknown): string {
|
|
24
|
+
return Array.isArray(value) ? "an array" : `a ${typeof value}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The one subprocess the installer runs: `claude --version`, probed for the
|
|
29
|
+
* version-drift note. A missing binary or unreadable output skips the check with
|
|
30
|
+
* a stderr note and never blocks the plan — honor or report.
|
|
31
|
+
*/
|
|
32
|
+
export function probeClaudeCodeVersion(err: (line: string) => void): string | undefined {
|
|
33
|
+
// env passed explicitly: runtime PATH edits (tests) must reach the lookup.
|
|
34
|
+
// shell: npm's global install on Windows provides only a claude.cmd shim,
|
|
35
|
+
// which spawnSync cannot launch without a shell; the command and args are
|
|
36
|
+
// hardcoded, so the shell hop is safe.
|
|
37
|
+
const result = spawnSync("claude", ["--version"], { timeout: 5000, encoding: "utf8", env: process.env, shell: true });
|
|
38
|
+
const version = result.status === 0 ? parseClaudeCodeVersion(result.stdout ?? "") : undefined;
|
|
39
|
+
if (version === undefined) {
|
|
40
|
+
err("note: claude was not found on PATH or printed no version; the version-drift check was skipped");
|
|
41
|
+
}
|
|
42
|
+
return version;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Parses `claude --version` output (`2.1.303 (Claude Code)`) into the bare version.
|
|
47
|
+
* The last semver wins: an update banner advertising a newer release must not
|
|
48
|
+
* anchor the drift note on the wrong number.
|
|
49
|
+
*/
|
|
50
|
+
export function parseClaudeCodeVersion(output: string): string | undefined {
|
|
51
|
+
return [...output.matchAll(/\b\d+\.\d+\.\d+\b/g)].map((match) => match[0]).at(-1);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Claude Code config, settings, and rules files per scope; the host module owns
|
|
56
|
+
* this mapping. MCP registration and the settings file are disjoint per scope
|
|
57
|
+
* (project: `.mcp.json` + `.claude/settings.json`; global: `~/.claude.json` +
|
|
58
|
+
* `~/.claude/settings.json`), so the two scopes can coexist unlike Codex's.
|
|
59
|
+
*/
|
|
60
|
+
function claudeCodePaths(input: InstallerInput): HostPaths & { readonly settings: string } {
|
|
61
|
+
return input.scope === "global"
|
|
62
|
+
? {
|
|
63
|
+
config: join(input.home, ".claude.json"),
|
|
64
|
+
settings: join(input.home, ".claude", "settings.json"),
|
|
65
|
+
rules: join(input.home, ".claude", "CLAUDE.md"),
|
|
66
|
+
}
|
|
67
|
+
: {
|
|
68
|
+
config: join(input.cwd, ".mcp.json"),
|
|
69
|
+
settings: join(input.cwd, ".claude", "settings.json"),
|
|
70
|
+
rules: join(input.cwd, "CLAUDE.md"),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The managed-policy layer, the one settings layer above the user. Its existence
|
|
76
|
+
* (never its contents) is reported: deny and allow rules there outrank anything
|
|
77
|
+
* this plan writes at either scope.
|
|
78
|
+
*/
|
|
79
|
+
function managedSettingsPath(input: InstallerInput): string {
|
|
80
|
+
const platform = input.platform ?? process.platform;
|
|
81
|
+
if (platform === "darwin") return "/Library/Application Support/ClaudeCode/managed-settings.json";
|
|
82
|
+
if (platform === "win32") return "C:\\Program Files\\ClaudeCode\\managed-settings.json";
|
|
83
|
+
return "/etc/claude-code/managed-settings.json";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function planClaudeCode(input: InstallerInput, _previous: InstallRecord | undefined): InstallPlan {
|
|
87
|
+
const paths = claudeCodePaths(input);
|
|
88
|
+
const loaded = codecFor("json", input.host).load(paths.config);
|
|
89
|
+
const tiers = { search: input.search, fetch: input.fetch };
|
|
90
|
+
const header = planHeader(input, paths, loaded, "JSON");
|
|
91
|
+
if (header.abort) {
|
|
92
|
+
return { host: "claude-code", scope: input.scope, tiers, edits: [], notes: header.notes, errors: header.errors };
|
|
93
|
+
}
|
|
94
|
+
const notes = [...header.notes];
|
|
95
|
+
const errors = [...header.errors];
|
|
96
|
+
const edits: FileEdit[] = [];
|
|
97
|
+
|
|
98
|
+
if (input.hostVersion !== undefined && input.hostVersion !== CLAUDE_CODE_TESTED_VERSION) {
|
|
99
|
+
notes.push(
|
|
100
|
+
`Claude Code ${input.hostVersion} is installed, but this installer's Claude Code facts are verified against ` +
|
|
101
|
+
`${CLAUDE_CODE_TESTED_VERSION} (the docs/hosts.jsonc lock). Deny-rule and tool-visibility behavior may have ` +
|
|
102
|
+
"drifted between the two — re-verify the tiers if routing misbehaves.",
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
const managed = managedSettingsPath(input);
|
|
106
|
+
if (existsSync(managed)) {
|
|
107
|
+
notes.push(
|
|
108
|
+
`${managed} exists: the managed policy layer outranks every settings file this plan writes, so a deny or allow ` +
|
|
109
|
+
"there overrides these changes. Check with your administrator if routing does not take effect.",
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// The server registration. `${VAR}` expansion is Claude Code's own mechanism, so
|
|
114
|
+
// the key expands from the environment that launches Claude Code and never sits
|
|
115
|
+
// in the file.
|
|
116
|
+
const servers = loaded.value.mcpServers;
|
|
117
|
+
if (servers !== undefined && !isPlainObject(servers)) {
|
|
118
|
+
errors.push({
|
|
119
|
+
message: `${paths.config}: mcpServers is ${valueKind(servers)}, not an object; fix it before installing.`,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
const existingEntry = isPlainObject(servers) ? servers.deepseek : undefined;
|
|
123
|
+
const entry = {
|
|
124
|
+
command: "npx",
|
|
125
|
+
args: ["-y", input.packageRef],
|
|
126
|
+
env: { [API_KEY_ENV]: `\${${API_KEY_ENV}}` },
|
|
127
|
+
};
|
|
128
|
+
const ops: ConfigOp[] = [{ op: "set", path: ["mcpServers", "deepseek"], value: entry, previous: existingEntry }];
|
|
129
|
+
edits.push({
|
|
130
|
+
file: paths.config,
|
|
131
|
+
format: "json",
|
|
132
|
+
// Global-scope files live outside the install record's config home, so they
|
|
133
|
+
// need the owner sidecar that stops a second install (another project or
|
|
134
|
+
// XDG_CONFIG_HOME) from claiming them — the codex topology.
|
|
135
|
+
...(input.scope === "global" ? { shared: true as const } : {}),
|
|
136
|
+
ops,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
if (isPlainObject(servers)) {
|
|
140
|
+
if ("deepseek" in servers) notes.push(replacingExistingEntryNote("mcpServers.deepseek"));
|
|
141
|
+
}
|
|
142
|
+
notes.push(
|
|
143
|
+
`\`${API_KEY_ENV}\` is written as "\${${API_KEY_ENV}}" in the server's env block, so the key expands from the ` +
|
|
144
|
+
"environment that launches Claude Code and stays out of the file.",
|
|
145
|
+
);
|
|
146
|
+
if (input.scope === "project") {
|
|
147
|
+
notes.push(
|
|
148
|
+
"Claude Code asks once per project whether to use servers from .mcp.json; approve the deepseek server when " +
|
|
149
|
+
"prompted (or via the /mcp menu), or its tools will not load in that project.",
|
|
150
|
+
);
|
|
151
|
+
} else {
|
|
152
|
+
notes.push(
|
|
153
|
+
`User-scope servers live in the mcpServers key of ${paths.config} — Claude Code's own user-level store, where ` +
|
|
154
|
+
"`claude mcp add --scope user` writes too; every other key in the file is left untouched.",
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Forced-tier competitor detection also scans the other stack's file — a project
|
|
159
|
+
// install sees user-scope servers and a global install runs inside a cwd — since
|
|
160
|
+
// a server-level deny rule in this scope's settings file excludes either origin.
|
|
161
|
+
const forced = input.search === "forced" || input.fetch === "forced";
|
|
162
|
+
const scanned: Record<string, unknown> = {};
|
|
163
|
+
if (isPlainObject(servers)) Object.assign(scanned, servers);
|
|
164
|
+
const otherFile = input.scope === "project" ? join(input.home, ".claude.json") : join(input.cwd, ".mcp.json");
|
|
165
|
+
if (forced && existsSync(otherFile)) {
|
|
166
|
+
let otherLoaded: LoadedConfig | undefined;
|
|
167
|
+
try {
|
|
168
|
+
otherLoaded = codecFor("json", input.host).load(otherFile);
|
|
169
|
+
} catch (error) {
|
|
170
|
+
// A directory or unreadable file (EISDIR/EACCES) must refuse the tier —
|
|
171
|
+
// honor or report — instead of crashing on a file we never edit.
|
|
172
|
+
errors.push({
|
|
173
|
+
message: `${otherFile} exists but cannot be read (${error instanceof Error ? error.message : String(error)}); the forced-tier competitor scan requires it. Fix the file or its permissions, or choose a lower tier.`,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
if (otherLoaded?.parseError) {
|
|
177
|
+
errors.push({ message: `${otherFile} exists but does not parse as JSON: ${otherLoaded.parseError}` });
|
|
178
|
+
} else if (
|
|
179
|
+
otherLoaded !== undefined &&
|
|
180
|
+
otherLoaded.value.mcpServers !== undefined &&
|
|
181
|
+
!isPlainObject(otherLoaded.value.mcpServers)
|
|
182
|
+
) {
|
|
183
|
+
errors.push({
|
|
184
|
+
message: `${otherFile}: mcpServers is ${valueKind(otherLoaded.value.mcpServers)}, not an object; the forced-tier competitor scan cannot read it. Fix the file or choose a lower tier.`,
|
|
185
|
+
});
|
|
186
|
+
} else if (otherLoaded !== undefined && isPlainObject(otherLoaded.value.mcpServers)) {
|
|
187
|
+
const otherServers = otherLoaded.value.mcpServers;
|
|
188
|
+
Object.assign(scanned, otherServers);
|
|
189
|
+
if ("deepseek" in otherServers) {
|
|
190
|
+
notes.push(
|
|
191
|
+
`A deepseek server is also registered in ${otherFile}; Claude Code resolves the name local first, then ` +
|
|
192
|
+
"project, then user scope, so consider uninstalling the other scope to keep one registration.",
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const denyEntries: string[] = [];
|
|
199
|
+
const { competitors, bystanders } = splitCompetitors(scanned);
|
|
200
|
+
if (forced && competitors.length > 0) {
|
|
201
|
+
// Claude Code has a per-server kill switch: a bare `mcp__<server>` deny rule
|
|
202
|
+
// denies every tool of that server, so detected web-capable competitors are
|
|
203
|
+
// excluded instead of refusing the tier (unlike Codex).
|
|
204
|
+
const rules = competitors.map((name) => `mcp__${name}`);
|
|
205
|
+
denyEntries.push(...rules);
|
|
206
|
+
notes.push(
|
|
207
|
+
`Forced tier: web-capable MCP servers are denied by server-level rules (${rules.join(", ")}), so DeepSeek ` +
|
|
208
|
+
"routing is exclusive while the entries stay." +
|
|
209
|
+
(input.scope === "global"
|
|
210
|
+
? " These denies apply in every project on this machine and match by server name, so a same-named server in another project is denied too."
|
|
211
|
+
: ""),
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
const stayRegistered = forced ? bystanders : [...competitors, ...bystanders];
|
|
215
|
+
if (stayRegistered.length > 0) notes.push(othersStayRegisteredNote(stayRegistered));
|
|
216
|
+
|
|
217
|
+
if (input.search === "forced") denyEntries.push(BUILTIN_TOOL_IDS.search);
|
|
218
|
+
if (input.fetch === "forced") denyEntries.push(BUILTIN_TOOL_IDS.fetch);
|
|
219
|
+
if (input.search === "off") denyEntries.push(MCP_TOOL_IDS.search);
|
|
220
|
+
if (input.fetch === "off") denyEntries.push(MCP_TOOL_IDS.fetch);
|
|
221
|
+
|
|
222
|
+
if (denyEntries.length > 0) {
|
|
223
|
+
const settingsLoaded = codecFor("json", input.host).load(paths.settings);
|
|
224
|
+
if (settingsLoaded.parseError) {
|
|
225
|
+
errors.push({ message: `${paths.settings} exists but does not parse as JSON: ${settingsLoaded.parseError}` });
|
|
226
|
+
} else {
|
|
227
|
+
const permissions = settingsLoaded.value.permissions;
|
|
228
|
+
const deny = isPlainObject(permissions) ? permissions.deny : undefined;
|
|
229
|
+
if (permissions !== undefined && !isPlainObject(permissions)) {
|
|
230
|
+
errors.push({
|
|
231
|
+
message: `${paths.settings}: the permissions key is ${valueKind(permissions)}, not an object; fix it before installing.`,
|
|
232
|
+
});
|
|
233
|
+
} else if (deny !== undefined && !Array.isArray(deny)) {
|
|
234
|
+
errors.push({
|
|
235
|
+
message: `${paths.settings}: permissions.deny is ${valueKind(deny)}, not an array; fix it before installing.`,
|
|
236
|
+
});
|
|
237
|
+
} else {
|
|
238
|
+
const existingCount = Array.isArray(deny) ? deny.length : 0;
|
|
239
|
+
const existingDeny = Array.isArray(deny) ? deny.filter((item): item is string => typeof item === "string") : [];
|
|
240
|
+
const denyOps: ConfigOp[] = [
|
|
241
|
+
{
|
|
242
|
+
op: "append-array",
|
|
243
|
+
path: ["permissions", "deny"],
|
|
244
|
+
entries: denyEntries,
|
|
245
|
+
existingCount,
|
|
246
|
+
},
|
|
247
|
+
];
|
|
248
|
+
edits.push({
|
|
249
|
+
file: paths.settings,
|
|
250
|
+
format: "json",
|
|
251
|
+
...(input.scope === "global" ? { shared: true as const } : {}),
|
|
252
|
+
ops: denyOps,
|
|
253
|
+
});
|
|
254
|
+
if (existingCount > 0) {
|
|
255
|
+
notes.push(
|
|
256
|
+
`permissions.deny already holds ${existingCount} ${existingCount === 1 ? "entry" : "entries"}; ` +
|
|
257
|
+
"the plan appends only its own and never removes yours.",
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
const preexisting = denyEntries.filter((name) => existingDeny.includes(name));
|
|
261
|
+
if (preexisting.length > 0) {
|
|
262
|
+
notes.push(
|
|
263
|
+
`${preexisting.join(", ")} ${preexisting.length === 1 ? "is" : "are"} already present in permissions.deny — present entries are not duplicated.`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (errors.length > 0) {
|
|
270
|
+
return { host: "claude-code", scope: input.scope, tiers, edits: [], notes, errors };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const forcedIds = [
|
|
274
|
+
...(input.search === "forced" ? [BUILTIN_TOOL_IDS.search] : []),
|
|
275
|
+
...(input.fetch === "forced" ? [BUILTIN_TOOL_IDS.fetch] : []),
|
|
276
|
+
];
|
|
277
|
+
if (forcedIds.length > 0) {
|
|
278
|
+
notes.push(
|
|
279
|
+
`Forced is exclusion: bare deny rules (${forcedIds.join(", ")}) in permissions.deny remove the built-in tool ` +
|
|
280
|
+
"from the model's context entirely — deny outranks allow at every settings level and cannot be carved out, so " +
|
|
281
|
+
`the exclusion also applies to every other workflow that relied on the tool ${
|
|
282
|
+
input.scope === "global" ? "in every project on this machine" : "in this project"
|
|
283
|
+
}. ` +
|
|
284
|
+
FORCED_COST_NOTE,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
const offIds = [
|
|
288
|
+
...(input.search === "off" ? [MCP_TOOL_IDS.search] : []),
|
|
289
|
+
...(input.fetch === "off" ? [MCP_TOOL_IDS.fetch] : []),
|
|
290
|
+
];
|
|
291
|
+
if (offIds.length > 0) {
|
|
292
|
+
notes.push(
|
|
293
|
+
`Off capabilities are written as their full tool ids (${offIds.join(", ")}) in permissions.deny: the tool ` +
|
|
294
|
+
"leaves the model's context while the server keeps running; delete the entry to re-enable it.",
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
if (input.search === "optional" || input.fetch === "optional") {
|
|
298
|
+
notes.push(
|
|
299
|
+
"Claude Code ships built-in WebSearch and WebFetch tools; under the optional tier they stay available " +
|
|
300
|
+
"alongside the DeepSeek tools.",
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (input.search === "preferred" || input.fetch === "preferred") {
|
|
305
|
+
if (input.scope === "project" && !existsSync(paths.rules) && existsSync(join(input.cwd, "AGENTS.md"))) {
|
|
306
|
+
notes.push(
|
|
307
|
+
`This plan creates ${paths.rules} where none exists, and ${join(input.cwd, "AGENTS.md")} is present: Claude ` +
|
|
308
|
+
"Code reads AGENTS.md only when no CLAUDE.md exists above the cwd, so the new CLAUDE.md takes precedence — " +
|
|
309
|
+
"merge anything you rely on from AGENTS.md before applying.",
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
edits.push({
|
|
313
|
+
file: paths.rules,
|
|
314
|
+
format: "rules",
|
|
315
|
+
block: buildRulesBlock(input.host, rulesLines(MCP_TOOL_IDS, input.search, input.fetch)),
|
|
316
|
+
});
|
|
317
|
+
notes.push(
|
|
318
|
+
input.scope === "global"
|
|
319
|
+
? `Rules block upserted in ${paths.rules}; Claude Code loads the user CLAUDE.md plus every CLAUDE.md from the cwd up the directory tree, concatenating the levels.`
|
|
320
|
+
: `Rules block upserted in ${paths.rules}; Claude Code loads every CLAUDE.md from the cwd up the directory tree, concatenating the levels.`,
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return { host: "claude-code", scope: input.scope, tiers, edits, notes, errors };
|
|
325
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { API_KEY_ENV } from "../../core/constants";
|
|
3
|
+
import { OPTION_ENV_VARS } from "../../core/env";
|
|
3
4
|
import { FORCED_COST_NOTE, othersStayRegisteredNote, replacingExistingEntryNote, splitCompetitors } from "../detect";
|
|
4
5
|
import { codecFor } from "../formats";
|
|
5
6
|
import { buildRulesBlock } from "../formats/rules";
|
|
@@ -22,7 +23,10 @@ export const CODEX_TESTED_VERSION = "0.163.0";
|
|
|
22
23
|
*/
|
|
23
24
|
export function probeCodexVersion(err: (line: string) => void): string | undefined {
|
|
24
25
|
// env passed explicitly: runtime PATH edits (tests) must reach the lookup.
|
|
25
|
-
|
|
26
|
+
// shell: npm's global install on Windows provides only a codex.cmd shim,
|
|
27
|
+
// which spawnSync cannot launch without a shell; the command and args are
|
|
28
|
+
// hardcoded, so the shell hop is safe.
|
|
29
|
+
const result = spawnSync("codex", ["--version"], { timeout: 5000, encoding: "utf8", env: process.env, shell: true });
|
|
26
30
|
const version = result.status === 0 ? parseCodexVersion(result.stdout ?? "") : undefined;
|
|
27
31
|
if (version === undefined) {
|
|
28
32
|
err("note: codex was not found on PATH or printed no version; the version-drift check was skipped");
|
|
@@ -111,7 +115,7 @@ export function planCodex(input: InstallerInput, _previous: InstallRecord | unde
|
|
|
111
115
|
command: "npx",
|
|
112
116
|
args: ["-y", input.packageRef],
|
|
113
117
|
...(keptDisabled.length > 0 || offDisabled.length > 0 ? { disabled_tools: [...keptDisabled, ...offDisabled] } : {}),
|
|
114
|
-
env_vars: [API_KEY_ENV],
|
|
118
|
+
env_vars: [API_KEY_ENV, ...OPTION_ENV_VARS],
|
|
115
119
|
};
|
|
116
120
|
|
|
117
121
|
const ops: ConfigOp[] = [{ op: "set", path: ["mcp_servers", "deepseek"], value: entry, previous: existingEntry }];
|
|
@@ -133,8 +137,9 @@ export function planCodex(input: InstallerInput, _previous: InstallRecord | unde
|
|
|
133
137
|
edits.push({ file: paths.config, format: "toml", shared: true, ops });
|
|
134
138
|
|
|
135
139
|
notes.push(
|
|
136
|
-
`Codex stdio children do not inherit the parent environment, so the entry forwards ${API_KEY_ENV}
|
|
137
|
-
"
|
|
140
|
+
`Codex stdio children do not inherit the parent environment, so the entry forwards ${API_KEY_ENV} and every ` +
|
|
141
|
+
"DEEPSEEK_* option variable explicitly via env_vars; shell-set knobs reach the server without hand-editing " +
|
|
142
|
+
"config.toml (the shared config file works without any forwarding).",
|
|
138
143
|
);
|
|
139
144
|
if (input.scope === "project") {
|
|
140
145
|
notes.push(
|
|
@@ -3,6 +3,7 @@ import { deepEqual, isPlainObject } from "../formats/value";
|
|
|
3
3
|
import { assertFileOwnership, fileOwnershipNotes } from "../ownership";
|
|
4
4
|
import { claimedByOtherScope, InstallRecordError, readRecord } from "../state";
|
|
5
5
|
import type { HostName, InstallerInput, InstallPlan, InstallRecord, RecordedFile } from "../types";
|
|
6
|
+
import { planClaudeCode, probeClaudeCodeVersion } from "./claude-code";
|
|
6
7
|
import { planCodex, probeCodexVersion } from "./codex";
|
|
7
8
|
import { planOpenCode } from "./opencode";
|
|
8
9
|
import { planPi } from "./pi";
|
|
@@ -23,6 +24,7 @@ export const HOST_PLANNERS: Readonly<Record<HostName, HostPlanner>> = {
|
|
|
23
24
|
opencode: planOpenCode,
|
|
24
25
|
pi: planPi,
|
|
25
26
|
codex: planCodex,
|
|
27
|
+
"claude-code": planClaudeCode,
|
|
26
28
|
};
|
|
27
29
|
|
|
28
30
|
/**
|
|
@@ -32,6 +34,7 @@ export const HOST_PLANNERS: Readonly<Record<HostName, HostPlanner>> = {
|
|
|
32
34
|
*/
|
|
33
35
|
export const HOST_VERSION_PROBES: Partial<Record<HostName, (err: (line: string) => void) => string | undefined>> = {
|
|
34
36
|
codex: probeCodexVersion,
|
|
37
|
+
"claude-code": probeClaudeCodeVersion,
|
|
35
38
|
};
|
|
36
39
|
|
|
37
40
|
export function planForHost(host: HostName, input: InstallerInput): InstallPlan {
|