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.
Files changed (46) hide show
  1. package/CHANGELOG.md +21 -1
  2. package/README.md +167 -45
  3. package/dist/cli.js +2303 -1836
  4. package/docs/adr/0002-shared-config-base-layer.md +43 -0
  5. package/docs/agents/codex.md +60 -32
  6. package/docs/agents/opencode-v2.md +69 -39
  7. package/docs/agents/pi.md +60 -31
  8. package/docs/design/config-layering-design.md +180 -0
  9. package/docs/design/installer-design.md +33 -20
  10. package/docs/integration.md +41 -7
  11. package/docs/research/control-matrix.md +17 -4
  12. package/docs/research/mcp-compatibility.md +9 -2
  13. package/docs/research/ori-question-sourcing.md +336 -0
  14. package/docs/research/result-quality-passthrough.md +159 -0
  15. package/docs/research/search-api-defaults.md +18 -2
  16. package/docs/research/search-inline-content.md +244 -0
  17. package/examples/opencode.global.jsonc +3 -2
  18. package/package.json +2 -1
  19. package/src/core/auth.ts +3 -0
  20. package/src/core/client.ts +26 -4
  21. package/src/core/config-file.ts +168 -0
  22. package/src/core/config-summary.ts +82 -0
  23. package/src/core/constants.ts +15 -1
  24. package/src/core/descriptions.ts +6 -2
  25. package/src/core/env.ts +99 -32
  26. package/src/core/format.ts +19 -1
  27. package/src/core/options.ts +22 -2
  28. package/src/core/search.ts +6 -0
  29. package/src/core/types.ts +11 -0
  30. package/src/installer/apply.ts +5 -3
  31. package/src/installer/cli.ts +92 -25
  32. package/src/installer/config-command.ts +116 -0
  33. package/src/installer/hosts/codex.ts +5 -3
  34. package/src/installer/hosts/opencode.ts +9 -5
  35. package/src/installer/hosts/pi.ts +10 -6
  36. package/src/installer/ownership.ts +54 -1
  37. package/src/installer/paths.ts +7 -1
  38. package/src/installer/state.ts +73 -19
  39. package/src/installer/types.ts +12 -0
  40. package/src/mcp/auth.ts +2 -3
  41. package/src/mcp/cli.ts +9 -51
  42. package/src/mcp/serve.ts +60 -0
  43. package/src/mcp/server.ts +7 -2
  44. package/src/opencode/plugin.ts +22 -2
  45. package/src/pi/extension.ts +78 -9
  46. 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} explicitly ` +
137
- "via env_vars; the key must be set in the shell that launches Codex.",
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
- // 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.
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 excused = previous === undefined ? new Set<string>() : new Set<string>([...trackedPackages, input.packageRef]);
60
+ const excusedRefs = previous === undefined ? [] : [...trackedPackages, input.packageRef];
61
+ const isExcusedRef = lineageMatcher(excusedRefs);
58
62
  const foreignOwn = pluginPackages.filter(
59
- (entry) => looksLikeOwnPackage(entry.package) && !excused.has(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) => excused.has(entry.package))) {
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
- // Only a tracked init can excuse a same-name source — without a record, even an
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 foreignOwn = sources.filter((entry) => looksLikeOwnPackage(entry) && !excused.has(entry));
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) => excused.has(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 ownSources = new Set<string>([...trackedSources, source]);
94
+ const isOwnSource = lineageMatcher([...trackedSources, source]);
91
95
  const unexcludable = [...collectSources(loaded.value.packages), ...collectSources(loaded.value.extensions)].filter(
92
- (entry) => !ownSources.has(entry) && looksWebCapable(entry, undefined),
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)}. ` +
@@ -7,7 +7,13 @@ export function join(...parts: readonly string[]): string {
7
7
  return nonEmpty.length === 0 ? "" : pathJoin(...nonEmpty);
8
8
  }
9
9
 
10
- /** Config home: an explicit root wins; otherwise ~/.config. The CLI resolves XDG at the edge. */
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
  }
@@ -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(statePathFor(input), input.host, input.scope);
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
- const file = statePathFor(input);
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
- /** Lists install records found in both scope locations for the given roots. */
130
- export function listRecords(roots: FsRoots): InstallRecord[] {
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
- for (const scope of ["global", "project"] as const) {
133
- const dir = stateDir(roots, scope);
134
- let names: string[];
135
- try {
136
- names = readdirSync(dir);
137
- } catch (error) {
138
- if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
139
- throw new InstallRecordError(dir, "unreadable", (error as NodeJS.ErrnoException).code ?? "list failed");
140
- }
141
- for (const name of names) {
142
- if (!name.startsWith("install.") || !name.endsWith(".json")) continue;
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
+ }
@@ -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 const MISSING_API_KEY_MESSAGE = `DeepSeek API key not found. Set ${API_KEY_ENV} in the MCP server's env configuration.`;
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
- import { serveStdio } from "@modelcontextprotocol/server/stdio";
3
- import { apiKeyFromEnv } from "../core/auth";
4
- import { resolveEnvOptions } from "../core/env";
5
- import { MISSING_API_KEY_MESSAGE } from "./auth";
6
- import { isJsonReplacingClient } from "./client";
7
- import { createDeepSeekServer, servePrecheck } from "./server";
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
- // stdout is the JSON-RPC channel; all diagnostics must go to stderr.
18
- const env = process.env;
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
- process.on("SIGINT", () => void handle.close());
57
- process.on("SIGTERM", () => void handle.close());
15
+ export {};
@@ -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
+ }