deepseek-web-retrieval 0.6.0 → 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 +12 -1
- package/README.md +79 -10
- package/dist/cli.js +2203 -1826
- package/docs/adr/0002-shared-config-base-layer.md +43 -0
- package/docs/agents/codex.md +48 -27
- package/docs/agents/opencode-v2.md +57 -33
- package/docs/agents/pi.md +48 -27
- package/docs/design/config-layering-design.md +180 -0
- package/docs/design/installer-design.md +8 -0
- package/docs/integration.md +6 -3
- package/docs/research/control-matrix.md +15 -2
- package/docs/research/mcp-compatibility.md +4 -1
- 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/cli.ts +50 -16
- 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 +72 -19
- package/src/installer/types.ts +10 -0
- package/src/mcp/auth.ts +2 -3
- package/src/mcp/cli.ts +4 -3
- package/src/mcp/serve.ts +10 -1
- package/src/mcp/server.ts +7 -2
- package/src/opencode/plugin.ts +22 -2
- package/src/pi/extension.ts +78 -9
package/src/core/descriptions.ts
CHANGED
|
@@ -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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
63
|
-
putNumber(options, "
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
putDomains(options, "
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
putFalseOnly(options, "
|
|
71
|
-
putFalseOnly(options, "
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
putNumber(options, "
|
|
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
|
-
|
|
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(
|
|
96
|
-
|
|
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(
|
|
100
|
-
|
|
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 {
|
package/src/core/format.ts
CHANGED
|
@@ -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
|
|
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
|
|
package/src/core/options.ts
CHANGED
|
@@ -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
|
|
40
|
-
|
|
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
|
}
|
package/src/core/search.ts
CHANGED
|
@@ -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 {
|
package/src/installer/cli.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
|
+
import { userConfigHome } from "../core/config-file";
|
|
4
|
+
import type { Env } from "../core/env";
|
|
3
5
|
import { TOOL_VERSION } from "../version";
|
|
4
6
|
import { ApplyError, applyPlan } from "./apply";
|
|
7
|
+
import { runConfigCommand } from "./config-command";
|
|
5
8
|
import { HOST_PLANNERS, HOST_VERSION_PROBES, planForHost } from "./hosts";
|
|
9
|
+
import { pinPackageRef } from "./ownership";
|
|
6
10
|
import { renderPlan } from "./render";
|
|
7
|
-
import { listRecords } from "./state";
|
|
11
|
+
import { legacyGlobalStateDir, listRecords, readingLegacyGlobalRecords } from "./state";
|
|
8
12
|
import type { HostName, InstallerInput, Scope, Tier } from "./types";
|
|
9
13
|
import { uninstall } from "./uninstall";
|
|
10
14
|
|
|
@@ -20,9 +24,10 @@ const EXIT_RECOVERY = 3;
|
|
|
20
24
|
const USAGE = `Usage:
|
|
21
25
|
deepseek-web-retrieval init --host ${HOST_CHOICES} --scope <project|global> \
|
|
22
26
|
--search <optional|preferred|forced|off> --fetch <optional|preferred|forced|off> \
|
|
23
|
-
[--path <extension|mcp>] [--package <npm ref>] [--cwd <dir>] [--apply]
|
|
27
|
+
[--path <extension|mcp>] [--package <npm ref>] [--pin] [--cwd <dir>] [--apply]
|
|
24
28
|
deepseek-web-retrieval uninstall --host ${HOST_CHOICES} --scope <project|global> [--cwd <dir>]
|
|
25
29
|
deepseek-web-retrieval list [--cwd <dir>]
|
|
30
|
+
deepseek-web-retrieval config [--search <query>]
|
|
26
31
|
deepseek-web-retrieval version
|
|
27
32
|
|
|
28
33
|
init prints the full plan (every file, old value → new value) and writes nothing
|
|
@@ -31,7 +36,17 @@ the integration mechanism for hosts with more than one (Pi: extension by
|
|
|
31
36
|
default, mcp for the stdio MCP server). --package overrides the package ref
|
|
32
37
|
written into host config; the Pi extension path turns it into the packages
|
|
33
38
|
source (a bare name is prefixed with npm:, a ref that already starts with npm:
|
|
34
|
-
is written as-is).
|
|
39
|
+
is written as-is). --pin appends the running CLI's version to the package ref
|
|
40
|
+
(a ref already ending in a version keeps it), so hosts resolve that exact
|
|
41
|
+
release instead of the latest publish; re-run init --pin to move the pin.
|
|
42
|
+
|
|
43
|
+
config prints the effective configuration — every knob with its value and its
|
|
44
|
+
source (default, shared file, or environment), resolved the way the MCP server
|
|
45
|
+
resolves it — and exits 1 when the config file or the DEEPSEEK_* environment
|
|
46
|
+
has problems (unknown or rejected keys, unparseable JSON, set-but-unusable
|
|
47
|
+
variable values; a missing file is fine). config --search <query>
|
|
48
|
+
runs one billed search through that configuration to verify the key, config,
|
|
49
|
+
and network end to end; it exits 1 when the key is missing or the search fails.
|
|
35
50
|
|
|
36
51
|
Exit codes: 0 success; 1 refused or failed with nothing written (a failed apply
|
|
37
52
|
is fully rolled back); 2 invalid command line; 3 recovery required — a failed
|
|
@@ -45,7 +60,7 @@ interface Flags {
|
|
|
45
60
|
}
|
|
46
61
|
|
|
47
62
|
const VALUE_FLAGS = new Set(["host", "scope", "search", "fetch", "path", "package", "cwd"]);
|
|
48
|
-
const BOOLEAN_FLAGS = new Set(["apply"]);
|
|
63
|
+
const BOOLEAN_FLAGS = new Set(["apply", "pin"]);
|
|
49
64
|
const PATH_CHOICES: readonly ["extension", "mcp"] = ["extension", "mcp"];
|
|
50
65
|
|
|
51
66
|
function parseFlags(args: readonly string[]): Flags {
|
|
@@ -99,6 +114,8 @@ function parseFlags(args: readonly string[]): Flags {
|
|
|
99
114
|
export interface CliIo {
|
|
100
115
|
readonly out?: (line: string) => void;
|
|
101
116
|
readonly err?: (line: string) => void;
|
|
117
|
+
/** Platform the CLI resolves paths for; tests pin "win32" on any host. */
|
|
118
|
+
readonly platform?: NodeJS.Platform;
|
|
102
119
|
}
|
|
103
120
|
|
|
104
121
|
/** CLI entry for the init/uninstall/list subcommands; returns the process exit code. */
|
|
@@ -126,12 +143,14 @@ export async function runInstallerCli(argv: readonly string[], io: CliIo = {}):
|
|
|
126
143
|
}
|
|
127
144
|
const cwd = resolve(flags.values.get("cwd") ?? process.cwd());
|
|
128
145
|
const home = process.env.HOME ?? homedir();
|
|
129
|
-
const
|
|
146
|
+
const platform = io.platform ?? process.platform;
|
|
147
|
+
const roots = { home, cwd, platform } as const;
|
|
148
|
+
const configHome = resolveConfigHome(process.env, home, platform);
|
|
130
149
|
|
|
131
150
|
if (command === "list") {
|
|
132
151
|
let records: ReturnType<typeof listRecords>;
|
|
133
152
|
try {
|
|
134
|
-
records = listRecords({
|
|
153
|
+
records = listRecords({ ...roots, configHome });
|
|
135
154
|
} catch (error) {
|
|
136
155
|
err(error instanceof Error ? error.message : String(error));
|
|
137
156
|
return 1;
|
|
@@ -140,6 +159,12 @@ export async function runInstallerCli(argv: readonly string[], io: CliIo = {}):
|
|
|
140
159
|
out("No installs recorded.");
|
|
141
160
|
return 0;
|
|
142
161
|
}
|
|
162
|
+
if (readingLegacyGlobalRecords({ ...roots, configHome })) {
|
|
163
|
+
out(
|
|
164
|
+
`Global install records are being read from the legacy ${legacyGlobalStateDir(roots)} ` +
|
|
165
|
+
"(written before the Windows config home moved to %APPDATA%).",
|
|
166
|
+
);
|
|
167
|
+
}
|
|
143
168
|
for (const record of records) {
|
|
144
169
|
const by = record.installerVersion ? ` by v${record.installerVersion}` : "";
|
|
145
170
|
out(
|
|
@@ -150,8 +175,12 @@ export async function runInstallerCli(argv: readonly string[], io: CliIo = {}):
|
|
|
150
175
|
return 0;
|
|
151
176
|
}
|
|
152
177
|
|
|
178
|
+
if (command === "config") {
|
|
179
|
+
return runConfigCommand(flags.values.get("search"), { out, err });
|
|
180
|
+
}
|
|
181
|
+
|
|
153
182
|
if (command === "uninstall") {
|
|
154
|
-
const required = requireInput(flags, cwd, home, configHome, false);
|
|
183
|
+
const required = requireInput(flags, cwd, home, configHome, platform, false);
|
|
155
184
|
if (!required.ok) return usageError(required.problems, err);
|
|
156
185
|
try {
|
|
157
186
|
for (const line of uninstall(required.input)) out(line);
|
|
@@ -167,7 +196,7 @@ export async function runInstallerCli(argv: readonly string[], io: CliIo = {}):
|
|
|
167
196
|
return EXIT_USAGE;
|
|
168
197
|
}
|
|
169
198
|
|
|
170
|
-
const required = requireInput(flags, cwd, home, configHome, true);
|
|
199
|
+
const required = requireInput(flags, cwd, home, configHome, platform, true);
|
|
171
200
|
if (!required.ok) return usageError(required.problems, err);
|
|
172
201
|
const { input } = required;
|
|
173
202
|
const probe = HOST_VERSION_PROBES[input.host];
|
|
@@ -205,13 +234,15 @@ function usageError(problems: readonly string[], err: (line: string) => void): n
|
|
|
205
234
|
}
|
|
206
235
|
|
|
207
236
|
/**
|
|
208
|
-
*
|
|
209
|
-
*
|
|
237
|
+
* The installer edge's one config-home resolution, built on the canonical
|
|
238
|
+
* helper (userConfigHome): an inherited XDG_CONFIG_HOME is honored only when it
|
|
239
|
+
* belongs to the real home — a rewritten HOME must not smuggle in someone
|
|
240
|
+
* else's config root — and every other case takes the helper's platform
|
|
241
|
+
* default (win32: %APPDATA% or `<home>\AppData\Roaming`; otherwise ~/.config).
|
|
210
242
|
*/
|
|
211
|
-
function resolveConfigHome(home: string): string
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
return undefined;
|
|
243
|
+
export function resolveConfigHome(env: Env, home: string, platform: NodeJS.Platform = process.platform): string {
|
|
244
|
+
const xdgTrusted = env.XDG_CONFIG_HOME !== undefined && env.XDG_CONFIG_HOME !== "" && home === (env.HOME ?? "");
|
|
245
|
+
return userConfigHome({ env: xdgTrusted ? env : { ...env, XDG_CONFIG_HOME: undefined }, home, platform });
|
|
215
246
|
}
|
|
216
247
|
|
|
217
248
|
type RequireInputResult =
|
|
@@ -222,7 +253,8 @@ function requireInput(
|
|
|
222
253
|
flags: Flags,
|
|
223
254
|
cwd: string,
|
|
224
255
|
home: string,
|
|
225
|
-
configHome: string
|
|
256
|
+
configHome: string,
|
|
257
|
+
platform: NodeJS.Platform,
|
|
226
258
|
needsTiers: boolean,
|
|
227
259
|
): RequireInputResult {
|
|
228
260
|
const problems: string[] = [];
|
|
@@ -262,6 +294,7 @@ function requireInput(
|
|
|
262
294
|
}
|
|
263
295
|
if (home === "") problems.push("HOME is not set and the user home cannot be resolved; cannot proceed");
|
|
264
296
|
if (problems.length > 0) return { ok: false, problems };
|
|
297
|
+
const packageRef = flags.values.get("package") ?? "deepseek-web-retrieval";
|
|
265
298
|
return {
|
|
266
299
|
ok: true,
|
|
267
300
|
input: {
|
|
@@ -269,10 +302,11 @@ function requireInput(
|
|
|
269
302
|
scope: scope as Scope,
|
|
270
303
|
search: search as Tier,
|
|
271
304
|
fetch: fetch as Tier,
|
|
272
|
-
packageRef: flags.
|
|
305
|
+
packageRef: flags.booleans.has("pin") ? pinPackageRef(packageRef, TOOL_VERSION) : packageRef,
|
|
273
306
|
home,
|
|
274
307
|
cwd,
|
|
275
308
|
configHome,
|
|
309
|
+
platform,
|
|
276
310
|
...(path !== undefined ? { path: path as "extension" | "mcp" } : {}),
|
|
277
311
|
},
|
|
278
312
|
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { apiKeyFromEnv, MISSING_API_KEY_MESSAGE } from "../core/auth";
|
|
2
|
+
import { createRetrievalClient, type RetrievalClient } from "../core/client";
|
|
3
|
+
import { configFilePath, readFileOptionsReport } from "../core/config-file";
|
|
4
|
+
import { configSummaryValues, formatConfigValue } from "../core/config-summary";
|
|
5
|
+
import type { Env } from "../core/env";
|
|
6
|
+
import { envOptionsReport, resolveEnvOptions } from "../core/env";
|
|
7
|
+
import { formatSearchResults } from "../core/format";
|
|
8
|
+
|
|
9
|
+
const EXIT_CONFIG_PROBLEMS = 1;
|
|
10
|
+
const EXIT_USAGE = 2;
|
|
11
|
+
|
|
12
|
+
const HOST_OVERRIDE_NOTE =
|
|
13
|
+
"Host-native layers (OpenCode options, Pi settings) may override file and env values per host.";
|
|
14
|
+
|
|
15
|
+
/** Where the config command writes; the installer CLI resolves its CliIo into this. */
|
|
16
|
+
export interface ConfigCommandIo {
|
|
17
|
+
readonly out: (line: string) => void;
|
|
18
|
+
readonly err: (line: string) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Injectable seams: the environment to resolve, and the client for --search. */
|
|
22
|
+
export interface ConfigCommandDeps {
|
|
23
|
+
readonly env?: Env;
|
|
24
|
+
readonly client?: RetrievalClient;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The `config` subcommand: the user-facing arm of configuration visibility.
|
|
29
|
+
* Prints the effective options exactly as the MCP server would resolve them
|
|
30
|
+
* (defaults < shared file < environment), each knob with its value and source,
|
|
31
|
+
* and replays the problems that would only whisper past on stderr at runtime —
|
|
32
|
+
* shared-file problems and set-but-unusable `DEEPSEEK_*` variables alike. With
|
|
33
|
+
* `query` set, runs one real billed search through that config — the
|
|
34
|
+
* end-to-end verifier for key, config, and network. Returns the exit code: 0
|
|
35
|
+
* normally, 1 for file or environment problems (plain form) or a failed
|
|
36
|
+
* search, 2 for a blank --search query. A missing file is not a problem.
|
|
37
|
+
*/
|
|
38
|
+
export async function runConfigCommand(
|
|
39
|
+
query: string | undefined,
|
|
40
|
+
io: ConfigCommandIo,
|
|
41
|
+
deps: ConfigCommandDeps = {},
|
|
42
|
+
): Promise<number> {
|
|
43
|
+
if (query !== undefined && !query.trim()) {
|
|
44
|
+
io.err('error: --search requires a query (for example: config --search "deepseek mcp")');
|
|
45
|
+
return EXIT_USAGE;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const env = deps.env ?? process.env;
|
|
49
|
+
const file = configFilePath({ env });
|
|
50
|
+
const report = readFileOptionsReport(file);
|
|
51
|
+
for (const warning of report.warnings) io.err(`warning: ${warning}`);
|
|
52
|
+
const envReport = envOptionsReport(env);
|
|
53
|
+
for (const warning of envReport.warnings) io.err(`warning: ${warning}`);
|
|
54
|
+
|
|
55
|
+
// The same layers, in the same order, as the MCP server's startup resolution.
|
|
56
|
+
const resolved = resolveEnvOptions(env, report.options);
|
|
57
|
+
|
|
58
|
+
if (query === undefined) {
|
|
59
|
+
printConfigReport(file, report.exists, report.options, envReport.options, resolved, io);
|
|
60
|
+
return report.warnings.length > 0 || envReport.warnings.length > 0 ? EXIT_CONFIG_PROBLEMS : 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const apiKey = apiKeyFromEnv(env);
|
|
64
|
+
if (!apiKey) {
|
|
65
|
+
io.err(MISSING_API_KEY_MESSAGE);
|
|
66
|
+
return EXIT_CONFIG_PROBLEMS;
|
|
67
|
+
}
|
|
68
|
+
const client = deps.client ?? createRetrievalClient();
|
|
69
|
+
const startedAt = Date.now();
|
|
70
|
+
try {
|
|
71
|
+
const results = await client.searchWeb(query, resolved.search, {
|
|
72
|
+
signal: new AbortController().signal,
|
|
73
|
+
auth: { apiKey },
|
|
74
|
+
retry: resolved.retry,
|
|
75
|
+
});
|
|
76
|
+
const elapsedMs = Date.now() - startedAt;
|
|
77
|
+
io.out(
|
|
78
|
+
`search "${query.trim()}" — ${results.length} ${results.length === 1 ? "result" : "results"} in ${elapsedMs} ms`,
|
|
79
|
+
);
|
|
80
|
+
io.out("");
|
|
81
|
+
for (const line of formatSearchResults(results).split("\n")) io.out(line);
|
|
82
|
+
return 0;
|
|
83
|
+
} catch (error) {
|
|
84
|
+
io.err(error instanceof Error ? error.message : String(error));
|
|
85
|
+
return EXIT_CONFIG_PROBLEMS;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The knob table, in the vocabulary order the startup deviation summary uses. */
|
|
90
|
+
function printConfigReport(
|
|
91
|
+
file: string,
|
|
92
|
+
exists: boolean,
|
|
93
|
+
fileOptions: Readonly<Record<string, unknown>>,
|
|
94
|
+
envOptions: Readonly<Record<string, unknown>>,
|
|
95
|
+
resolved: ReturnType<typeof resolveEnvOptions>,
|
|
96
|
+
io: ConfigCommandIo,
|
|
97
|
+
): void {
|
|
98
|
+
io.out(`Config file: ${file} (${exists ? "exists" : "missing"})`);
|
|
99
|
+
io.out("");
|
|
100
|
+
const effective = configSummaryValues(resolved);
|
|
101
|
+
const fileKeys = new Set(Object.keys(fileOptions));
|
|
102
|
+
const envKeys = new Set(Object.keys(envOptions));
|
|
103
|
+
for (const name of Object.keys(effective)) {
|
|
104
|
+
// The environment wins over the file for the same key, exactly as resolution does.
|
|
105
|
+
let source = envKeys.has(name) ? "env" : fileKeys.has(name) ? "file" : "default";
|
|
106
|
+
// maxResults follows the effective count when unset (options.ts), so when
|
|
107
|
+
// the count knob was configured its derived value is not "default" — its
|
|
108
|
+
// source is the count setting that drove it.
|
|
109
|
+
if (name === "maxResults" && source === "default" && (envKeys.has("count") || fileKeys.has("count"))) {
|
|
110
|
+
source = "count";
|
|
111
|
+
}
|
|
112
|
+
io.out(`${name} = ${formatConfigValue(effective[name], "join")} (${source})`);
|
|
113
|
+
}
|
|
114
|
+
io.out("");
|
|
115
|
+
io.out(HOST_OVERRIDE_NOTE);
|
|
116
|
+
}
|
|
@@ -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(
|