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
@@ -16,7 +16,7 @@ export const SEARCH_RANKED_FILTERED =
16
16
  export const SEARCH_EMPTY_IS_NORMAL = "An empty result list is normal, not an error.";
17
17
 
18
18
  export const SEARCH_QUERY_OPERATORS =
19
- "Use site:/-site: operators in the query to include or exclude domains; the full query must stay within 128 characters.";
19
+ "Use site:/-site: operators in the query to include or exclude domains, and after:/before: with a year or date (e.g. after:2025-06-01) to filter by publish time; the full query must stay within 128 characters.";
20
20
 
21
21
  /**
22
22
  * The full tool descriptions, composed from the fact fragments. Every host
@@ -38,6 +38,10 @@ export const FETCH_TOOL_DESCRIPTION =
38
38
 
39
39
  /** Parameter descriptions shared by every adapter's input schema for the two tools. */
40
40
  export const SEARCH_QUERY_PARAMETER =
41
- "Search query, 128 characters or fewer including operators (site:, -site:, quoted phrases, lang:).";
41
+ "Search query, 128 characters or fewer including operators (site:, -site:, quoted phrases, lang:, after:, before:).";
42
+
43
+ export const SEARCH_ORI_QUESTION_PARAMETER =
44
+ "The user's original question or task that triggered this search, in the user's own words rather than a " +
45
+ "reformulated query. Optional; 1-128 characters, and longer values are truncated.";
42
46
 
43
47
  export const FETCH_URL_PARAMETER = "Absolute http:// or https:// URL to fetch. Credentials are rejected.";
package/src/core/env.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { ENABLE_FETCH_ENV, ENABLE_SEARCH_ENV } from "./constants";
1
+ import { API_KEY_ENV, ENABLE_FETCH_ENV, ENABLE_SEARCH_ENV, SEARCH_LOCALIZATION_ENV } from "./constants";
2
+ import { normalizeDomain } from "./domains";
2
3
  import { resolvePluginOptions } from "./options";
3
4
  import type { PluginOptions, ResolvedOptions } from "./types";
4
5
 
@@ -21,6 +22,7 @@ const NUMBER_ENV = {
21
22
 
22
23
  const TEXT_ENV = {
23
24
  language: "DEEPSEEK_SEARCH_LANGUAGE",
25
+ searchLocalization: SEARCH_LOCALIZATION_ENV,
24
26
  } as const;
25
27
 
26
28
  const DOMAINS_ENV = {
@@ -38,51 +40,92 @@ const FALSE_ONLY_ENV = {
38
40
  fetchEnabled: ENABLE_FETCH_ENV,
39
41
  } as const;
40
42
 
43
+ /**
44
+ * Every option's `DEEPSEEK_*` variable name, in map order — the names a host that
45
+ * isolates its children from the parent environment (Codex) must forward for
46
+ * shell-set options to reach the server. `DEEPSEEK_API_KEY` is deliberately
47
+ * absent: it is not an option variable, and installers compose it separately.
48
+ */
49
+ export const OPTION_ENV_VARS: readonly string[] = [
50
+ ...Object.values(NUMBER_ENV),
51
+ ...Object.values(TEXT_ENV),
52
+ ...Object.values(DOMAINS_ENV),
53
+ ...Object.values(FALSE_ONLY_ENV),
54
+ ];
55
+
41
56
  /**
42
57
  * Maps `DEEPSEEK_*` environment variables onto the shared plugin option keys and
43
58
  * resolves them through the shared pipeline, so defaults and clamping stay
44
59
  * identical across every adapter. Invalid values are dropped and the shared
45
- * default applies.
60
+ * default applies; `envOptionsReport` is where those drops become visible.
61
+ * `fileOptions` (the shared config file) layer beneath the environment: for
62
+ * the same key the environment wins.
46
63
  */
47
- export function resolveEnvOptions(env: Env = process.env): ResolvedOptions {
48
- return resolvePluginOptions(pluginOptionsFromEnv(env));
64
+ export function resolveEnvOptions(env: Env = process.env, fileOptions?: PluginOptions): ResolvedOptions {
65
+ return resolvePluginOptions({ ...fileOptions, ...pluginOptionsFromEnv(env) });
49
66
  }
50
67
 
51
- export function pluginOptionsFromEnv(env: Env): PluginOptions {
52
- const options: Record<string, unknown> = {};
68
+ /**
69
+ * What reading the `DEEPSEEK_*` environment produced, plus the problems as
70
+ * structured data. Startup paths stay silent (they use pluginOptionsFromEnv);
71
+ * surfaces whose job is visibility — the config command — replay these
72
+ * warnings through their own channel.
73
+ */
74
+ export interface EnvOptionsReport {
75
+ readonly options: PluginOptions;
76
+ /** One entry per set-but-unusable `DEEPSEEK_*` variable: unknown names and values that had to be dropped. */
77
+ readonly warnings: readonly string[];
78
+ }
53
79
 
54
- putNumber(options, "count", env);
55
- putNumber(options, "maxResults", env);
56
- putNumber(options, "minRerank", env);
57
- putNumber(options, "minAuthority", env);
58
- putFalseOnly(options, "allowUnknownAuthority", env);
59
- putDomains(options, "blockedDomains", env);
60
- putDomains(options, "includeDomains", env);
80
+ /** The options layer of the environment, with every drop reported instead of silent. */
81
+ export function envOptionsReport(env: Env = process.env): EnvOptionsReport {
82
+ const options: Record<string, unknown> = {};
83
+ const warnings: string[] = [];
84
+
85
+ putNumber(options, "count", env, warnings);
86
+ putNumber(options, "maxResults", env, warnings);
87
+ putNumber(options, "minRerank", env, warnings);
88
+ putNumber(options, "minAuthority", env, warnings);
89
+ putFalseOnly(options, "allowUnknownAuthority", env, warnings);
90
+ putDomains(options, "blockedDomains", env, warnings);
91
+ putDomains(options, "includeDomains", env, warnings);
61
92
  putText(options, "language", env);
62
- putNumber(options, "maxSummaryChars", env);
63
- putNumber(options, "searchTimeoutMs", env);
64
-
65
- putNumber(options, "fetchMaxChars", env);
66
- putDomains(options, "fetchAllowedDomains", env);
67
- putDomains(options, "fetchBlockedDomains", env);
68
- putNumber(options, "fetchTimeoutMs", env);
69
- putFalseOnly(options, "fetchBlockPrivateHosts", env);
70
- putFalseOnly(options, "searchEnabled", env);
71
- putFalseOnly(options, "fetchEnabled", env);
72
-
73
- putNumber(options, "maxRetries", env);
74
- putNumber(options, "retryBaseDelayMs", env);
93
+ putText(options, "searchLocalization", env);
94
+ putNumber(options, "maxSummaryChars", env, warnings);
95
+ putNumber(options, "searchTimeoutMs", env, warnings);
96
+
97
+ putNumber(options, "fetchMaxChars", env, warnings);
98
+ putDomains(options, "fetchAllowedDomains", env, warnings);
99
+ putDomains(options, "fetchBlockedDomains", env, warnings);
100
+ putNumber(options, "fetchTimeoutMs", env, warnings);
101
+ putFalseOnly(options, "fetchBlockPrivateHosts", env, warnings);
102
+ putFalseOnly(options, "searchEnabled", env, warnings);
103
+ putFalseOnly(options, "fetchEnabled", env, warnings);
104
+
105
+ putNumber(options, "maxRetries", env, warnings);
106
+ putNumber(options, "retryBaseDelayMs", env, warnings);
107
+
108
+ const known = new Set(OPTION_ENV_VARS);
109
+ for (const key of Object.keys(env)) {
110
+ if (!key.startsWith("DEEPSEEK_") || key === API_KEY_ENV || known.has(key)) continue;
111
+ if (!(env[key] ?? "").trim()) continue;
112
+ warnings.push(`unknown DEEPSEEK_* variable ${key} — ignored`);
113
+ }
114
+ return { options, warnings };
115
+ }
75
116
 
76
- return options;
117
+ export function pluginOptionsFromEnv(env: Env): PluginOptions {
118
+ return envOptionsReport(env).options;
77
119
  }
78
120
 
79
- function putNumber(target: Record<string, unknown>, key: keyof typeof NUMBER_ENV, env: Env): void {
121
+ function putNumber(target: Record<string, unknown>, key: keyof typeof NUMBER_ENV, env: Env, warnings: string[]): void {
80
122
  const value = env[NUMBER_ENV[key]];
81
123
  if (value === undefined) return;
82
124
  const trimmed = value.trim();
83
125
  if (!trimmed) return;
84
126
  const parsed = Number(trimmed);
85
127
  if (Number.isFinite(parsed)) target[key] = parsed;
128
+ else warnings.push(`${NUMBER_ENV[key]} is not a number ("${trimmed}") — ignored`);
86
129
  }
87
130
 
88
131
  function putText(target: Record<string, unknown>, key: keyof typeof TEXT_ENV, env: Env): void {
@@ -92,18 +135,42 @@ function putText(target: Record<string, unknown>, key: keyof typeof TEXT_ENV, en
92
135
  if (trimmed) target[key] = trimmed;
93
136
  }
94
137
 
95
- function putFalseOnly(target: Record<string, unknown>, key: keyof typeof FALSE_ONLY_ENV, env: Env): void {
96
- if (isFalseValue(env[FALSE_ONLY_ENV[key]])) target[key] = false;
138
+ function putFalseOnly(
139
+ target: Record<string, unknown>,
140
+ key: keyof typeof FALSE_ONLY_ENV,
141
+ env: Env,
142
+ warnings: string[],
143
+ ): void {
144
+ const name = FALSE_ONLY_ENV[key];
145
+ const value = env[name];
146
+ if (value === undefined || !value.trim()) return;
147
+ if (isFalseValue(value)) {
148
+ target[key] = false;
149
+ return;
150
+ }
151
+ warnings.push(`${name}="${value.trim()}" changes nothing — only false/0/no/off turns the switch off`);
97
152
  }
98
153
 
99
- function putDomains(target: Record<string, unknown>, key: keyof typeof DOMAINS_ENV, env: Env): void {
100
- const value = env[DOMAINS_ENV[key]];
154
+ function putDomains(
155
+ target: Record<string, unknown>,
156
+ key: keyof typeof DOMAINS_ENV,
157
+ env: Env,
158
+ warnings: string[],
159
+ ): void {
160
+ const name = DOMAINS_ENV[key];
161
+ const value = env[name];
101
162
  if (value === undefined || !value.trim()) return;
102
163
  const domains = value
103
164
  .split(",")
104
165
  .map((domain) => domain.trim())
105
166
  .filter((domain) => domain.length > 0);
167
+ for (const domain of domains) {
168
+ if (normalizeDomain(domain) === undefined) {
169
+ warnings.push(`${name} entry "${domain}" is not a domain — ignored`);
170
+ }
171
+ }
106
172
  if (domains.length > 0) target[key] = domains;
173
+ else warnings.push(`${name} contains no domains — ignored`);
107
174
  }
108
175
 
109
176
  function isFalseValue(value: string | undefined): boolean {
@@ -95,7 +95,8 @@ export function formatSearchResults(results: readonly SearchResult[]): string {
95
95
  return results
96
96
  .map((result, index) => {
97
97
  const title = result.title?.trim() || result.url;
98
- const lines = [`${index + 1}. [${escapeBrackets(title)}](${result.url})`];
98
+ const marker = formatQualityMarker(result);
99
+ const lines = [`${index + 1}. [${escapeBrackets(title)}](${result.url})${marker ? ` ${marker}` : ""}`];
99
100
  if (result.content) {
100
101
  lines.push(` ${result.content.replaceAll("\n", " ")}`);
101
102
  }
@@ -107,6 +108,20 @@ export function formatSearchResults(results: readonly SearchResult[]): string {
107
108
  .join("\n\n");
108
109
  }
109
110
 
111
+ /**
112
+ * Renders the quality fields DeepSeek reports as one bracketed token on the
113
+ * result's heading line; the text channel is the only one every host delivers
114
+ * to the model. Authority -1 means unknown and stays in the structured shape
115
+ * only, so it never wastes text.
116
+ */
117
+ export function formatQualityMarker(result: SearchResult): string {
118
+ const parts: string[] = [];
119
+ if (result.rerankScore !== undefined) parts.push(`rerank ${result.rerankScore}`);
120
+ if (result.authority !== undefined && result.authority >= 0) parts.push(`authority ${result.authority}`);
121
+ if (result.siteName) parts.push(result.siteName);
122
+ return parts.length > 0 ? `[${parts.join(" · ")}]` : "";
123
+ }
124
+
110
125
  /**
111
126
  * Renders one search result as the structured mirror of its text form; hosts
112
127
  * that read structuredContent (every JSON-replacing client excepted) get this
@@ -118,6 +133,9 @@ export function toStructuredResult(result: SearchResult) {
118
133
  ...(result.title ? { title: result.title } : {}),
119
134
  ...(result.content ? { content: result.content } : {}),
120
135
  ...(result.published !== undefined ? { published: result.published } : {}),
136
+ ...(result.rerankScore !== undefined ? { rerankScore: result.rerankScore } : {}),
137
+ ...(result.authority !== undefined ? { authority: result.authority } : {}),
138
+ ...(result.siteName ? { siteName: result.siteName } : {}),
121
139
  };
122
140
  }
123
141
 
@@ -1,3 +1,4 @@
1
+ import { isIP } from "node:net";
1
2
  import {
2
3
  DEFAULT_API_KEY_SOURCE,
3
4
  DEFAULT_FETCH_MAX_CHARS,
@@ -35,15 +36,21 @@ export function resolvePluginOptions(input?: PluginOptions): ResolvedOptions {
35
36
  }
36
37
 
37
38
  export function resolveSearchOptions(input?: PluginOptions): SearchOptions {
39
+ const count = clampInt(input?.count, DEFAULT_SEARCH_COUNT, 1, 50);
38
40
  return {
39
- count: clampInt(input?.count, DEFAULT_SEARCH_COUNT, 1, 50),
40
- maxResults: clampInt(input?.maxResults, DEFAULT_MAX_RESULTS, 0, 50),
41
+ count,
42
+ // Constraint: maxResults follows count only when the user left it alone —
43
+ // an explicit maxResults still caps as before. count never follows
44
+ // maxResults: fetching more than the cap returns wastes nothing visible,
45
+ // while returning fewer than were fetched silently discards results.
46
+ maxResults: clampInt(input?.maxResults, Number.isFinite(input?.count) ? count : DEFAULT_MAX_RESULTS, 0, 50),
41
47
  minRerank: finiteNumber(input?.minRerank, DEFAULT_MIN_RERANK),
42
48
  minAuthority: finiteNumber(input?.minAuthority, DEFAULT_MIN_AUTHORITY),
43
49
  allowUnknownAuthority: input?.allowUnknownAuthority !== false,
44
50
  blockedDomains: normalizeDomains(input?.blockedDomains),
45
51
  includeDomains: normalizeDomains(input?.includeDomains),
46
52
  language: optionalText(input?.language),
53
+ localization: localizationValue(input?.searchLocalization),
47
54
  maxSummaryChars: clampInt(input?.maxSummaryChars, DEFAULT_MAX_SUMMARY_CHARS, 0, 1_000_000),
48
55
  timeoutMs: clampInt(input?.searchTimeoutMs, DEFAULT_TIMEOUT_MS, MIN_TIMEOUT_MS, MAX_TIMEOUT_MS),
49
56
  enabled: input?.searchEnabled !== false,
@@ -93,6 +100,19 @@ function optionalText(value: unknown): string | undefined {
93
100
  return trimmed ? trimmed : undefined;
94
101
  }
95
102
 
103
+ /**
104
+ * Constraint: only a valid IPv4 dotted-quad (four 0-255 octets) or IPv6
105
+ * address (including "::" compression) survives; anything else drops to
106
+ * undefined so the request omits `localization` entirely. This is the
107
+ * enforcement point no matter which config channel delivered the value.
108
+ */
109
+ function localizationValue(value: unknown): string | undefined {
110
+ const text = optionalText(value);
111
+ // isIP is the platform's strict parser; its one lax spot is IPv6 zone IDs
112
+ // ("%eth0"), which name a local link, not a geolocation — reject those.
113
+ return text !== undefined && !text.includes("%") && isIP(text) !== 0 ? text : undefined;
114
+ }
115
+
96
116
  function finiteNumber(value: unknown, fallback: number): number {
97
117
  return typeof value === "number" && Number.isFinite(value) ? value : fallback;
98
118
  }
@@ -67,11 +67,17 @@ export function processResults(raw: readonly DeepSeekSearchResult[], options: Se
67
67
  const content = truncate(optionalText(item.summary) ?? optionalText(item.snippet) ?? "", options.maxSummaryChars);
68
68
  const published = parsePublished(item.date);
69
69
  const title = optionalText(item.title);
70
+ const rerankScore = typeof rerank === "number" && Number.isFinite(rerank) ? rerank : undefined;
71
+ const authorityScore = typeof authority === "number" && Number.isFinite(authority) ? authority : undefined;
72
+ const siteName = optionalText(item.site_name);
70
73
  results.push({
71
74
  url,
72
75
  ...(title ? { title } : {}),
73
76
  ...(content ? { content } : {}),
74
77
  ...(published !== undefined ? { published } : {}),
78
+ ...(rerankScore !== undefined ? { rerankScore } : {}),
79
+ ...(authorityScore !== undefined ? { authority: authorityScore } : {}),
80
+ ...(siteName ? { siteName } : {}),
75
81
  });
76
82
  }
77
83
 
package/src/core/types.ts CHANGED
@@ -13,6 +13,12 @@ export interface SearchOptions {
13
13
  readonly blockedDomains: readonly string[];
14
14
  readonly includeDomains: readonly string[];
15
15
  readonly language?: string;
16
+ /**
17
+ * Deployment-level only — never a per-call tool parameter. A valid IPv4 or
18
+ * IPv6 address sent as DeepSeek's optional `localization` to prefer
19
+ * geo-localized results; invalid values are dropped at option resolution.
20
+ */
21
+ readonly localization?: string;
16
22
  readonly maxSummaryChars: number;
17
23
  readonly timeoutMs: number;
18
24
  readonly enabled: boolean;
@@ -46,6 +52,7 @@ export interface ResolvedOptions {
46
52
  export interface DeepSeekSearchResult {
47
53
  readonly rerank_score?: number;
48
54
  readonly authority?: number;
55
+ readonly site_name?: string | null;
49
56
  readonly title?: string | null;
50
57
  readonly url?: string | null;
51
58
  readonly snippet?: string | null;
@@ -59,6 +66,10 @@ export interface SearchResult {
59
66
  readonly title?: string;
60
67
  readonly content?: string;
61
68
  readonly published?: number;
69
+ readonly rerankScore?: number;
70
+ /** Faithful to the wire: -1 means unknown authority. */
71
+ readonly authority?: number;
72
+ readonly siteName?: string;
62
73
  }
63
74
 
64
75
  export interface FetchedPage {
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync, rmSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
+ import { TOOL_VERSION } from "../version";
3
4
  import { codecFor, writeConfig } from "./formats";
4
5
  import { deepEqual, valueAt } from "./formats/value";
5
6
  import { withOperationLock } from "./operation-lock";
@@ -8,9 +9,9 @@ import { pruneEmptyDir, revertRecordedFile } from "./revert";
8
9
  import { deleteRecord, readRecord, statePathFor, writeRecord } from "./state";
9
10
  import type { ConfigOp, FileEdit, InstallerInput, InstallPlan, InstallRecord, RecordedFile } from "./types";
10
11
 
12
+ /** What a successful apply reports; every failure mode throws instead. */
11
13
  export interface ApplyResult {
12
- readonly lines: string[];
13
- readonly ok: boolean;
14
+ readonly lines: readonly string[];
14
15
  }
15
16
 
16
17
  /** A failed apply tells callers whether retrying is safe or recovery is still needed. */
@@ -94,6 +95,7 @@ function applyLocked(plan: InstallPlan, input: InstallerInput): ApplyResult {
94
95
  lines.push(...verification.lines);
95
96
  const record: InstallRecord = {
96
97
  version: 1,
98
+ installerVersion: TOOL_VERSION,
97
99
  host: plan.host,
98
100
  scope: plan.scope,
99
101
  tiers: plan.tiers,
@@ -135,7 +137,7 @@ function applyLocked(plan: InstallPlan, input: InstallerInput): ApplyResult {
135
137
  error,
136
138
  );
137
139
  }
138
- return { lines, ok: true };
140
+ return { lines };
139
141
  }
140
142
 
141
143
  function errorMessage(error: unknown): string {
@@ -1,9 +1,14 @@
1
1
  import { homedir } from "node:os";
2
2
  import { resolve } from "node:path";
3
- import { applyPlan } from "./apply";
3
+ import { userConfigHome } from "../core/config-file";
4
+ import type { Env } from "../core/env";
5
+ import { TOOL_VERSION } from "../version";
6
+ import { ApplyError, applyPlan } from "./apply";
7
+ import { runConfigCommand } from "./config-command";
4
8
  import { HOST_PLANNERS, HOST_VERSION_PROBES, planForHost } from "./hosts";
9
+ import { pinPackageRef } from "./ownership";
5
10
  import { renderPlan } from "./render";
6
- import { listRecords } from "./state";
11
+ import { legacyGlobalStateDir, listRecords, readingLegacyGlobalRecords } from "./state";
7
12
  import type { HostName, InstallerInput, Scope, Tier } from "./types";
8
13
  import { uninstall } from "./uninstall";
9
14
 
@@ -12,12 +17,18 @@ const HOST_CHOICES = `<${HOSTS.join("|")}>`;
12
17
  const SCOPES: readonly Scope[] = ["project", "global"];
13
18
  const TIERS: readonly Tier[] = ["optional", "preferred", "forced", "off"];
14
19
 
20
+ const EXIT_FAILURE = 1;
21
+ const EXIT_USAGE = 2;
22
+ const EXIT_RECOVERY = 3;
23
+
15
24
  const USAGE = `Usage:
16
25
  deepseek-web-retrieval init --host ${HOST_CHOICES} --scope <project|global> \
17
26
  --search <optional|preferred|forced|off> --fetch <optional|preferred|forced|off> \
18
- [--path <extension|mcp>] [--package <npm ref>] [--cwd <dir>] [--apply]
27
+ [--path <extension|mcp>] [--package <npm ref>] [--pin] [--cwd <dir>] [--apply]
19
28
  deepseek-web-retrieval uninstall --host ${HOST_CHOICES} --scope <project|global> [--cwd <dir>]
20
29
  deepseek-web-retrieval list [--cwd <dir>]
30
+ deepseek-web-retrieval config [--search <query>]
31
+ deepseek-web-retrieval version
21
32
 
22
33
  init prints the full plan (every file, old value → new value) and writes nothing
23
34
  until --apply is passed. uninstall restores what the plan applied. --path picks
@@ -25,7 +36,22 @@ the integration mechanism for hosts with more than one (Pi: extension by
25
36
  default, mcp for the stdio MCP server). --package overrides the package ref
26
37
  written into host config; the Pi extension path turns it into the packages
27
38
  source (a bare name is prefixed with npm:, a ref that already starts with npm:
28
- 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.
50
+
51
+ Exit codes: 0 success; 1 refused or failed with nothing written (a failed apply
52
+ is fully rolled back); 2 invalid command line; 3 recovery required — a failed
53
+ apply could not roll back completely, follow the reported guidance before
54
+ re-running.`;
29
55
 
30
56
  interface Flags {
31
57
  readonly values: Map<string, string>;
@@ -34,7 +60,7 @@ interface Flags {
34
60
  }
35
61
 
36
62
  const VALUE_FLAGS = new Set(["host", "scope", "search", "fetch", "path", "package", "cwd"]);
37
- const BOOLEAN_FLAGS = new Set(["apply"]);
63
+ const BOOLEAN_FLAGS = new Set(["apply", "pin"]);
38
64
  const PATH_CHOICES: readonly ["extension", "mcp"] = ["extension", "mcp"];
39
65
 
40
66
  function parseFlags(args: readonly string[]): Flags {
@@ -54,6 +80,10 @@ function parseFlags(args: readonly string[]): Flags {
54
80
  problems.push(`--${name} does not accept a value; use --${name} to enable it or omit it`);
55
81
  continue;
56
82
  }
83
+ if (booleans.has(name)) {
84
+ problems.push(`--${name} was given more than once`);
85
+ continue;
86
+ }
57
87
  booleans.add(name);
58
88
  continue;
59
89
  }
@@ -61,6 +91,10 @@ function parseFlags(args: readonly string[]): Flags {
61
91
  problems.push(`unknown flag --${name}`);
62
92
  continue;
63
93
  }
94
+ if (values.has(name)) {
95
+ problems.push(`--${name} was given more than once`);
96
+ continue;
97
+ }
64
98
  if (eq !== -1) {
65
99
  values.set(name, arg.slice(eq + 1));
66
100
  continue;
@@ -80,6 +114,8 @@ function parseFlags(args: readonly string[]): Flags {
80
114
  export interface CliIo {
81
115
  readonly out?: (line: string) => void;
82
116
  readonly err?: (line: string) => void;
117
+ /** Platform the CLI resolves paths for; tests pin "win32" on any host. */
118
+ readonly platform?: NodeJS.Platform;
83
119
  }
84
120
 
85
121
  /** CLI entry for the init/uninstall/list subcommands; returns the process exit code. */
@@ -87,24 +123,34 @@ export async function runInstallerCli(argv: readonly string[], io: CliIo = {}):
87
123
  const out = io.out ?? ((line: string) => console.log(line));
88
124
  const err = io.err ?? ((line: string) => console.error(line));
89
125
  const [command, ...rest] = argv;
90
- if (command === undefined || command === "help" || command === "--help") {
126
+ if (command === "version" || command === "--version") {
127
+ out(TOOL_VERSION);
128
+ return 0;
129
+ }
130
+ if (command === undefined) {
131
+ err(USAGE);
132
+ return EXIT_USAGE;
133
+ }
134
+ if (command === "help" || command === "--help") {
91
135
  out(USAGE);
92
- return command === undefined ? 1 : 0;
136
+ return 0;
93
137
  }
94
138
  const flags = parseFlags(rest);
95
139
  if (flags.problems.length > 0) {
96
140
  for (const problem of flags.problems) err(`error: ${problem}`);
97
141
  err(`\n${USAGE}`);
98
- return 1;
142
+ return EXIT_USAGE;
99
143
  }
100
144
  const cwd = resolve(flags.values.get("cwd") ?? process.cwd());
101
145
  const home = process.env.HOME ?? homedir();
102
- const configHome = resolveConfigHome(home);
146
+ const platform = io.platform ?? process.platform;
147
+ const roots = { home, cwd, platform } as const;
148
+ const configHome = resolveConfigHome(process.env, home, platform);
103
149
 
104
150
  if (command === "list") {
105
151
  let records: ReturnType<typeof listRecords>;
106
152
  try {
107
- records = listRecords({ home, cwd, configHome });
153
+ records = listRecords({ ...roots, configHome });
108
154
  } catch (error) {
109
155
  err(error instanceof Error ? error.message : String(error));
110
156
  return 1;
@@ -113,17 +159,28 @@ export async function runInstallerCli(argv: readonly string[], io: CliIo = {}):
113
159
  out("No installs recorded.");
114
160
  return 0;
115
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
+ }
116
168
  for (const record of records) {
169
+ const by = record.installerVersion ? ` by v${record.installerVersion}` : "";
117
170
  out(
118
- `${record.host} (${record.scope}) — search=${record.tiers.search}, fetch=${record.tiers.fetch}, installed ${record.createdAt}`,
171
+ `${record.host} (${record.scope}) — search=${record.tiers.search}, fetch=${record.tiers.fetch}, installed ${record.createdAt}${by}`,
119
172
  );
120
173
  for (const file of record.files) out(` ${file.created ? "created" : "modified"} ${file.file}`);
121
174
  }
122
175
  return 0;
123
176
  }
124
177
 
178
+ if (command === "config") {
179
+ return runConfigCommand(flags.values.get("search"), { out, err });
180
+ }
181
+
125
182
  if (command === "uninstall") {
126
- const required = requireInput(flags, cwd, home, configHome, false);
183
+ const required = requireInput(flags, cwd, home, configHome, platform, false);
127
184
  if (!required.ok) return usageError(required.problems, err);
128
185
  try {
129
186
  for (const line of uninstall(required.input)) out(line);
@@ -136,10 +193,10 @@ export async function runInstallerCli(argv: readonly string[], io: CliIo = {}):
136
193
 
137
194
  if (command !== "init") {
138
195
  err(`Unknown command "${command}".\n\n${USAGE}`);
139
- return 1;
196
+ return EXIT_USAGE;
140
197
  }
141
198
 
142
- const required = requireInput(flags, cwd, home, configHome, true);
199
+ const required = requireInput(flags, cwd, home, configHome, platform, true);
143
200
  if (!required.ok) return usageError(required.problems, err);
144
201
  const { input } = required;
145
202
  const probe = HOST_VERSION_PROBES[input.host];
@@ -158,27 +215,34 @@ export async function runInstallerCli(argv: readonly string[], io: CliIo = {}):
158
215
  try {
159
216
  const result = applyPlan(plan, input);
160
217
  for (const line of result.lines) out(line);
161
- return result.ok ? 0 : 1;
218
+ return 0;
162
219
  } catch (error) {
163
220
  err(error instanceof Error ? error.message : String(error));
164
- return 1;
221
+ return exitCodeForError(error);
165
222
  }
166
223
  }
167
224
 
225
+ /** Maps a thrown error to its exit code; recovery is the case a script must not retry blindly. */
226
+ export function exitCodeForError(error: unknown): number {
227
+ return error instanceof ApplyError && error.status === "recovery_required" ? EXIT_RECOVERY : EXIT_FAILURE;
228
+ }
229
+
168
230
  function usageError(problems: readonly string[], err: (line: string) => void): number {
169
231
  for (const problem of problems) err(`error: ${problem}`);
170
232
  err(`\n${USAGE}`);
171
- return 1;
233
+ return EXIT_USAGE;
172
234
  }
173
235
 
174
236
  /**
175
- * XDG_CONFIG_HOME is honored only when it belongs to the real home; the one
176
- * environment read happens here, at the edge, and flows down as explicit input.
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).
177
242
  */
178
- function resolveConfigHome(home: string): string | undefined {
179
- const xdg = process.env.XDG_CONFIG_HOME;
180
- if (xdg && home === (process.env.HOME ?? "")) return xdg;
181
- 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 });
182
246
  }
183
247
 
184
248
  type RequireInputResult =
@@ -189,7 +253,8 @@ function requireInput(
189
253
  flags: Flags,
190
254
  cwd: string,
191
255
  home: string,
192
- configHome: string | undefined,
256
+ configHome: string,
257
+ platform: NodeJS.Platform,
193
258
  needsTiers: boolean,
194
259
  ): RequireInputResult {
195
260
  const problems: string[] = [];
@@ -229,6 +294,7 @@ function requireInput(
229
294
  }
230
295
  if (home === "") problems.push("HOME is not set and the user home cannot be resolved; cannot proceed");
231
296
  if (problems.length > 0) return { ok: false, problems };
297
+ const packageRef = flags.values.get("package") ?? "deepseek-web-retrieval";
232
298
  return {
233
299
  ok: true,
234
300
  input: {
@@ -236,10 +302,11 @@ function requireInput(
236
302
  scope: scope as Scope,
237
303
  search: search as Tier,
238
304
  fetch: fetch as Tier,
239
- packageRef: flags.values.get("package") ?? "deepseek-web-retrieval",
305
+ packageRef: flags.booleans.has("pin") ? pinPackageRef(packageRef, TOOL_VERSION) : packageRef,
240
306
  home,
241
307
  cwd,
242
308
  configHome,
309
+ platform,
243
310
  ...(path !== undefined ? { path: path as "extension" | "mcp" } : {}),
244
311
  },
245
312
  };