deepseek-web-retrieval 0.5.1 → 0.7.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 +21 -1
- package/README.md +167 -45
- package/dist/cli.js +2303 -1836
- package/docs/adr/0002-shared-config-base-layer.md +43 -0
- package/docs/agents/codex.md +60 -32
- package/docs/agents/opencode-v2.md +69 -39
- package/docs/agents/pi.md +60 -31
- package/docs/design/config-layering-design.md +180 -0
- package/docs/design/installer-design.md +33 -20
- package/docs/integration.md +41 -7
- package/docs/research/control-matrix.md +17 -4
- package/docs/research/mcp-compatibility.md +9 -2
- 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 +99 -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/apply.ts +5 -3
- package/src/installer/cli.ts +92 -25
- package/src/installer/config-command.ts +116 -0
- package/src/installer/hosts/codex.ts +5 -3
- package/src/installer/hosts/opencode.ts +9 -5
- package/src/installer/hosts/pi.ts +10 -6
- package/src/installer/ownership.ts +54 -1
- package/src/installer/paths.ts +7 -1
- package/src/installer/state.ts +73 -19
- package/src/installer/types.ts +12 -0
- package/src/mcp/auth.ts +2 -3
- package/src/mcp/cli.ts +9 -51
- package/src/mcp/serve.ts +60 -0
- package/src/mcp/server.ts +7 -2
- package/src/opencode/plugin.ts +22 -2
- package/src/pi/extension.ts +78 -9
- package/src/version.ts +12 -0
|
@@ -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
|
+
}
|
|
@@ -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";
|
|
@@ -111,7 +112,7 @@ export function planCodex(input: InstallerInput, _previous: InstallRecord | unde
|
|
|
111
112
|
command: "npx",
|
|
112
113
|
args: ["-y", input.packageRef],
|
|
113
114
|
...(keptDisabled.length > 0 || offDisabled.length > 0 ? { disabled_tools: [...keptDisabled, ...offDisabled] } : {}),
|
|
114
|
-
env_vars: [API_KEY_ENV],
|
|
115
|
+
env_vars: [API_KEY_ENV, ...OPTION_ENV_VARS],
|
|
115
116
|
};
|
|
116
117
|
|
|
117
118
|
const ops: ConfigOp[] = [{ op: "set", path: ["mcp_servers", "deepseek"], value: entry, previous: existingEntry }];
|
|
@@ -133,8 +134,9 @@ export function planCodex(input: InstallerInput, _previous: InstallRecord | unde
|
|
|
133
134
|
edits.push({ file: paths.config, format: "toml", shared: true, ops });
|
|
134
135
|
|
|
135
136
|
notes.push(
|
|
136
|
-
`Codex stdio children do not inherit the parent environment, so the entry forwards ${API_KEY_ENV}
|
|
137
|
-
"
|
|
137
|
+
`Codex stdio children do not inherit the parent environment, so the entry forwards ${API_KEY_ENV} and every ` +
|
|
138
|
+
"DEEPSEEK_* option variable explicitly via env_vars; shell-set knobs reach the server without hand-editing " +
|
|
139
|
+
"config.toml (the shared config file works without any forwarding).",
|
|
138
140
|
);
|
|
139
141
|
if (input.scope === "project") {
|
|
140
142
|
notes.push(
|
|
@@ -2,6 +2,7 @@ import { FORCED_COST_NOTE, looksLikeOwnPackage } from "../detect";
|
|
|
2
2
|
import { codecFor } from "../formats";
|
|
3
3
|
import { buildRulesBlock } from "../formats/rules";
|
|
4
4
|
import { isPlainObject } from "../formats/value";
|
|
5
|
+
import { lineageMatcher } from "../ownership";
|
|
5
6
|
import { configHome, join } from "../paths";
|
|
6
7
|
import { rulesLines } from "../rulesContent";
|
|
7
8
|
import { appendedByPrevious } from "../state";
|
|
@@ -44,8 +45,10 @@ export function planOpenCode(input: InstallerInput, previous: InstallRecord | un
|
|
|
44
45
|
|
|
45
46
|
const plugins = Array.isArray(loaded.value.plugins) ? loaded.value.plugins : [];
|
|
46
47
|
// Precisely ours: plugin packages a tracked init appended, plus this init's ref.
|
|
47
|
-
//
|
|
48
|
-
//
|
|
48
|
+
// Refs compare by package name (samePackageName), so a pinned ref excuses the
|
|
49
|
+
// unpinned lineage a previous init wrote and vice versa. Without a record
|
|
50
|
+
// nothing is excused — an untracked same-name plugin (a fork registers the
|
|
51
|
+
// same tool ids and would collide) must be removed first.
|
|
49
52
|
const trackedPackages = new Set(
|
|
50
53
|
appendedByPrevious(previous, ["plugins"])
|
|
51
54
|
.filter((entry): entry is { package: string } => isPlainObject(entry) && typeof entry.package === "string")
|
|
@@ -54,9 +57,10 @@ export function planOpenCode(input: InstallerInput, previous: InstallRecord | un
|
|
|
54
57
|
const pluginPackages = plugins.filter(
|
|
55
58
|
(entry): entry is { package: string } => isPlainObject(entry) && typeof entry.package === "string",
|
|
56
59
|
);
|
|
57
|
-
const
|
|
60
|
+
const excusedRefs = previous === undefined ? [] : [...trackedPackages, input.packageRef];
|
|
61
|
+
const isExcusedRef = lineageMatcher(excusedRefs);
|
|
58
62
|
const foreignOwn = pluginPackages.filter(
|
|
59
|
-
(entry) => looksLikeOwnPackage(entry.package) && !
|
|
63
|
+
(entry) => looksLikeOwnPackage(entry.package) && !isExcusedRef(entry.package),
|
|
60
64
|
);
|
|
61
65
|
if (foreignOwn.length > 0) {
|
|
62
66
|
errors.push({
|
|
@@ -65,7 +69,7 @@ export function planOpenCode(input: InstallerInput, previous: InstallRecord | un
|
|
|
65
69
|
"write. Remove it (or run uninstall if it came from a previous init), then retry.",
|
|
66
70
|
});
|
|
67
71
|
}
|
|
68
|
-
if (previous !== undefined && pluginPackages.some((entry) =>
|
|
72
|
+
if (previous !== undefined && pluginPackages.some((entry) => isExcusedRef(entry.package))) {
|
|
69
73
|
notes.push(
|
|
70
74
|
"A previous init wrote the plugins entry; re-applying keeps it the only entry (a changed package ref or tier " +
|
|
71
75
|
"options replace it, an unchanged one is a no-op) and the original snapshots carry over.",
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
import { codecFor } from "../formats";
|
|
12
12
|
import { buildRulesBlock } from "../formats/rules";
|
|
13
13
|
import { isPlainObject } from "../formats/value";
|
|
14
|
+
import { lineageMatcher } from "../ownership";
|
|
14
15
|
import { join } from "../paths";
|
|
15
16
|
import { MCP_TOOL_IDS, RAW_MCP_TOOL_IDS, rulesLines } from "../rulesContent";
|
|
16
17
|
import { appendedByPrevious } from "../state";
|
|
@@ -60,14 +61,17 @@ function planPiExtension(input: InstallerInput, previous: InstallRecord | undefi
|
|
|
60
61
|
const packages = Array.isArray(loaded.value.packages) ? loaded.value.packages : [];
|
|
61
62
|
const source = piSourceRef(input.packageRef);
|
|
62
63
|
// Precisely ours: what a tracked init appended, plus what this init would write.
|
|
63
|
-
//
|
|
64
|
+
// Sources compare by package name (samePackageName), so a pinned source
|
|
65
|
+
// excuses the unpinned lineage a previous init wrote and vice versa. Only a
|
|
66
|
+
// tracked init can excuse a same-name source — without a record, even an
|
|
64
67
|
// exact match is an untracked install this installer must not adopt silently.
|
|
65
68
|
const trackedSources = appendedByPrevious(previous, ["packages"]).filter(
|
|
66
69
|
(entry): entry is string => typeof entry === "string",
|
|
67
70
|
);
|
|
68
|
-
const excused = previous === undefined ? new Set<string>() : new Set<string>([...trackedSources, source]);
|
|
69
71
|
const sources = packages.filter((entry): entry is string => typeof entry === "string");
|
|
70
|
-
const
|
|
72
|
+
const excusedSources = previous === undefined ? [] : [...trackedSources, source];
|
|
73
|
+
const isExcusedSource = lineageMatcher(excusedSources);
|
|
74
|
+
const foreignOwn = sources.filter((entry) => looksLikeOwnPackage(entry) && !isExcusedSource(entry));
|
|
71
75
|
if (foreignOwn.length > 0) {
|
|
72
76
|
errors.push({
|
|
73
77
|
message:
|
|
@@ -75,7 +79,7 @@ function planPiExtension(input: InstallerInput, previous: InstallRecord | undefi
|
|
|
75
79
|
"write. Remove it (pi remove <source>, or uninstall if it came from a previous init), then retry.",
|
|
76
80
|
});
|
|
77
81
|
}
|
|
78
|
-
if (previous !== undefined && sources.some((entry) =>
|
|
82
|
+
if (previous !== undefined && sources.some((entry) => isExcusedSource(entry))) {
|
|
79
83
|
notes.push(
|
|
80
84
|
"A previous init wrote the packages entry; re-applying reuses it (an unchanged source deduplicates, a changed " +
|
|
81
85
|
"--package ref replaces the old source) and the original snapshots carry over.",
|
|
@@ -87,9 +91,9 @@ function planPiExtension(input: InstallerInput, previous: InstallRecord | undefi
|
|
|
87
91
|
// Pi has no per-package kill switch in settings.json, so a web-capable
|
|
88
92
|
// package or extension source cannot be excluded from the plan — refuse the
|
|
89
93
|
// tier instead of promising routing that is not actually forced.
|
|
90
|
-
const
|
|
94
|
+
const isOwnSource = lineageMatcher([...trackedSources, source]);
|
|
91
95
|
const unexcludable = [...collectSources(loaded.value.packages), ...collectSources(loaded.value.extensions)].filter(
|
|
92
|
-
(entry) => !
|
|
96
|
+
(entry) => !isOwnSource(entry) && looksWebCapable(entry, undefined),
|
|
93
97
|
);
|
|
94
98
|
if (unexcludable.length > 0) {
|
|
95
99
|
errors.push({
|
|
@@ -2,11 +2,60 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSyn
|
|
|
2
2
|
import { basename, dirname, join, resolve } from "node:path";
|
|
3
3
|
import { isPlainObject } from "../core/object";
|
|
4
4
|
import { pruneEmptyDir } from "./revert";
|
|
5
|
-
import { statePathFor } from "./state";
|
|
5
|
+
import { recordReadPath, statePathFor } from "./state";
|
|
6
6
|
import type { InstallerInput, RecordedFile, Scope } from "./types";
|
|
7
7
|
|
|
8
8
|
type ClaimedFile = Pick<RecordedFile, "file" | "format" | "shared">;
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* True when a ref's final `@` segment is a version spec — it starts with a digit
|
|
12
|
+
* (`name@<version>`, `npm:name@<version>`) — and not an npm scope
|
|
13
|
+
* (`@scope/name`, whose only `@` leads the string) or a dist-tag (`name@canary`).
|
|
14
|
+
*/
|
|
15
|
+
function hasVersionSuffix(ref: string): boolean {
|
|
16
|
+
const at = ref.lastIndexOf("@");
|
|
17
|
+
return at > 0 && /^[0-9]/.test(ref.slice(at + 1));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function stripVersionSuffix(ref: string): string {
|
|
21
|
+
const at = ref.lastIndexOf("@");
|
|
22
|
+
return hasVersionSuffix(ref) ? ref.slice(0, at) : ref;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Package-ref identity, the one comparison for "is this entry our package":
|
|
27
|
+
* two refs are the same package when they are equal after stripping a trailing
|
|
28
|
+
* `@version` suffix, so a pinned ref (`deepseek-web-retrieval@<version>`,
|
|
29
|
+
* `npm:deepseek-web-retrieval@<version>`) still matches its lineage
|
|
30
|
+
* (`deepseek-web-retrieval`). Forks, dist-tags, and lookalike names compare
|
|
31
|
+
* unequal. Everywhere a recorded or configured ref is matched against this
|
|
32
|
+
* install's ref — orphan detection, upgrade detection — goes through this
|
|
33
|
+
* helper instead of exact string equality.
|
|
34
|
+
*/
|
|
35
|
+
export function samePackageName(a: string, b: string): boolean {
|
|
36
|
+
return stripVersionSuffix(a) === stripVersionSuffix(b);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A lineage-membership predicate over an excused/own ref set: the candidate
|
|
41
|
+
* matches when it is the same package as any listed ref (version suffixes
|
|
42
|
+
* stripped via samePackageName — a pinned or hand-edited ref still matches its
|
|
43
|
+
* lineage). Both host modules build their set (previous init's tracked entries
|
|
44
|
+
* plus this run's ref) and ask the same two questions with this one predicate.
|
|
45
|
+
*/
|
|
46
|
+
export function lineageMatcher(refs: readonly string[]): (candidate: string) => boolean {
|
|
47
|
+
return (candidate: string) => refs.some((ref) => samePackageName(ref, candidate));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The `--pin` rule: append the running CLI's version to the ref unless its
|
|
52
|
+
* final `@` segment already is a version (`name` → `name@<version>`; a ref
|
|
53
|
+
* already ending in a version keeps it).
|
|
54
|
+
*/
|
|
55
|
+
export function pinPackageRef(ref: string, version: string): string {
|
|
56
|
+
return hasVersionSuffix(ref) ? ref : `${ref}@${version}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
10
59
|
interface FileOwner {
|
|
11
60
|
readonly recordPath: string;
|
|
12
61
|
readonly scope: Scope;
|
|
@@ -63,6 +112,10 @@ function readOwner(file: string): FileOwner | undefined {
|
|
|
63
112
|
function assertOwner(input: InstallerInput, claim: string): void {
|
|
64
113
|
const owner = readOwner(claim);
|
|
65
114
|
if (owner === undefined || owner.recordPath === ownerOf(input).recordPath) return;
|
|
115
|
+
// A win32 sidecar written before the config home moved to %APPDATA% names the
|
|
116
|
+
// legacy record location; when that is where this input's record is read
|
|
117
|
+
// from (see recordReadPath), it still identifies this same install lineage.
|
|
118
|
+
if (owner.recordPath === canonicalPath(recordReadPath(input))) return;
|
|
66
119
|
throw new Error(
|
|
67
120
|
`A ${input.host} install is already recorded at the ${owner.scope} scope (${owner.recordPath}) and claims the same config file. ` +
|
|
68
121
|
`Uninstall it first: deepseek-web-retrieval uninstall --host ${input.host} --scope ${owner.scope} --cwd ${JSON.stringify(owner.cwd)}. ` +
|
package/src/installer/paths.ts
CHANGED
|
@@ -7,7 +7,13 @@ export function join(...parts: readonly string[]): string {
|
|
|
7
7
|
return nonEmpty.length === 0 ? "" : pathJoin(...nonEmpty);
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* Config home: an explicit root wins; otherwise `~/.config`. The CLI edge now
|
|
12
|
+
* supplies the platform-aware explicit root (see userConfigHome in
|
|
13
|
+
* core/config-file: an honored XDG on every platform, `%APPDATA%` or
|
|
14
|
+
* `<home>\AppData\Roaming` on win32), so the `.config` fallback applies only to
|
|
15
|
+
* callers that construct roots directly without going through the CLI.
|
|
16
|
+
*/
|
|
11
17
|
export function configHome(roots: FsRoots): string {
|
|
12
18
|
return roots.configHome ?? join(roots.home, ".config");
|
|
13
19
|
}
|
package/src/installer/state.ts
CHANGED
|
@@ -3,12 +3,38 @@ import { isAbsolute, join } from "node:path";
|
|
|
3
3
|
import { z } from "zod/v4";
|
|
4
4
|
import { stateDir } from "./paths";
|
|
5
5
|
import { atomicWrite } from "./persistence";
|
|
6
|
-
import type { FsRoots, InstallerInput, InstallRecord } from "./types";
|
|
6
|
+
import type { FsRoots, InstallerInput, InstallRecord, Scope } from "./types";
|
|
7
7
|
|
|
8
8
|
export function statePathFor(input: InstallerInput): string {
|
|
9
9
|
return join(stateDir(input, input.scope), `install.${input.host}.json`);
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
/** The platform a read resolves for: injected at the CLI edge, process.platform otherwise. */
|
|
13
|
+
function platformOf(roots: FsRoots): NodeJS.Platform {
|
|
14
|
+
return roots.platform ?? process.platform;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Windows-only read compatibility: global records written before the config
|
|
19
|
+
* home moved to %APPDATA% live under `~/.config`. Kept readable so `list` and
|
|
20
|
+
* `uninstall` keep tracking installs made by older releases.
|
|
21
|
+
*/
|
|
22
|
+
export function legacyGlobalStateDir(roots: FsRoots): string {
|
|
23
|
+
return join(roots.home, ".config", "deepseek-web-retrieval");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The file a record is read from: the canonical state path, except that a
|
|
28
|
+
* missing win32 global record falls back to the legacy `~/.config` location
|
|
29
|
+
* (see legacyGlobalStateDir). Writes always target the canonical path.
|
|
30
|
+
*/
|
|
31
|
+
export function recordReadPath(input: InstallerInput, platform: NodeJS.Platform = platformOf(input)): string {
|
|
32
|
+
const canonical = statePathFor(input);
|
|
33
|
+
if (platform !== "win32" || input.scope !== "global" || existsSync(canonical)) return canonical;
|
|
34
|
+
const legacy = join(legacyGlobalStateDir(input), `install.${input.host}.json`);
|
|
35
|
+
return existsSync(legacy) ? legacy : canonical;
|
|
36
|
+
}
|
|
37
|
+
|
|
12
38
|
const keyPath = z.array(z.string().min(1)).min(1);
|
|
13
39
|
const tier = z.enum(["optional", "preferred", "forced", "off"]);
|
|
14
40
|
const operation = z.discriminatedUnion("op", [
|
|
@@ -22,6 +48,7 @@ const operation = z.discriminatedUnion("op", [
|
|
|
22
48
|
]);
|
|
23
49
|
const recordSchema = z.object({
|
|
24
50
|
version: z.literal(1),
|
|
51
|
+
installerVersion: z.string().optional(),
|
|
25
52
|
host: z.enum(["opencode", "pi", "codex"]),
|
|
26
53
|
scope: z.enum(["global", "project"]),
|
|
27
54
|
tiers: z.object({ search: tier, fetch: tier }),
|
|
@@ -85,7 +112,7 @@ function readRecordFile(file: string, host: string, scope: string): InstallRecor
|
|
|
85
112
|
}
|
|
86
113
|
|
|
87
114
|
export function readRecord(input: InstallerInput): InstallRecord | undefined {
|
|
88
|
-
return readRecordFile(
|
|
115
|
+
return readRecordFile(recordReadPath(input), input.host, input.scope);
|
|
89
116
|
}
|
|
90
117
|
|
|
91
118
|
/**
|
|
@@ -122,27 +149,54 @@ export function appendedByPrevious(record: InstallRecord | undefined, path: read
|
|
|
122
149
|
}
|
|
123
150
|
|
|
124
151
|
export function deleteRecord(input: InstallerInput): void {
|
|
125
|
-
|
|
152
|
+
// Win32 legacy records are removed where they were found, so uninstalling a
|
|
153
|
+
// pre-APPDATA install clears its tracking instead of a canonical file that
|
|
154
|
+
// was never written.
|
|
155
|
+
const file = recordReadPath(input);
|
|
126
156
|
if (existsSync(file)) rmSync(file);
|
|
127
157
|
}
|
|
128
158
|
|
|
129
|
-
/**
|
|
130
|
-
|
|
159
|
+
/** Reads every valid record in one scope's directory; an absent directory is empty. */
|
|
160
|
+
function readRecordsFromDir(dir: string, scope: Scope): InstallRecord[] {
|
|
131
161
|
const records: InstallRecord[] = [];
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const record = readRecordFile(join(dir, name), name.slice("install.".length, -".json".length), scope);
|
|
144
|
-
if (record !== undefined) records.push(record);
|
|
145
|
-
}
|
|
162
|
+
let names: string[];
|
|
163
|
+
try {
|
|
164
|
+
names = readdirSync(dir);
|
|
165
|
+
} catch (error) {
|
|
166
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return records;
|
|
167
|
+
throw new InstallRecordError(dir, "unreadable", (error as NodeJS.ErrnoException).code ?? "list failed");
|
|
168
|
+
}
|
|
169
|
+
for (const name of names) {
|
|
170
|
+
if (!name.startsWith("install.") || !name.endsWith(".json")) continue;
|
|
171
|
+
const record = readRecordFile(join(dir, name), name.slice("install.".length, -".json".length), scope);
|
|
172
|
+
if (record !== undefined) records.push(record);
|
|
146
173
|
}
|
|
147
174
|
return records;
|
|
148
175
|
}
|
|
176
|
+
|
|
177
|
+
function hasRecordFiles(dir: string): boolean {
|
|
178
|
+
try {
|
|
179
|
+
return readdirSync(dir).some((name) => name.startsWith("install.") && name.endsWith(".json"));
|
|
180
|
+
} catch {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* True when the global records must be read from the legacy win32 `~/.config`
|
|
187
|
+
* location: win32, nothing recorded under the canonical config home, and
|
|
188
|
+
* pre-%APPDATA% records still present there. `list` says so when this holds.
|
|
189
|
+
*/
|
|
190
|
+
export function readingLegacyGlobalRecords(roots: FsRoots): boolean {
|
|
191
|
+
if (platformOf(roots) !== "win32") return false;
|
|
192
|
+
if (hasRecordFiles(stateDir(roots, "global"))) return false;
|
|
193
|
+
return hasRecordFiles(legacyGlobalStateDir(roots));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Lists install records found in both scope locations for the given roots. */
|
|
197
|
+
export function listRecords(roots: FsRoots): InstallRecord[] {
|
|
198
|
+
const global = readRecordsFromDir(stateDir(roots, "global"), "global");
|
|
199
|
+
const project = readRecordsFromDir(stateDir(roots, "project"), "project");
|
|
200
|
+
const legacy = readingLegacyGlobalRecords(roots) ? readRecordsFromDir(legacyGlobalStateDir(roots), "global") : [];
|
|
201
|
+
return [...global, ...legacy, ...project];
|
|
202
|
+
}
|
package/src/installer/types.ts
CHANGED
|
@@ -13,6 +13,11 @@ export interface FsRoots {
|
|
|
13
13
|
readonly cwd: string;
|
|
14
14
|
/** Explicit config home; the CLI edge resolves XDG once and passes it down. */
|
|
15
15
|
readonly configHome?: string;
|
|
16
|
+
/**
|
|
17
|
+
* Platform for the win32-aware reads (legacy record fallback); the CLI edge
|
|
18
|
+
* passes it down like configHome. Readers default to process.platform.
|
|
19
|
+
*/
|
|
20
|
+
readonly platform?: NodeJS.Platform;
|
|
16
21
|
}
|
|
17
22
|
|
|
18
23
|
/** The files a host edit touches for a given scope. */
|
|
@@ -37,6 +42,11 @@ export interface InstallerInput {
|
|
|
37
42
|
readonly home: string;
|
|
38
43
|
readonly cwd: string;
|
|
39
44
|
readonly configHome?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Platform for the win32-aware reads (legacy record fallback); see FsRoots.
|
|
47
|
+
* Injected at the CLI edge, absent when callers construct inputs directly.
|
|
48
|
+
*/
|
|
49
|
+
readonly platform?: NodeJS.Platform;
|
|
40
50
|
/**
|
|
41
51
|
* Integration mechanism for hosts with more than one (Pi: the native
|
|
42
52
|
* extension, or the MCP server). Undefined means the host's default path.
|
|
@@ -136,6 +146,8 @@ export interface RecordedFile {
|
|
|
136
146
|
/** Persisted install state; one record per host and scope. */
|
|
137
147
|
export interface InstallRecord {
|
|
138
148
|
readonly version: 1;
|
|
149
|
+
/** Version of the installer that wrote this record; absent on records from older releases. */
|
|
150
|
+
readonly installerVersion?: string;
|
|
139
151
|
readonly host: HostName;
|
|
140
152
|
readonly scope: Scope;
|
|
141
153
|
readonly tiers: { readonly search: Tier; readonly fetch: Tier };
|
package/src/mcp/auth.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { apiKeyFromEnv } from "../core/auth";
|
|
2
|
-
import { API_KEY_ENV } from "../core/constants";
|
|
1
|
+
import { apiKeyFromEnv, MISSING_API_KEY_MESSAGE } from "../core/auth";
|
|
3
2
|
import type { Env } from "../core/env";
|
|
4
3
|
|
|
5
|
-
export
|
|
4
|
+
export { MISSING_API_KEY_MESSAGE };
|
|
6
5
|
|
|
7
6
|
/** Resolves the API key from the environment or fails with shared guidance. */
|
|
8
7
|
export function requireApiKey(env: Env): string {
|
package/src/mcp/cli.ts
CHANGED
|
@@ -1,57 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
// Installer subcommands run before the server path; MCP clients spawn this bin
|
|
10
|
-
// with no arguments, so the default stdio behavior is unchanged.
|
|
11
|
-
const subcommand = process.argv[2];
|
|
12
|
-
if (subcommand === "init" || subcommand === "uninstall" || subcommand === "list") {
|
|
2
|
+
// Any argument means a human ran the CLI; MCP clients spawn this bin with no
|
|
3
|
+
// arguments for the stdio server. Each side is imported dynamically, and only
|
|
4
|
+
// the side being routed to, so init/uninstall never load — or depend on — the
|
|
5
|
+
// server's dependency graph (and the server never loads the installer),
|
|
6
|
+
// including when recovering from a bad server setup.
|
|
7
|
+
if (process.argv.length > 2) {
|
|
13
8
|
const { runInstallerCli } = await import("../installer/cli");
|
|
14
9
|
process.exit(await runInstallerCli(process.argv.slice(2)));
|
|
15
10
|
}
|
|
16
11
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
if (!apiKeyFromEnv(env)) {
|
|
20
|
-
console.error(MISSING_API_KEY_MESSAGE);
|
|
21
|
-
process.exit(1);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const config = resolveEnvOptions(env);
|
|
25
|
-
const precheck = servePrecheck(config);
|
|
26
|
-
if (precheck) {
|
|
27
|
-
console.error(precheck);
|
|
28
|
-
process.exit(1);
|
|
29
|
-
}
|
|
30
|
-
const handle = serveStdio(() => {
|
|
31
|
-
const server = createDeepSeekServer(config, env);
|
|
32
|
-
// Fires after the handshake-era initialize exchange (the stateless era opens
|
|
33
|
-
// differently and simply never logs). One stderr line so a human run shows
|
|
34
|
-
// which client connected and whether text-only shaping kicked in; if a host
|
|
35
|
-
// renames its clientInfo, this line is where the regression becomes visible.
|
|
36
|
-
server.server.oninitialized = () => {
|
|
37
|
-
const info = server.server.getClientVersion();
|
|
38
|
-
if (info === undefined) return;
|
|
39
|
-
console.error(
|
|
40
|
-
`mcp client: ${info.name} ${info.version ?? "unknown"} — ` +
|
|
41
|
-
(isJsonReplacingClient(info.name)
|
|
42
|
-
? "text-only results (this client replaces content with serialized structuredContent)"
|
|
43
|
-
: "text + structuredContent results"),
|
|
44
|
-
);
|
|
45
|
-
};
|
|
46
|
-
return server;
|
|
47
|
-
});
|
|
48
|
-
const tools = [
|
|
49
|
-
config.search.enabled ? `search (max ${config.search.maxResults} results)` : null,
|
|
50
|
-
config.fetch.enabled ? `fetch (max ${config.fetch.maxChars} chars)` : null,
|
|
51
|
-
]
|
|
52
|
-
.filter(Boolean)
|
|
53
|
-
.join(", ");
|
|
54
|
-
console.error(`deepseek-web-retrieval MCP server on stdio (${tools})`);
|
|
12
|
+
const { serveMcp } = await import("./serve");
|
|
13
|
+
serveMcp();
|
|
55
14
|
|
|
56
|
-
|
|
57
|
-
process.on("SIGTERM", () => void handle.close());
|
|
15
|
+
export {};
|
package/src/mcp/serve.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
2
|
+
import { apiKeyFromEnv } from "../core/auth";
|
|
3
|
+
import { sharedFileOptions } from "../core/config-file";
|
|
4
|
+
import { configDeviations } from "../core/config-summary";
|
|
5
|
+
import { resolveEnvOptions } from "../core/env";
|
|
6
|
+
import { MISSING_API_KEY_MESSAGE } from "./auth";
|
|
7
|
+
import { isJsonReplacingClient } from "./client";
|
|
8
|
+
import { createDeepSeekServer, servePrecheck } from "./server";
|
|
9
|
+
|
|
10
|
+
/** Boots the stdio MCP server; the CLI entry reaches this only with no arguments. */
|
|
11
|
+
export function serveMcp(): void {
|
|
12
|
+
// stdout is the JSON-RPC channel; all diagnostics must go to stderr.
|
|
13
|
+
const env = process.env;
|
|
14
|
+
if (!apiKeyFromEnv(env)) {
|
|
15
|
+
console.error(MISSING_API_KEY_MESSAGE);
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Defaults < shared config file < environment: the server reads the file
|
|
20
|
+
// itself, so hosts that forward only DEEPSEEK_API_KEY still honor it.
|
|
21
|
+
const config = resolveEnvOptions(env, sharedFileOptions({ env }));
|
|
22
|
+
const precheck = servePrecheck(config);
|
|
23
|
+
if (precheck) {
|
|
24
|
+
console.error(precheck);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
const handle = serveStdio(() => {
|
|
28
|
+
const server = createDeepSeekServer(config, env);
|
|
29
|
+
// Fires after the handshake-era initialize exchange (the stateless era opens
|
|
30
|
+
// differently and simply never logs). One stderr line so a human run shows
|
|
31
|
+
// which client connected and whether text-only shaping kicked in; if a host
|
|
32
|
+
// renames its clientInfo, this line is where the regression becomes visible.
|
|
33
|
+
server.server.oninitialized = () => {
|
|
34
|
+
const info = server.server.getClientVersion();
|
|
35
|
+
if (info === undefined) return;
|
|
36
|
+
console.error(
|
|
37
|
+
`mcp client: ${info.name} ${info.version ?? "unknown"} — ` +
|
|
38
|
+
(isJsonReplacingClient(info.name)
|
|
39
|
+
? "text-only results (this client replaces content with serialized structuredContent)"
|
|
40
|
+
: "text + structuredContent results"),
|
|
41
|
+
);
|
|
42
|
+
};
|
|
43
|
+
return server;
|
|
44
|
+
});
|
|
45
|
+
const tools = [
|
|
46
|
+
config.search.enabled ? `search (max ${config.search.maxResults} results)` : null,
|
|
47
|
+
config.fetch.enabled ? `fetch (max ${config.fetch.maxChars} chars)` : null,
|
|
48
|
+
]
|
|
49
|
+
.filter(Boolean)
|
|
50
|
+
.join(", ");
|
|
51
|
+
console.error(`deepseek-web-retrieval MCP server on stdio (${tools})`);
|
|
52
|
+
// Deviations only, so the line stays short while still showing every
|
|
53
|
+
// non-default knob the layered resolution produced; nothing deviating
|
|
54
|
+
// means no line at all.
|
|
55
|
+
const deviations = configDeviations(config);
|
|
56
|
+
if (deviations) console.error(`config: ${deviations}`);
|
|
57
|
+
|
|
58
|
+
process.on("SIGINT", () => void handle.close());
|
|
59
|
+
process.on("SIGTERM", () => void handle.close());
|
|
60
|
+
}
|