deepseek-web-retrieval 0.7.0 → 0.9.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 +20 -0
- package/README.md +100 -101
- package/dist/cli.js +526 -175
- package/docs/agents/claude-code.md +279 -0
- package/docs/agents/codex.md +9 -1
- package/docs/agents/opencode-v2.md +13 -3
- package/docs/agents/pi.md +8 -1
- package/docs/configuration.md +116 -0
- package/docs/design/installer-design.md +20 -17
- package/docs/hosts.jsonc +5 -1
- package/docs/integration.md +9 -6
- package/docs/research/control-matrix.md +34 -9
- package/docs/research/mcp-compatibility.md +2 -2
- package/docs/research/ori-question-sourcing.md +12 -1
- package/package.json +3 -2
- package/src/core/auth.ts +7 -0
- package/src/core/client.ts +89 -25
- package/src/core/config-summary.ts +1 -0
- package/src/core/constants.ts +17 -2
- package/src/core/descriptions.ts +10 -1
- package/src/core/env.ts +12 -7
- package/src/core/format.ts +25 -4
- package/src/core/options.ts +47 -0
- package/src/core/search.ts +97 -14
- package/src/core/types.ts +17 -0
- package/src/installer/apply.ts +4 -4
- package/src/installer/cli.ts +2 -2
- package/src/installer/config-command.ts +4 -3
- package/src/installer/hosts/claude-code.ts +311 -0
- package/src/installer/hosts/codex.ts +3 -14
- package/src/installer/hosts/index.ts +5 -2
- package/src/installer/hosts/opencode.ts +1 -1
- package/src/installer/hosts/pi.ts +38 -18
- package/src/installer/hosts/shared.ts +23 -0
- package/src/installer/ownership.ts +2 -2
- package/src/installer/paths.ts +2 -2
- package/src/installer/{state.ts → record.ts} +16 -16
- package/src/installer/types.ts +2 -2
- package/src/installer/uninstall.ts +8 -5
- package/src/mcp/serve.ts +1 -2
- package/src/mcp/server.ts +16 -19
- package/src/opencode/plugin.ts +15 -10
- package/src/pi/extension.ts +23 -17
- package/src/mcp/auth.ts +0 -11
package/src/core/search.ts
CHANGED
|
@@ -1,25 +1,103 @@
|
|
|
1
|
+
import { MAX_QUERY_CHARS } from "./constants";
|
|
1
2
|
import { normalizeDomains } from "./domains";
|
|
2
3
|
import { truncate } from "./format";
|
|
3
4
|
import type { DeepSeekSearchResult, SearchOptions, SearchResult } from "./types";
|
|
4
5
|
import { normalizeResultUrl } from "./url";
|
|
5
6
|
|
|
7
|
+
/** The fewest body characters a degraded query keeps before operators start dropping. */
|
|
8
|
+
const MIN_BODY_CHARS = 8;
|
|
9
|
+
|
|
10
|
+
function charCount(value: string): number {
|
|
11
|
+
return Array.from(value).length;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The query string plus every adjustment made to fit it under the length limit. */
|
|
15
|
+
export interface ComposedQuery {
|
|
16
|
+
readonly query: string;
|
|
17
|
+
readonly notes: readonly string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface OperatorParts {
|
|
21
|
+
readonly blocked: readonly string[];
|
|
22
|
+
readonly include?: string;
|
|
23
|
+
readonly language?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
6
26
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* include: site:a.com OR site:b.com
|
|
10
|
-
* language: lang:zh or lang:zh,en
|
|
27
|
+
* Operators are official API surface, documented in the guide's search-syntax
|
|
28
|
+
* section (搜索语法): site:, -site:, lang:, quoted phrases, after:/before:.
|
|
11
29
|
*/
|
|
12
|
-
|
|
30
|
+
function operatorParts(options: Pick<SearchOptions, "blockedDomains" | "includeDomains" | "language">): OperatorParts {
|
|
31
|
+
const blocked = normalizeDomains(options.blockedDomains).map((domain) => `-site:${domain}`);
|
|
32
|
+
const include = normalizeDomains(options.includeDomains).map((domain) => `site:${domain}`);
|
|
33
|
+
const language = options.language?.trim().replace(/^lang:/i, "");
|
|
34
|
+
return {
|
|
35
|
+
blocked,
|
|
36
|
+
include: include.length ? include.join(" OR ") : undefined,
|
|
37
|
+
language: language ? `lang:${language}` : undefined,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function assemble(body: string, parts: OperatorParts, withInclude: boolean, withLang: boolean): string {
|
|
42
|
+
const segments = [body, ...parts.blocked];
|
|
43
|
+
if (withInclude && parts.include) segments.push(parts.include);
|
|
44
|
+
if (withLang && parts.language) segments.push(parts.language);
|
|
45
|
+
return segments.filter((segment) => segment.length > 0).join(" ");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Characters left for the query body once the kept operators and separators are paid for. */
|
|
49
|
+
function bodyBudget(parts: OperatorParts, withInclude: boolean, withLang: boolean, maxChars: number): number {
|
|
50
|
+
const fixed = [
|
|
51
|
+
...parts.blocked,
|
|
52
|
+
...(withInclude && parts.include ? [parts.include] : []),
|
|
53
|
+
...(withLang && parts.language ? [parts.language] : []),
|
|
54
|
+
].join(" ");
|
|
55
|
+
return maxChars - (fixed ? charCount(fixed) + 1 : 0);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Builds the wire query within the API's character budget, degrading instead of
|
|
60
|
+
* failing when operators push it over: the query body is truncated first (user
|
|
61
|
+
* configuration outranks model verbosity), the language filter drops next, the
|
|
62
|
+
* include list last; blocked domains never drop. Only when the -site:
|
|
63
|
+
* operators alone crowd out the body does the call fail. Every adjustment is
|
|
64
|
+
* reported in `notes` so the model can be shown what was actually searched.
|
|
65
|
+
*/
|
|
66
|
+
export function composeSearchQuery(
|
|
13
67
|
query: string,
|
|
14
68
|
options: Pick<SearchOptions, "blockedDomains" | "includeDomains" | "language">,
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
69
|
+
maxChars: number = MAX_QUERY_CHARS,
|
|
70
|
+
): ComposedQuery {
|
|
71
|
+
const parts = operatorParts(options);
|
|
72
|
+
const body = query.trim();
|
|
73
|
+
const notes: string[] = [];
|
|
74
|
+
|
|
75
|
+
const attempt = (withInclude: boolean, withLang: boolean): string | undefined => {
|
|
76
|
+
const budget = bodyBudget(parts, withInclude, withLang, maxChars);
|
|
77
|
+
if (charCount(body) <= budget) return assemble(body, parts, withInclude, withLang);
|
|
78
|
+
if (budget >= MIN_BODY_CHARS) {
|
|
79
|
+
notes.push(`query truncated to ${budget} characters (query length limit)`);
|
|
80
|
+
return assemble(truncate(body, budget), parts, withInclude, withLang);
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
let assembled = attempt(true, true);
|
|
86
|
+
if (assembled === undefined && parts.language) {
|
|
87
|
+
notes.push("language filter dropped (query length limit)");
|
|
88
|
+
assembled = attempt(true, false);
|
|
89
|
+
}
|
|
90
|
+
if (assembled === undefined && parts.include) {
|
|
91
|
+
notes.push("includeDomains filter dropped (query length limit)");
|
|
92
|
+
assembled = attempt(false, false);
|
|
93
|
+
}
|
|
94
|
+
if (assembled === undefined) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`DeepSeek search query cannot fit ${maxChars} characters: the blockedDomains -site: ` +
|
|
97
|
+
`operators alone occupy ${charCount(parts.blocked.join(" "))} characters`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return { query: assembled, notes };
|
|
23
101
|
}
|
|
24
102
|
|
|
25
103
|
export function parsePublished(value: unknown): number | undefined {
|
|
@@ -64,7 +142,12 @@ export function processResults(raw: readonly DeepSeekSearchResult[], options: Se
|
|
|
64
142
|
}
|
|
65
143
|
|
|
66
144
|
seen.add(url);
|
|
67
|
-
const
|
|
145
|
+
const rawContent = optionalText(item.summary) ?? optionalText(item.snippet) ?? "";
|
|
146
|
+
const cutContent = truncate(rawContent, options.maxSummaryChars);
|
|
147
|
+
// The ellipsis marks a summary the cap actually shortened; official
|
|
148
|
+
// summaries are typically 600-800 characters, so the 1,200 default
|
|
149
|
+
// rarely triggers it.
|
|
150
|
+
const content = cutContent && charCount(rawContent) > options.maxSummaryChars ? `${cutContent}…` : cutContent;
|
|
68
151
|
const published = parsePublished(item.date);
|
|
69
152
|
const title = optionalText(item.title);
|
|
70
153
|
const rerankScore = typeof rerank === "number" && Number.isFinite(rerank) ? rerank : undefined;
|
package/src/core/types.ts
CHANGED
|
@@ -47,6 +47,12 @@ export interface ResolvedOptions {
|
|
|
47
47
|
readonly fetch: FetchOptions;
|
|
48
48
|
readonly auth: AuthOptions;
|
|
49
49
|
readonly retry: RetryOptions;
|
|
50
|
+
/**
|
|
51
|
+
* The API root both retrieval endpoints are composed from. Infrastructure
|
|
52
|
+
* routing, not a result-shaping knob: proxies and mock upstreams override it;
|
|
53
|
+
* anything but an http(s) URL is dropped at resolution back to the default.
|
|
54
|
+
*/
|
|
55
|
+
readonly baseUrl: string;
|
|
50
56
|
}
|
|
51
57
|
|
|
52
58
|
export interface DeepSeekSearchResult {
|
|
@@ -72,6 +78,17 @@ export interface SearchResult {
|
|
|
72
78
|
readonly siteName?: string;
|
|
73
79
|
}
|
|
74
80
|
|
|
81
|
+
/** What one search call executed: the processed results plus the query actually sent. */
|
|
82
|
+
export interface SearchOutcome {
|
|
83
|
+
readonly results: readonly SearchResult[];
|
|
84
|
+
/** The query as the caller provided it, trimmed. */
|
|
85
|
+
readonly inputQuery: string;
|
|
86
|
+
/** The query sent to DeepSeek, operators included. */
|
|
87
|
+
readonly effectiveQuery: string;
|
|
88
|
+
/** One entry per degradation applied to fit the query budget; empty means nothing was adjusted. */
|
|
89
|
+
readonly notes: readonly string[];
|
|
90
|
+
}
|
|
91
|
+
|
|
75
92
|
export interface FetchedPage {
|
|
76
93
|
readonly url: string;
|
|
77
94
|
readonly title: string;
|
package/src/installer/apply.ts
CHANGED
|
@@ -5,8 +5,8 @@ import { codecFor, writeConfig } from "./formats";
|
|
|
5
5
|
import { deepEqual, valueAt } from "./formats/value";
|
|
6
6
|
import { withOperationLock } from "./operation-lock";
|
|
7
7
|
import { acquireFileOwnership, assertFileOwnership, releaseFileOwnership } from "./ownership";
|
|
8
|
+
import { deleteRecord, readRecord, recordPathFor, writeRecord } from "./record";
|
|
8
9
|
import { pruneEmptyDir, revertRecordedFile } from "./revert";
|
|
9
|
-
import { deleteRecord, readRecord, statePathFor, writeRecord } from "./state";
|
|
10
10
|
import type { ConfigOp, FileEdit, InstallerInput, InstallPlan, InstallRecord, RecordedFile } from "./types";
|
|
11
11
|
|
|
12
12
|
/** What a successful apply reports; every failure mode throws instead. */
|
|
@@ -37,7 +37,7 @@ interface PreparedEdit {
|
|
|
37
37
|
* rendered (the plan and the record must describe one plan-time snapshot —
|
|
38
38
|
* ADR-0001), prepares every edit in memory (a malformed existing config fails
|
|
39
39
|
* before any file is written), writes the files, reverts artifacts orphaned by
|
|
40
|
-
* a tier change, verifies the result, then records
|
|
40
|
+
* a tier change, verifies the result, then records the install record. Write
|
|
41
41
|
* and verification failures restore touched files and the previous record.
|
|
42
42
|
* Incomplete recovery is reported explicitly and keeps new ownership claims
|
|
43
43
|
* so another install cannot adopt partially applied configuration.
|
|
@@ -46,7 +46,7 @@ export function applyPlan(plan: InstallPlan, input: InstallerInput): ApplyResult
|
|
|
46
46
|
if (plan.errors.length > 0) {
|
|
47
47
|
throw new Error(`refusing to apply a plan with errors: ${plan.errors.map((error) => error.message).join("; ")}`);
|
|
48
48
|
}
|
|
49
|
-
return withOperationLock([
|
|
49
|
+
return withOperationLock([recordPathFor(input)], () =>
|
|
50
50
|
withOperationLock(
|
|
51
51
|
[...plan.edits, ...(plan.reverts ?? [])].map((file) => file.file),
|
|
52
52
|
() => applyLocked(plan, input),
|
|
@@ -231,7 +231,7 @@ function rollbackApply(
|
|
|
231
231
|
try {
|
|
232
232
|
if (previous !== undefined) writeRecord(input, previous);
|
|
233
233
|
else deleteRecord(input);
|
|
234
|
-
if (previous === undefined ? existsSync(
|
|
234
|
+
if (previous === undefined ? existsSync(recordPathFor(input)) : !deepEqual(readRecord(input), previous)) {
|
|
235
235
|
throw new Error("restored install record differs from its previous state");
|
|
236
236
|
}
|
|
237
237
|
} catch (restoreError) {
|
package/src/installer/cli.ts
CHANGED
|
@@ -7,8 +7,8 @@ import { ApplyError, applyPlan } from "./apply";
|
|
|
7
7
|
import { runConfigCommand } from "./config-command";
|
|
8
8
|
import { HOST_PLANNERS, HOST_VERSION_PROBES, planForHost } from "./hosts";
|
|
9
9
|
import { pinPackageRef } from "./ownership";
|
|
10
|
+
import { legacyGlobalRecordDir, listRecords, readingLegacyGlobalRecords } from "./record";
|
|
10
11
|
import { renderPlan } from "./render";
|
|
11
|
-
import { legacyGlobalStateDir, listRecords, readingLegacyGlobalRecords } from "./state";
|
|
12
12
|
import type { HostName, InstallerInput, Scope, Tier } from "./types";
|
|
13
13
|
import { uninstall } from "./uninstall";
|
|
14
14
|
|
|
@@ -161,7 +161,7 @@ export async function runInstallerCli(argv: readonly string[], io: CliIo = {}):
|
|
|
161
161
|
}
|
|
162
162
|
if (readingLegacyGlobalRecords({ ...roots, configHome })) {
|
|
163
163
|
out(
|
|
164
|
-
`Global install records are being read from the legacy ${
|
|
164
|
+
`Global install records are being read from the legacy ${legacyGlobalRecordDir(roots)} ` +
|
|
165
165
|
"(written before the Windows config home moved to %APPDATA%).",
|
|
166
166
|
);
|
|
167
167
|
}
|
|
@@ -68,17 +68,18 @@ export async function runConfigCommand(
|
|
|
68
68
|
const client = deps.client ?? createRetrievalClient();
|
|
69
69
|
const startedAt = Date.now();
|
|
70
70
|
try {
|
|
71
|
-
const
|
|
71
|
+
const outcome = await client.searchWeb(query, resolved.search, {
|
|
72
72
|
signal: new AbortController().signal,
|
|
73
73
|
auth: { apiKey },
|
|
74
74
|
retry: resolved.retry,
|
|
75
|
+
baseUrl: resolved.baseUrl,
|
|
75
76
|
});
|
|
76
77
|
const elapsedMs = Date.now() - startedAt;
|
|
77
78
|
io.out(
|
|
78
|
-
`search "${query.trim()}" — ${results.length} ${results.length === 1 ? "result" : "results"} in ${elapsedMs} ms`,
|
|
79
|
+
`search "${query.trim()}" — ${outcome.results.length} ${outcome.results.length === 1 ? "result" : "results"} in ${elapsedMs} ms`,
|
|
79
80
|
);
|
|
80
81
|
io.out("");
|
|
81
|
-
for (const line of formatSearchResults(results).split("\n")) io.out(line);
|
|
82
|
+
for (const line of formatSearchResults(outcome.results, outcome).split("\n")) io.out(line);
|
|
82
83
|
return 0;
|
|
83
84
|
} catch (error) {
|
|
84
85
|
io.err(error instanceof Error ? error.message : String(error));
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { API_KEY_ENV } from "../../core/constants";
|
|
3
|
+
import { FORCED_COST_NOTE, othersStayRegisteredNote, replacingExistingEntryNote, splitCompetitors } from "../detect";
|
|
4
|
+
import { codecFor, type LoadedConfig } from "../formats";
|
|
5
|
+
import { buildRulesBlock } from "../formats/rules";
|
|
6
|
+
import { isPlainObject } from "../formats/value";
|
|
7
|
+
import { join } from "../paths";
|
|
8
|
+
import { MCP_TOOL_IDS, rulesLines } from "../rulesContent";
|
|
9
|
+
import type { ConfigOp, FileEdit, HostPaths, InstallerInput, InstallPlan, InstallRecord } from "../types";
|
|
10
|
+
import { planHeader, probeHostVersion } from "./shared";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The Claude Code release this installer's facts are verified against — the lock in
|
|
14
|
+
* docs/hosts.jsonc; the drift note fires when the probed CLI differs.
|
|
15
|
+
*/
|
|
16
|
+
export const CLAUDE_CODE_TESTED_VERSION = "2.1.303";
|
|
17
|
+
|
|
18
|
+
/** The built-in web tools the forced tier denies; bare names remove them from context. */
|
|
19
|
+
const BUILTIN_TOOL_IDS = { search: "WebSearch", fetch: "WebFetch" } as const;
|
|
20
|
+
|
|
21
|
+
/** Human name for a wrong-typed config value in plan errors ("an array", "a string"). */
|
|
22
|
+
function valueKind(value: unknown): string {
|
|
23
|
+
return Array.isArray(value) ? "an array" : `a ${typeof value}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Probes `claude --version` for the version-drift note (see probeHostVersion). */
|
|
27
|
+
export function probeClaudeCodeVersion(err: (line: string) => void): string | undefined {
|
|
28
|
+
return probeHostVersion("claude", parseClaudeCodeVersion, err);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Parses `claude --version` output (`2.1.303 (Claude Code)`) into the bare version.
|
|
33
|
+
* The last semver wins: an update banner advertising a newer release must not
|
|
34
|
+
* anchor the drift note on the wrong number.
|
|
35
|
+
*/
|
|
36
|
+
export function parseClaudeCodeVersion(output: string): string | undefined {
|
|
37
|
+
return [...output.matchAll(/\b\d+\.\d+\.\d+\b/g)].map((match) => match[0]).at(-1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Claude Code config, settings, and rules files per scope; the host module owns
|
|
42
|
+
* this mapping. MCP registration and the settings file are disjoint per scope
|
|
43
|
+
* (project: `.mcp.json` + `.claude/settings.json`; global: `~/.claude.json` +
|
|
44
|
+
* `~/.claude/settings.json`), so the two scopes can coexist unlike Codex's.
|
|
45
|
+
*/
|
|
46
|
+
function claudeCodePaths(input: InstallerInput): HostPaths & { readonly settings: string } {
|
|
47
|
+
return input.scope === "global"
|
|
48
|
+
? {
|
|
49
|
+
config: join(input.home, ".claude.json"),
|
|
50
|
+
settings: join(input.home, ".claude", "settings.json"),
|
|
51
|
+
rules: join(input.home, ".claude", "CLAUDE.md"),
|
|
52
|
+
}
|
|
53
|
+
: {
|
|
54
|
+
config: join(input.cwd, ".mcp.json"),
|
|
55
|
+
settings: join(input.cwd, ".claude", "settings.json"),
|
|
56
|
+
rules: join(input.cwd, "CLAUDE.md"),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The managed-policy layer, the one settings layer above the user. Its existence
|
|
62
|
+
* (never its contents) is reported: deny and allow rules there outrank anything
|
|
63
|
+
* this plan writes at either scope.
|
|
64
|
+
*/
|
|
65
|
+
function managedSettingsPath(input: InstallerInput): string {
|
|
66
|
+
const platform = input.platform ?? process.platform;
|
|
67
|
+
if (platform === "darwin") return "/Library/Application Support/ClaudeCode/managed-settings.json";
|
|
68
|
+
if (platform === "win32") return "C:\\Program Files\\ClaudeCode\\managed-settings.json";
|
|
69
|
+
return "/etc/claude-code/managed-settings.json";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function planClaudeCode(input: InstallerInput, _previous: InstallRecord | undefined): InstallPlan {
|
|
73
|
+
const paths = claudeCodePaths(input);
|
|
74
|
+
const loaded = codecFor("json", input.host).load(paths.config);
|
|
75
|
+
const tiers = { search: input.search, fetch: input.fetch };
|
|
76
|
+
const header = planHeader(input, paths, loaded, "JSON");
|
|
77
|
+
if (header.abort) {
|
|
78
|
+
return { host: "claude-code", scope: input.scope, tiers, edits: [], notes: header.notes, errors: header.errors };
|
|
79
|
+
}
|
|
80
|
+
const notes = [...header.notes];
|
|
81
|
+
const errors = [...header.errors];
|
|
82
|
+
const edits: FileEdit[] = [];
|
|
83
|
+
|
|
84
|
+
if (input.hostVersion !== undefined && input.hostVersion !== CLAUDE_CODE_TESTED_VERSION) {
|
|
85
|
+
notes.push(
|
|
86
|
+
`Claude Code ${input.hostVersion} is installed, but this installer's Claude Code facts are verified against ` +
|
|
87
|
+
`${CLAUDE_CODE_TESTED_VERSION} (the docs/hosts.jsonc lock). Deny-rule and tool-visibility behavior may have ` +
|
|
88
|
+
"drifted between the two — re-verify the tiers if routing misbehaves.",
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
const managed = managedSettingsPath(input);
|
|
92
|
+
if (existsSync(managed)) {
|
|
93
|
+
notes.push(
|
|
94
|
+
`${managed} exists: the managed policy layer outranks every settings file this plan writes, so a deny or allow ` +
|
|
95
|
+
"there overrides these changes. Check with your administrator if routing does not take effect.",
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The server registration. `${VAR}` expansion is Claude Code's own mechanism, so
|
|
100
|
+
// the key expands from the environment that launches Claude Code and never sits
|
|
101
|
+
// in the file.
|
|
102
|
+
const servers = loaded.value.mcpServers;
|
|
103
|
+
if (servers !== undefined && !isPlainObject(servers)) {
|
|
104
|
+
errors.push({
|
|
105
|
+
message: `${paths.config}: mcpServers is ${valueKind(servers)}, not an object; fix it before installing.`,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
const existingEntry = isPlainObject(servers) ? servers.deepseek : undefined;
|
|
109
|
+
const entry = {
|
|
110
|
+
command: "npx",
|
|
111
|
+
args: ["-y", input.packageRef],
|
|
112
|
+
env: { [API_KEY_ENV]: `\${${API_KEY_ENV}}` },
|
|
113
|
+
};
|
|
114
|
+
const ops: ConfigOp[] = [{ op: "set", path: ["mcpServers", "deepseek"], value: entry, previous: existingEntry }];
|
|
115
|
+
edits.push({
|
|
116
|
+
file: paths.config,
|
|
117
|
+
format: "json",
|
|
118
|
+
// Global-scope files live outside the install record's config home, so they
|
|
119
|
+
// need the owner sidecar that stops a second install (another project or
|
|
120
|
+
// XDG_CONFIG_HOME) from claiming them — the codex topology.
|
|
121
|
+
...(input.scope === "global" ? { shared: true as const } : {}),
|
|
122
|
+
ops,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
if (isPlainObject(servers)) {
|
|
126
|
+
if ("deepseek" in servers) notes.push(replacingExistingEntryNote("mcpServers.deepseek"));
|
|
127
|
+
}
|
|
128
|
+
notes.push(
|
|
129
|
+
`\`${API_KEY_ENV}\` is written as "\${${API_KEY_ENV}}" in the server's env block, so the key expands from the ` +
|
|
130
|
+
"environment that launches Claude Code and stays out of the file.",
|
|
131
|
+
);
|
|
132
|
+
if (input.scope === "project") {
|
|
133
|
+
notes.push(
|
|
134
|
+
"Claude Code asks once per project whether to use servers from .mcp.json; approve the deepseek server when " +
|
|
135
|
+
"prompted (or via the /mcp menu), or its tools will not load in that project.",
|
|
136
|
+
);
|
|
137
|
+
} else {
|
|
138
|
+
notes.push(
|
|
139
|
+
`User-scope servers live in the mcpServers key of ${paths.config} — Claude Code's own user-level store, where ` +
|
|
140
|
+
"`claude mcp add --scope user` writes too; every other key in the file is left untouched.",
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Forced-tier competitor detection also scans the other stack's file — a project
|
|
145
|
+
// install sees user-scope servers and a global install runs inside a cwd — since
|
|
146
|
+
// a server-level deny rule in this scope's settings file excludes either origin.
|
|
147
|
+
const forced = input.search === "forced" || input.fetch === "forced";
|
|
148
|
+
const scanned: Record<string, unknown> = {};
|
|
149
|
+
if (isPlainObject(servers)) Object.assign(scanned, servers);
|
|
150
|
+
const otherFile = input.scope === "project" ? join(input.home, ".claude.json") : join(input.cwd, ".mcp.json");
|
|
151
|
+
if (forced && existsSync(otherFile)) {
|
|
152
|
+
let otherLoaded: LoadedConfig | undefined;
|
|
153
|
+
try {
|
|
154
|
+
otherLoaded = codecFor("json", input.host).load(otherFile);
|
|
155
|
+
} catch (error) {
|
|
156
|
+
// A directory or unreadable file (EISDIR/EACCES) must refuse the tier —
|
|
157
|
+
// honor or report — instead of crashing on a file we never edit.
|
|
158
|
+
errors.push({
|
|
159
|
+
message: `${otherFile} exists but cannot be read (${error instanceof Error ? error.message : String(error)}); the forced-tier competitor scan requires it. Fix the file or its permissions, or choose a lower tier.`,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
if (otherLoaded?.parseError) {
|
|
163
|
+
errors.push({ message: `${otherFile} exists but does not parse as JSON: ${otherLoaded.parseError}` });
|
|
164
|
+
} else if (
|
|
165
|
+
otherLoaded !== undefined &&
|
|
166
|
+
otherLoaded.value.mcpServers !== undefined &&
|
|
167
|
+
!isPlainObject(otherLoaded.value.mcpServers)
|
|
168
|
+
) {
|
|
169
|
+
errors.push({
|
|
170
|
+
message: `${otherFile}: mcpServers is ${valueKind(otherLoaded.value.mcpServers)}, not an object; the forced-tier competitor scan cannot read it. Fix the file or choose a lower tier.`,
|
|
171
|
+
});
|
|
172
|
+
} else if (otherLoaded !== undefined && isPlainObject(otherLoaded.value.mcpServers)) {
|
|
173
|
+
const otherServers = otherLoaded.value.mcpServers;
|
|
174
|
+
Object.assign(scanned, otherServers);
|
|
175
|
+
if ("deepseek" in otherServers) {
|
|
176
|
+
notes.push(
|
|
177
|
+
`A deepseek server is also registered in ${otherFile}; Claude Code resolves the name local first, then ` +
|
|
178
|
+
"project, then user scope, so consider uninstalling the other scope to keep one registration.",
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const denyEntries: string[] = [];
|
|
185
|
+
const { competitors, bystanders } = splitCompetitors(scanned);
|
|
186
|
+
if (forced && competitors.length > 0) {
|
|
187
|
+
// Claude Code has a per-server kill switch: a bare `mcp__<server>` deny rule
|
|
188
|
+
// denies every tool of that server, so detected web-capable competitors are
|
|
189
|
+
// excluded instead of refusing the tier (unlike Codex).
|
|
190
|
+
const rules = competitors.map((name) => `mcp__${name}`);
|
|
191
|
+
denyEntries.push(...rules);
|
|
192
|
+
notes.push(
|
|
193
|
+
`Forced tier: web-capable MCP servers are denied by server-level rules (${rules.join(", ")}), so DeepSeek ` +
|
|
194
|
+
"routing is exclusive while the entries stay." +
|
|
195
|
+
(input.scope === "global"
|
|
196
|
+
? " These denies apply in every project on this machine and match by server name, so a same-named server in another project is denied too."
|
|
197
|
+
: ""),
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
const stayRegistered = forced ? bystanders : [...competitors, ...bystanders];
|
|
201
|
+
if (stayRegistered.length > 0) notes.push(othersStayRegisteredNote(stayRegistered));
|
|
202
|
+
|
|
203
|
+
if (input.search === "forced") denyEntries.push(BUILTIN_TOOL_IDS.search);
|
|
204
|
+
if (input.fetch === "forced") denyEntries.push(BUILTIN_TOOL_IDS.fetch);
|
|
205
|
+
if (input.search === "off") denyEntries.push(MCP_TOOL_IDS.search);
|
|
206
|
+
if (input.fetch === "off") denyEntries.push(MCP_TOOL_IDS.fetch);
|
|
207
|
+
|
|
208
|
+
if (denyEntries.length > 0) {
|
|
209
|
+
const settingsLoaded = codecFor("json", input.host).load(paths.settings);
|
|
210
|
+
if (settingsLoaded.parseError) {
|
|
211
|
+
errors.push({ message: `${paths.settings} exists but does not parse as JSON: ${settingsLoaded.parseError}` });
|
|
212
|
+
} else {
|
|
213
|
+
const permissions = settingsLoaded.value.permissions;
|
|
214
|
+
const deny = isPlainObject(permissions) ? permissions.deny : undefined;
|
|
215
|
+
if (permissions !== undefined && !isPlainObject(permissions)) {
|
|
216
|
+
errors.push({
|
|
217
|
+
message: `${paths.settings}: the permissions key is ${valueKind(permissions)}, not an object; fix it before installing.`,
|
|
218
|
+
});
|
|
219
|
+
} else if (deny !== undefined && !Array.isArray(deny)) {
|
|
220
|
+
errors.push({
|
|
221
|
+
message: `${paths.settings}: permissions.deny is ${valueKind(deny)}, not an array; fix it before installing.`,
|
|
222
|
+
});
|
|
223
|
+
} else {
|
|
224
|
+
const existingCount = Array.isArray(deny) ? deny.length : 0;
|
|
225
|
+
const existingDeny = Array.isArray(deny) ? deny.filter((item): item is string => typeof item === "string") : [];
|
|
226
|
+
const denyOps: ConfigOp[] = [
|
|
227
|
+
{
|
|
228
|
+
op: "append-array",
|
|
229
|
+
path: ["permissions", "deny"],
|
|
230
|
+
entries: denyEntries,
|
|
231
|
+
existingCount,
|
|
232
|
+
},
|
|
233
|
+
];
|
|
234
|
+
edits.push({
|
|
235
|
+
file: paths.settings,
|
|
236
|
+
format: "json",
|
|
237
|
+
...(input.scope === "global" ? { shared: true as const } : {}),
|
|
238
|
+
ops: denyOps,
|
|
239
|
+
});
|
|
240
|
+
if (existingCount > 0) {
|
|
241
|
+
notes.push(
|
|
242
|
+
`permissions.deny already holds ${existingCount} ${existingCount === 1 ? "entry" : "entries"}; ` +
|
|
243
|
+
"the plan appends only its own and never removes yours.",
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
const preexisting = denyEntries.filter((name) => existingDeny.includes(name));
|
|
247
|
+
if (preexisting.length > 0) {
|
|
248
|
+
notes.push(
|
|
249
|
+
`${preexisting.join(", ")} ${preexisting.length === 1 ? "is" : "are"} already present in permissions.deny — present entries are not duplicated.`,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (errors.length > 0) {
|
|
256
|
+
return { host: "claude-code", scope: input.scope, tiers, edits: [], notes, errors };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const forcedIds = [
|
|
260
|
+
...(input.search === "forced" ? [BUILTIN_TOOL_IDS.search] : []),
|
|
261
|
+
...(input.fetch === "forced" ? [BUILTIN_TOOL_IDS.fetch] : []),
|
|
262
|
+
];
|
|
263
|
+
if (forcedIds.length > 0) {
|
|
264
|
+
notes.push(
|
|
265
|
+
`Forced is exclusion: bare deny rules (${forcedIds.join(", ")}) in permissions.deny remove the built-in tool ` +
|
|
266
|
+
"from the model's context entirely — deny outranks allow at every settings level and cannot be carved out, so " +
|
|
267
|
+
`the exclusion also applies to every other workflow that relied on the tool ${
|
|
268
|
+
input.scope === "global" ? "in every project on this machine" : "in this project"
|
|
269
|
+
}. ` +
|
|
270
|
+
FORCED_COST_NOTE,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
const offIds = [
|
|
274
|
+
...(input.search === "off" ? [MCP_TOOL_IDS.search] : []),
|
|
275
|
+
...(input.fetch === "off" ? [MCP_TOOL_IDS.fetch] : []),
|
|
276
|
+
];
|
|
277
|
+
if (offIds.length > 0) {
|
|
278
|
+
notes.push(
|
|
279
|
+
`Off capabilities are written as their full tool ids (${offIds.join(", ")}) in permissions.deny: the tool ` +
|
|
280
|
+
"leaves the model's context while the server keeps running; delete the entry to re-enable it.",
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
if (input.search === "optional" || input.fetch === "optional") {
|
|
284
|
+
notes.push(
|
|
285
|
+
"Claude Code ships built-in WebSearch and WebFetch tools; under the optional tier they stay available " +
|
|
286
|
+
"alongside the DeepSeek tools.",
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (input.search === "preferred" || input.fetch === "preferred") {
|
|
291
|
+
if (input.scope === "project" && !existsSync(paths.rules) && existsSync(join(input.cwd, "AGENTS.md"))) {
|
|
292
|
+
notes.push(
|
|
293
|
+
`This plan creates ${paths.rules} where none exists, and ${join(input.cwd, "AGENTS.md")} is present: Claude ` +
|
|
294
|
+
"Code reads AGENTS.md only when no CLAUDE.md exists above the cwd, so the new CLAUDE.md takes precedence — " +
|
|
295
|
+
"merge anything you rely on from AGENTS.md before applying.",
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
edits.push({
|
|
299
|
+
file: paths.rules,
|
|
300
|
+
format: "rules",
|
|
301
|
+
block: buildRulesBlock(input.host, rulesLines(MCP_TOOL_IDS, input.search, input.fetch)),
|
|
302
|
+
});
|
|
303
|
+
notes.push(
|
|
304
|
+
input.scope === "global"
|
|
305
|
+
? `Rules block upserted in ${paths.rules}; Claude Code loads the user CLAUDE.md plus every CLAUDE.md from the cwd up the directory tree, concatenating the levels.`
|
|
306
|
+
: `Rules block upserted in ${paths.rules}; Claude Code loads every CLAUDE.md from the cwd up the directory tree, concatenating the levels.`,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return { host: "claude-code", scope: input.scope, tiers, edits, notes, errors };
|
|
311
|
+
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { spawnSync } from "node:child_process";
|
|
2
1
|
import { API_KEY_ENV } from "../../core/constants";
|
|
3
2
|
import { OPTION_ENV_VARS } from "../../core/env";
|
|
4
3
|
import { FORCED_COST_NOTE, othersStayRegisteredNote, replacingExistingEntryNote, splitCompetitors } from "../detect";
|
|
@@ -8,7 +7,7 @@ import { isPlainObject } from "../formats/value";
|
|
|
8
7
|
import { join } from "../paths";
|
|
9
8
|
import { MCP_TOOL_IDS, RAW_MCP_TOOL_IDS, rulesLines } from "../rulesContent";
|
|
10
9
|
import type { ConfigOp, FileEdit, HostPaths, InstallerInput, InstallPlan, InstallRecord } from "../types";
|
|
11
|
-
import { planHeader } from "./shared";
|
|
10
|
+
import { planHeader, probeHostVersion } from "./shared";
|
|
12
11
|
|
|
13
12
|
/**
|
|
14
13
|
* The Codex release this installer's facts are verified against — the lock in
|
|
@@ -16,19 +15,9 @@ import { planHeader } from "./shared";
|
|
|
16
15
|
*/
|
|
17
16
|
export const CODEX_TESTED_VERSION = "0.163.0";
|
|
18
17
|
|
|
19
|
-
/**
|
|
20
|
-
* The one subprocess the installer runs: `codex --version`, probed for the
|
|
21
|
-
* version-drift note. A missing binary or unreadable output skips the check with
|
|
22
|
-
* a stderr note and never blocks the plan — honor or report.
|
|
23
|
-
*/
|
|
18
|
+
/** Probes `codex --version` for the version-drift note (see probeHostVersion). */
|
|
24
19
|
export function probeCodexVersion(err: (line: string) => void): string | undefined {
|
|
25
|
-
|
|
26
|
-
const result = spawnSync("codex", ["--version"], { timeout: 5000, encoding: "utf8", env: process.env });
|
|
27
|
-
const version = result.status === 0 ? parseCodexVersion(result.stdout ?? "") : undefined;
|
|
28
|
-
if (version === undefined) {
|
|
29
|
-
err("note: codex was not found on PATH or printed no version; the version-drift check was skipped");
|
|
30
|
-
}
|
|
31
|
-
return version;
|
|
20
|
+
return probeHostVersion("codex", parseCodexVersion, err);
|
|
32
21
|
}
|
|
33
22
|
|
|
34
23
|
/** Parses `codex --version` output (`codex-cli 0.163.0`) into the bare version. */
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { API_KEY_ENV } from "../../core/constants";
|
|
2
2
|
import { deepEqual, isPlainObject } from "../formats/value";
|
|
3
3
|
import { assertFileOwnership, fileOwnershipNotes } from "../ownership";
|
|
4
|
-
import { claimedByOtherScope, InstallRecordError, readRecord } from "../
|
|
4
|
+
import { claimedByOtherScope, InstallRecordError, readRecord } from "../record";
|
|
5
5
|
import type { HostName, InstallerInput, InstallPlan, InstallRecord, RecordedFile } from "../types";
|
|
6
|
+
import { planClaudeCode, probeClaudeCodeVersion } from "./claude-code";
|
|
6
7
|
import { planCodex, probeCodexVersion } from "./codex";
|
|
7
8
|
import { planOpenCode } from "./opencode";
|
|
8
9
|
import { planPi } from "./pi";
|
|
@@ -23,6 +24,7 @@ export const HOST_PLANNERS: Readonly<Record<HostName, HostPlanner>> = {
|
|
|
23
24
|
opencode: planOpenCode,
|
|
24
25
|
pi: planPi,
|
|
25
26
|
codex: planCodex,
|
|
27
|
+
"claude-code": planClaudeCode,
|
|
26
28
|
};
|
|
27
29
|
|
|
28
30
|
/**
|
|
@@ -32,6 +34,7 @@ export const HOST_PLANNERS: Readonly<Record<HostName, HostPlanner>> = {
|
|
|
32
34
|
*/
|
|
33
35
|
export const HOST_VERSION_PROBES: Partial<Record<HostName, (err: (line: string) => void) => string | undefined>> = {
|
|
34
36
|
codex: probeCodexVersion,
|
|
37
|
+
"claude-code": probeClaudeCodeVersion,
|
|
35
38
|
};
|
|
36
39
|
|
|
37
40
|
export function planForHost(host: HostName, input: InstallerInput): InstallPlan {
|
|
@@ -150,7 +153,7 @@ function withSecretGuard(plan: InstallPlan): InstallPlan {
|
|
|
150
153
|
{
|
|
151
154
|
message:
|
|
152
155
|
`${edit.file}: the existing value at ${op.path.join(".")} contains a credential-looking key ` +
|
|
153
|
-
`(${offender}). The installer refuses to snapshot secrets into its
|
|
156
|
+
`(${offender}). The installer refuses to snapshot secrets into its install record. Move the key to ` +
|
|
154
157
|
`environment indirection ("\${${API_KEY_ENV}}" expansion or env_vars) and re-run.`,
|
|
155
158
|
},
|
|
156
159
|
],
|
|
@@ -4,8 +4,8 @@ import { buildRulesBlock } from "../formats/rules";
|
|
|
4
4
|
import { isPlainObject } from "../formats/value";
|
|
5
5
|
import { lineageMatcher } from "../ownership";
|
|
6
6
|
import { configHome, join } from "../paths";
|
|
7
|
+
import { appendedByPrevious } from "../record";
|
|
7
8
|
import { rulesLines } from "../rulesContent";
|
|
8
|
-
import { appendedByPrevious } from "../state";
|
|
9
9
|
import type { ConfigOp, FileEdit, HostPaths, InstallerInput, InstallPlan, InstallRecord } from "../types";
|
|
10
10
|
import { planHeader } from "./shared";
|
|
11
11
|
|