deepseek-web-retrieval 0.4.0 → 0.5.1
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 +99 -244
- package/README.md +16 -11
- package/dist/cli.js +1366 -615
- package/docs/agents/codex.md +28 -7
- package/docs/agents/opencode-v2.md +1 -1
- package/docs/agents/pi.md +88 -86
- package/docs/design/installer-design.md +72 -9
- package/docs/hosts.jsonc +2 -2
- package/docs/integration.md +14 -9
- package/docs/research/control-matrix.md +48 -13
- package/docs/research/mcp-compatibility.md +9 -4
- package/examples/pi.settings.jsonc +17 -0
- package/index.ts +2 -2
- package/package.json +35 -4
- package/src/core/auth.ts +4 -6
- package/src/core/client.ts +65 -53
- package/src/core/constants.ts +24 -24
- package/src/core/descriptions.ts +30 -6
- package/src/core/domains.ts +56 -67
- package/src/core/env.ts +112 -0
- package/src/core/format.ts +53 -50
- package/src/core/index.ts +9 -9
- package/src/core/jsonc.ts +6 -6
- package/src/core/object.ts +4 -0
- package/src/core/options.ts +20 -25
- package/src/core/search.ts +45 -44
- package/src/core/transport.ts +109 -113
- package/src/core/types.ts +47 -47
- package/src/core/url.ts +20 -20
- package/src/installer/apply.ts +256 -81
- package/src/installer/cli.ts +135 -104
- package/src/installer/detect.ts +46 -31
- package/src/installer/formats/index.ts +26 -28
- package/src/installer/formats/json.ts +81 -77
- package/src/installer/formats/rules.ts +55 -28
- package/src/installer/formats/toml.ts +88 -81
- package/src/installer/formats/value.ts +34 -30
- package/src/installer/hosts/codex.ts +100 -54
- package/src/installer/hosts/index.ts +126 -41
- package/src/installer/hosts/opencode.ts +70 -51
- package/src/installer/hosts/pi.ts +269 -75
- package/src/installer/hosts/shared.ts +19 -14
- package/src/installer/operation-lock.ts +54 -0
- package/src/installer/ownership.ts +129 -0
- package/src/installer/paths.ts +7 -5
- package/src/installer/persistence.ts +66 -0
- package/src/installer/render.ts +29 -29
- package/src/installer/revert.ts +33 -29
- package/src/installer/rulesContent.ts +24 -13
- package/src/installer/state.ts +116 -31
- package/src/installer/types.ts +74 -56
- package/src/installer/uninstall.ts +40 -17
- package/src/mcp/auth.ts +7 -8
- package/src/mcp/cli.ts +38 -20
- package/src/mcp/client.ts +35 -0
- package/src/mcp/server.ts +68 -97
- package/src/opencode/auth.ts +24 -26
- package/src/opencode/plugin.ts +99 -87
- package/src/pi/extension.ts +164 -0
- package/src/pi/settings.ts +49 -0
- package/src/mcp/config.ts +0 -112
package/src/installer/state.ts
CHANGED
|
@@ -1,20 +1,91 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { join } from "node:path"
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
1
|
+
import { existsSync, lstatSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
3
|
+
import { z } from "zod/v4";
|
|
4
|
+
import { stateDir } from "./paths";
|
|
5
|
+
import { atomicWrite } from "./persistence";
|
|
6
|
+
import type { FsRoots, InstallerInput, InstallRecord } from "./types";
|
|
5
7
|
|
|
6
8
|
export function statePathFor(input: InstallerInput): string {
|
|
7
|
-
return join(stateDir(input, input.scope), `install.${input.host}.json`)
|
|
9
|
+
return join(stateDir(input, input.scope), `install.${input.host}.json`);
|
|
8
10
|
}
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
const keyPath = z.array(z.string().min(1)).min(1);
|
|
13
|
+
const tier = z.enum(["optional", "preferred", "forced", "off"]);
|
|
14
|
+
const operation = z.discriminatedUnion("op", [
|
|
15
|
+
z.object({ op: z.literal("set"), path: keyPath, applied: z.unknown(), previous: z.unknown().optional() }),
|
|
16
|
+
z.object({
|
|
17
|
+
op: z.literal("append-array"),
|
|
18
|
+
path: keyPath,
|
|
19
|
+
applied: z.array(z.unknown()),
|
|
20
|
+
previous: z.unknown().optional(),
|
|
21
|
+
}),
|
|
22
|
+
]);
|
|
23
|
+
const recordSchema = z.object({
|
|
24
|
+
version: z.literal(1),
|
|
25
|
+
host: z.enum(["opencode", "pi", "codex"]),
|
|
26
|
+
scope: z.enum(["global", "project"]),
|
|
27
|
+
tiers: z.object({ search: tier, fetch: tier }),
|
|
28
|
+
createdAt: z.string().refine((value) => Number.isFinite(Date.parse(value))),
|
|
29
|
+
files: z.array(
|
|
30
|
+
z.object({
|
|
31
|
+
file: z.string().refine(isAbsolute),
|
|
32
|
+
format: z.enum(["json", "toml", "rules"]),
|
|
33
|
+
created: z.boolean(),
|
|
34
|
+
shared: z.literal(true).optional(),
|
|
35
|
+
ops: z.array(operation),
|
|
36
|
+
blockApplied: z.string().optional(),
|
|
37
|
+
createdParents: z.array(keyPath).optional(),
|
|
38
|
+
}),
|
|
39
|
+
),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export class InstallRecordError extends Error {
|
|
43
|
+
constructor(
|
|
44
|
+
readonly file: string,
|
|
45
|
+
readonly kind: "unreadable" | "invalid",
|
|
46
|
+
detail: string,
|
|
47
|
+
) {
|
|
48
|
+
super(
|
|
49
|
+
`${kind === "invalid" ? "Invalid install record" : "Cannot read install record"} at ${file} (${detail}). ` +
|
|
50
|
+
"Restore a valid record from backup or repair its permissions before retrying; configuration was not changed. Do not delete undo state to bypass this check.",
|
|
51
|
+
);
|
|
52
|
+
this.name = "InstallRecordError";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readRecordFile(file: string, host: string, scope: string): InstallRecord | undefined {
|
|
57
|
+
let text: string;
|
|
13
58
|
try {
|
|
14
|
-
|
|
59
|
+
text = readFileSync(file, "utf8");
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
62
|
+
try {
|
|
63
|
+
lstatSync(file);
|
|
64
|
+
} catch (statError) {
|
|
65
|
+
if ((statError as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
throw new InstallRecordError(file, "unreadable", (error as NodeJS.ErrnoException).code ?? "read failed");
|
|
69
|
+
}
|
|
70
|
+
let value: unknown;
|
|
71
|
+
try {
|
|
72
|
+
value = JSON.parse(text);
|
|
15
73
|
} catch {
|
|
16
|
-
|
|
74
|
+
throw new InstallRecordError(file, "invalid", "malformed JSON");
|
|
75
|
+
}
|
|
76
|
+
const result = recordSchema.safeParse(value);
|
|
77
|
+
if (!result.success) {
|
|
78
|
+
// Never include snapshot values in diagnostics: they may contain private configuration.
|
|
79
|
+
throw new InstallRecordError(file, "invalid", "unsupported version or malformed record structure");
|
|
17
80
|
}
|
|
81
|
+
if (result.data.host !== host || result.data.scope !== scope) {
|
|
82
|
+
throw new InstallRecordError(file, "invalid", "host or scope does not match its location");
|
|
83
|
+
}
|
|
84
|
+
return result.data as InstallRecord;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function readRecord(input: InstallerInput): InstallRecord | undefined {
|
|
88
|
+
return readRecordFile(statePathFor(input), input.host, input.scope);
|
|
18
89
|
}
|
|
19
90
|
|
|
20
91
|
/**
|
|
@@ -22,10 +93,10 @@ export function readRecord(input: InstallerInput): InstallRecord | undefined {
|
|
|
22
93
|
* same host: returns the subset of `files` the record at the other scope claims.
|
|
23
94
|
*/
|
|
24
95
|
export function claimedByOtherScope(input: InstallerInput, files: readonly string[]): string[] {
|
|
25
|
-
const other = input.scope === "global" ? "project" : "global"
|
|
26
|
-
const record = readRecord({ ...input, scope: other })
|
|
27
|
-
if (record === undefined) return []
|
|
28
|
-
return files.filter((file) => record.files.some((candidate) => candidate.file === file))
|
|
96
|
+
const other = input.scope === "global" ? "project" : "global";
|
|
97
|
+
const record = readRecord({ ...input, scope: other });
|
|
98
|
+
if (record === undefined) return [];
|
|
99
|
+
return files.filter((file) => record.files.some((candidate) => candidate.file === file));
|
|
29
100
|
}
|
|
30
101
|
|
|
31
102
|
/**
|
|
@@ -33,31 +104,45 @@ export function claimedByOtherScope(input: InstallerInput, files: readonly strin
|
|
|
33
104
|
* owner-only permissions regardless of umask.
|
|
34
105
|
*/
|
|
35
106
|
export function writeRecord(input: InstallerInput, record: InstallRecord): void {
|
|
36
|
-
const file = statePathFor(input)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
107
|
+
const file = statePathFor(input);
|
|
108
|
+
atomicWrite(file, `${JSON.stringify(record, null, 2)}\n`, 0o600);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Entries a previous init appended at the given path, across its recorded files. */
|
|
112
|
+
export function appendedByPrevious(record: InstallRecord | undefined, path: readonly string[]): readonly unknown[] {
|
|
113
|
+
if (record === undefined) return [];
|
|
114
|
+
const entries: unknown[] = [];
|
|
115
|
+
for (const file of record.files) {
|
|
116
|
+
for (const op of file.ops) {
|
|
117
|
+
if (op.op !== "append-array" || op.path.join("\u0000") !== path.join("\u0000")) continue;
|
|
118
|
+
if (Array.isArray(op.applied)) entries.push(...op.applied);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return entries;
|
|
40
122
|
}
|
|
41
123
|
|
|
42
124
|
export function deleteRecord(input: InstallerInput): void {
|
|
43
|
-
const file = statePathFor(input)
|
|
44
|
-
if (existsSync(file)) rmSync(file)
|
|
125
|
+
const file = statePathFor(input);
|
|
126
|
+
if (existsSync(file)) rmSync(file);
|
|
45
127
|
}
|
|
46
128
|
|
|
47
129
|
/** Lists install records found in both scope locations for the given roots. */
|
|
48
130
|
export function listRecords(roots: FsRoots): InstallRecord[] {
|
|
49
|
-
const records: InstallRecord[] = []
|
|
131
|
+
const records: InstallRecord[] = [];
|
|
50
132
|
for (const scope of ["global", "project"] as const) {
|
|
51
|
-
const dir = stateDir(roots, scope)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
133
|
+
const dir = stateDir(roots, scope);
|
|
134
|
+
let names: string[];
|
|
135
|
+
try {
|
|
136
|
+
names = readdirSync(dir);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
|
|
139
|
+
throw new InstallRecordError(dir, "unreadable", (error as NodeJS.ErrnoException).code ?? "list failed");
|
|
140
|
+
}
|
|
141
|
+
for (const name of names) {
|
|
142
|
+
if (!name.startsWith("install.") || !name.endsWith(".json")) continue;
|
|
143
|
+
const record = readRecordFile(join(dir, name), name.slice("install.".length, -".json".length), scope);
|
|
144
|
+
if (record !== undefined) records.push(record);
|
|
60
145
|
}
|
|
61
146
|
}
|
|
62
|
-
return records
|
|
147
|
+
return records;
|
|
63
148
|
}
|
package/src/installer/types.ts
CHANGED
|
@@ -1,126 +1,144 @@
|
|
|
1
1
|
/** Hosts the installer supports. */
|
|
2
|
-
export type HostName = "opencode" | "pi" | "codex"
|
|
2
|
+
export type HostName = "opencode" | "pi" | "codex";
|
|
3
3
|
|
|
4
4
|
/** Install scope, a first-class user choice. */
|
|
5
|
-
export type Scope = "project" | "global"
|
|
5
|
+
export type Scope = "project" | "global";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* The filesystem roots path resolution needs; InstallerInput satisfies this structurally.
|
|
9
9
|
* Each host module owns the mapping from an input to its config and rules files.
|
|
10
10
|
*/
|
|
11
11
|
export interface FsRoots {
|
|
12
|
-
readonly home: string
|
|
13
|
-
readonly cwd: string
|
|
12
|
+
readonly home: string;
|
|
13
|
+
readonly cwd: string;
|
|
14
14
|
/** Explicit config home; the CLI edge resolves XDG once and passes it down. */
|
|
15
|
-
readonly configHome?: string
|
|
15
|
+
readonly configHome?: string;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
/** The files a host edit touches for a given scope. */
|
|
19
19
|
export interface HostPaths {
|
|
20
20
|
/** Main host config file the installer edits. */
|
|
21
|
-
readonly config: string
|
|
21
|
+
readonly config: string;
|
|
22
22
|
/** Rules file receiving the marked block for the preferred tier. */
|
|
23
|
-
readonly rules: string
|
|
23
|
+
readonly rules: string;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
/** Control tier per capability; "off" skips the tool entirely. */
|
|
27
|
-
export type Tier = "optional" | "preferred" | "forced" | "off"
|
|
27
|
+
export type Tier = "optional" | "preferred" | "forced" | "off";
|
|
28
28
|
|
|
29
29
|
/** Everything the installer needs to build a plan. */
|
|
30
30
|
export interface InstallerInput {
|
|
31
|
-
readonly host: HostName
|
|
32
|
-
readonly scope: Scope
|
|
33
|
-
readonly search: Tier
|
|
34
|
-
readonly fetch: Tier
|
|
31
|
+
readonly host: HostName;
|
|
32
|
+
readonly scope: Scope;
|
|
33
|
+
readonly search: Tier;
|
|
34
|
+
readonly fetch: Tier;
|
|
35
35
|
/** npm package reference written into host config; defaults to the published name. */
|
|
36
|
-
readonly packageRef: string
|
|
37
|
-
readonly home: string
|
|
38
|
-
readonly cwd: string
|
|
39
|
-
readonly configHome?: string
|
|
36
|
+
readonly packageRef: string;
|
|
37
|
+
readonly home: string;
|
|
38
|
+
readonly cwd: string;
|
|
39
|
+
readonly configHome?: string;
|
|
40
|
+
/**
|
|
41
|
+
* Integration mechanism for hosts with more than one (Pi: the native
|
|
42
|
+
* extension, or the MCP server). Undefined means the host's default path.
|
|
43
|
+
*/
|
|
44
|
+
readonly path?: "extension" | "mcp";
|
|
45
|
+
/**
|
|
46
|
+
* The host CLI's version probed at the CLI edge (for drift notes); undefined
|
|
47
|
+
* means the probe was not attempted or could not read a version.
|
|
48
|
+
*/
|
|
49
|
+
readonly hostVersion?: string;
|
|
40
50
|
}
|
|
41
51
|
|
|
42
52
|
/** A mutation on a parsed config object, addressed by a dotted path. */
|
|
43
53
|
export interface ConfigOp {
|
|
44
|
-
readonly op: "set" | "append-array"
|
|
45
|
-
readonly path: readonly string[]
|
|
46
|
-
readonly value?: unknown
|
|
47
|
-
readonly entries?: readonly unknown[]
|
|
54
|
+
readonly op: "set" | "append-array";
|
|
55
|
+
readonly path: readonly string[];
|
|
56
|
+
readonly value?: unknown;
|
|
57
|
+
readonly entries?: readonly unknown[];
|
|
48
58
|
/** Plan-preview metadata: the value the set replaces (undefined when absent). */
|
|
49
|
-
readonly previous?: unknown
|
|
59
|
+
readonly previous?: unknown;
|
|
50
60
|
/** Plan-preview metadata: entries already present at an append-array path. */
|
|
51
|
-
readonly existingCount?: number
|
|
61
|
+
readonly existingCount?: number;
|
|
52
62
|
}
|
|
53
63
|
|
|
54
64
|
/** A JSON file edit: comment-preserving ops on JSONC text. */
|
|
55
65
|
export interface JsonEdit {
|
|
56
|
-
readonly file: string
|
|
57
|
-
readonly format: "json"
|
|
58
|
-
readonly ops: readonly ConfigOp[]
|
|
66
|
+
readonly file: string;
|
|
67
|
+
readonly format: "json";
|
|
68
|
+
readonly ops: readonly ConfigOp[];
|
|
69
|
+
/** The host maps multiple install locations onto this one config file. */
|
|
70
|
+
readonly shared?: true;
|
|
59
71
|
}
|
|
60
72
|
|
|
61
73
|
/** A TOML file edit: ops on the parsed value, serialized on write. */
|
|
62
74
|
export interface TomlEdit {
|
|
63
|
-
readonly file: string
|
|
64
|
-
readonly format: "toml"
|
|
65
|
-
readonly ops: readonly ConfigOp[]
|
|
75
|
+
readonly file: string;
|
|
76
|
+
readonly format: "toml";
|
|
77
|
+
readonly ops: readonly ConfigOp[];
|
|
78
|
+
/** The host maps multiple install locations onto this one config file. */
|
|
79
|
+
readonly shared?: true;
|
|
66
80
|
}
|
|
67
81
|
|
|
68
82
|
/** A rules file edit: upserts the host's marked block; it carries no ops. */
|
|
69
83
|
export interface RulesEdit {
|
|
70
|
-
readonly file: string
|
|
71
|
-
readonly format: "rules"
|
|
72
|
-
readonly block: string
|
|
84
|
+
readonly file: string;
|
|
85
|
+
readonly format: "rules";
|
|
86
|
+
readonly block: string;
|
|
73
87
|
}
|
|
74
88
|
|
|
75
89
|
/** One file the plan touches, discriminated by config format. */
|
|
76
|
-
export type FileEdit = JsonEdit | TomlEdit | RulesEdit
|
|
90
|
+
export type FileEdit = JsonEdit | TomlEdit | RulesEdit;
|
|
77
91
|
|
|
78
92
|
/** A plan that refuses to proceed (for example a structurally unavailable tier). */
|
|
79
93
|
export interface PlanError {
|
|
80
|
-
readonly message: string
|
|
94
|
+
readonly message: string;
|
|
81
95
|
}
|
|
82
96
|
|
|
83
97
|
export interface InstallPlan {
|
|
84
|
-
|
|
85
|
-
readonly
|
|
86
|
-
readonly
|
|
87
|
-
readonly
|
|
88
|
-
readonly
|
|
89
|
-
readonly
|
|
98
|
+
/** Plan-time undo state; apply rejects a plan superseded by another operation. */
|
|
99
|
+
readonly previousRecord?: InstallRecord;
|
|
100
|
+
readonly host: HostName;
|
|
101
|
+
readonly scope: Scope;
|
|
102
|
+
readonly tiers: { readonly search: Tier; readonly fetch: Tier };
|
|
103
|
+
readonly edits: readonly FileEdit[];
|
|
104
|
+
readonly notes: readonly string[];
|
|
105
|
+
readonly errors: readonly PlanError[];
|
|
90
106
|
/**
|
|
91
107
|
* Artifacts from a previous init that this plan no longer wants (for example the
|
|
92
108
|
* web_search kill switch after a forced → optional tier change). They are shown in
|
|
93
109
|
* the plan, reverted during apply, and then gone from the record — so uninstall
|
|
94
110
|
* still restores exactly what this install applied.
|
|
95
111
|
*/
|
|
96
|
-
readonly reverts?: readonly RecordedFile[]
|
|
112
|
+
readonly reverts?: readonly RecordedFile[];
|
|
97
113
|
}
|
|
98
114
|
|
|
99
115
|
/** An op as it was actually applied, with the previous value for surgical undo. */
|
|
100
116
|
export interface RecordedOp {
|
|
101
|
-
readonly op: "set" | "append-array"
|
|
102
|
-
readonly path: readonly string[]
|
|
103
|
-
readonly applied: unknown
|
|
104
|
-
readonly previous: unknown
|
|
117
|
+
readonly op: "set" | "append-array";
|
|
118
|
+
readonly path: readonly string[];
|
|
119
|
+
readonly applied: unknown;
|
|
120
|
+
readonly previous: unknown;
|
|
105
121
|
}
|
|
106
122
|
|
|
107
123
|
/** A file as it was actually written, for surgical uninstall. */
|
|
108
124
|
export interface RecordedFile {
|
|
109
|
-
readonly file: string
|
|
110
|
-
readonly format: "json" | "toml" | "rules"
|
|
111
|
-
readonly created: boolean
|
|
112
|
-
|
|
113
|
-
readonly
|
|
125
|
+
readonly file: string;
|
|
126
|
+
readonly format: "json" | "toml" | "rules";
|
|
127
|
+
readonly created: boolean;
|
|
128
|
+
/** Shared config ownership must be discoverable from every project. */
|
|
129
|
+
readonly shared?: true;
|
|
130
|
+
readonly ops: readonly RecordedOp[];
|
|
131
|
+
readonly blockApplied?: string;
|
|
114
132
|
/** Path prefixes the apply step had to create; uninstall removes them when left empty. */
|
|
115
|
-
readonly createdParents?: readonly (readonly string[])[]
|
|
133
|
+
readonly createdParents?: readonly (readonly string[])[];
|
|
116
134
|
}
|
|
117
135
|
|
|
118
136
|
/** Persisted install state; one record per host and scope. */
|
|
119
137
|
export interface InstallRecord {
|
|
120
|
-
readonly version: 1
|
|
121
|
-
readonly host: HostName
|
|
122
|
-
readonly scope: Scope
|
|
123
|
-
readonly tiers: { readonly search: Tier; readonly fetch: Tier }
|
|
124
|
-
readonly createdAt: string
|
|
125
|
-
readonly files: readonly RecordedFile[]
|
|
138
|
+
readonly version: 1;
|
|
139
|
+
readonly host: HostName;
|
|
140
|
+
readonly scope: Scope;
|
|
141
|
+
readonly tiers: { readonly search: Tier; readonly fetch: Tier };
|
|
142
|
+
readonly createdAt: string;
|
|
143
|
+
readonly files: readonly RecordedFile[];
|
|
126
144
|
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
1
|
+
import { codecFor } from "./formats";
|
|
2
|
+
import { withOperationLock } from "./operation-lock";
|
|
3
|
+
import { assertFileOwnership, releaseFileOwnership } from "./ownership";
|
|
4
|
+
import { stateDir } from "./paths";
|
|
5
|
+
import { pruneEmptyDir, revertRecordedFile } from "./revert";
|
|
6
|
+
import { claimedByOtherScope, deleteRecord, readRecord, statePathFor } from "./state";
|
|
7
|
+
import type { InstallerInput, InstallRecord } from "./types";
|
|
5
8
|
|
|
6
9
|
/**
|
|
7
10
|
* Reverses exactly what the plan applied: overwritten values restore their
|
|
@@ -10,24 +13,44 @@ import type { InstallerInput } from "./types"
|
|
|
10
13
|
* rules blocks are removed, and files the installer created are deleted.
|
|
11
14
|
*/
|
|
12
15
|
export function uninstall(input: InstallerInput): string[] {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
return withOperationLock([statePathFor(input)], () => {
|
|
17
|
+
const record = readRecord(input);
|
|
18
|
+
if (record === undefined) {
|
|
19
|
+
throw new Error(`No ${input.host} install is recorded for the ${input.scope} scope.`);
|
|
20
|
+
}
|
|
21
|
+
return withOperationLock(
|
|
22
|
+
record.files.map((file) => file.file),
|
|
23
|
+
() => uninstallLocked(input, record),
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function uninstallLocked(input: InstallerInput, record: InstallRecord): string[] {
|
|
29
|
+
assertFileOwnership(input, record.files);
|
|
30
|
+
const other = input.scope === "global" ? "project" : "global";
|
|
31
|
+
const overlap = claimedByOtherScope(
|
|
32
|
+
input,
|
|
33
|
+
record.files.map((file) => file.file),
|
|
34
|
+
);
|
|
19
35
|
if (overlap.length > 0) {
|
|
20
36
|
throw new Error(
|
|
21
37
|
`A ${input.host} install is also recorded at the ${other} scope and claims the same file(s): ${overlap.join(", ")}. ` +
|
|
22
38
|
`Uninstall the ${other}-scope install first so this rollback cannot clobber its entries.`,
|
|
23
|
-
)
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
const lines: string[] = [];
|
|
42
|
+
// Validate all rules before reverting any file, retaining the record on refusal.
|
|
43
|
+
for (const file of record.files) {
|
|
44
|
+
if (file.format !== "rules") continue;
|
|
45
|
+
const loaded = codecFor("rules", record.host).load(file.file);
|
|
46
|
+
if (loaded.parseError) throw new Error(`${file.file}: ${loaded.parseError}`);
|
|
24
47
|
}
|
|
25
|
-
const lines: string[] = []
|
|
26
48
|
for (const file of [...record.files].reverse()) {
|
|
27
|
-
lines.push(...revertRecordedFile(file, record.host, { label: "", whole: true }))
|
|
49
|
+
lines.push(...revertRecordedFile(file, record.host, { label: "", whole: true }));
|
|
28
50
|
}
|
|
29
|
-
deleteRecord(input)
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
51
|
+
deleteRecord(input);
|
|
52
|
+
releaseFileOwnership(input, record.files);
|
|
53
|
+
pruneEmptyDir(stateDir(input, input.scope));
|
|
54
|
+
lines.push(`removed install record (${input.host}, ${input.scope})`);
|
|
55
|
+
return lines;
|
|
33
56
|
}
|
package/src/mcp/auth.ts
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import type { Env } from "
|
|
1
|
+
import { apiKeyFromEnv } from "../core/auth";
|
|
2
|
+
import { API_KEY_ENV } from "../core/constants";
|
|
3
|
+
import type { Env } from "../core/env";
|
|
4
4
|
|
|
5
|
-
export const MISSING_API_KEY_MESSAGE =
|
|
6
|
-
`DeepSeek API key not found. Set ${API_KEY_ENV} in the MCP server's env configuration.`
|
|
5
|
+
export const MISSING_API_KEY_MESSAGE = `DeepSeek API key not found. Set ${API_KEY_ENV} in the MCP server's env configuration.`;
|
|
7
6
|
|
|
8
7
|
/** Resolves the API key from the environment or fails with shared guidance. */
|
|
9
8
|
export function requireApiKey(env: Env): string {
|
|
10
|
-
const apiKey = apiKeyFromEnv(env)
|
|
11
|
-
if (!apiKey) throw new Error(MISSING_API_KEY_MESSAGE)
|
|
12
|
-
return apiKey
|
|
9
|
+
const apiKey = apiKeyFromEnv(env);
|
|
10
|
+
if (!apiKey) throw new Error(MISSING_API_KEY_MESSAGE);
|
|
11
|
+
return apiKey;
|
|
13
12
|
}
|
package/src/mcp/cli.ts
CHANGED
|
@@ -1,39 +1,57 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { serveStdio } from "@modelcontextprotocol/server/stdio"
|
|
3
|
-
import { apiKeyFromEnv } from "../core/auth"
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
2
|
+
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
3
|
+
import { apiKeyFromEnv } from "../core/auth";
|
|
4
|
+
import { resolveEnvOptions } from "../core/env";
|
|
5
|
+
import { MISSING_API_KEY_MESSAGE } from "./auth";
|
|
6
|
+
import { isJsonReplacingClient } from "./client";
|
|
7
|
+
import { createDeepSeekServer, servePrecheck } from "./server";
|
|
7
8
|
|
|
8
9
|
// Installer subcommands run before the server path; MCP clients spawn this bin
|
|
9
10
|
// with no arguments, so the default stdio behavior is unchanged.
|
|
10
|
-
const subcommand = process.argv[2]
|
|
11
|
+
const subcommand = process.argv[2];
|
|
11
12
|
if (subcommand === "init" || subcommand === "uninstall" || subcommand === "list") {
|
|
12
|
-
const { runInstallerCli } = await import("../installer/cli")
|
|
13
|
-
process.exit(await runInstallerCli(process.argv.slice(2)))
|
|
13
|
+
const { runInstallerCli } = await import("../installer/cli");
|
|
14
|
+
process.exit(await runInstallerCli(process.argv.slice(2)));
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
// stdout is the JSON-RPC channel; all diagnostics must go to stderr.
|
|
17
|
-
const env = process.env
|
|
18
|
+
const env = process.env;
|
|
18
19
|
if (!apiKeyFromEnv(env)) {
|
|
19
|
-
console.error(MISSING_API_KEY_MESSAGE)
|
|
20
|
-
process.exit(1)
|
|
20
|
+
console.error(MISSING_API_KEY_MESSAGE);
|
|
21
|
+
process.exit(1);
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
const config = resolveEnvOptions(env)
|
|
24
|
-
const precheck = servePrecheck(config)
|
|
24
|
+
const config = resolveEnvOptions(env);
|
|
25
|
+
const precheck = servePrecheck(config);
|
|
25
26
|
if (precheck) {
|
|
26
|
-
console.error(precheck)
|
|
27
|
-
process.exit(1)
|
|
27
|
+
console.error(precheck);
|
|
28
|
+
process.exit(1);
|
|
28
29
|
}
|
|
29
|
-
const handle = serveStdio(() =>
|
|
30
|
+
const handle = serveStdio(() => {
|
|
31
|
+
const server = createDeepSeekServer(config, env);
|
|
32
|
+
// Fires after the handshake-era initialize exchange (the stateless era opens
|
|
33
|
+
// differently and simply never logs). One stderr line so a human run shows
|
|
34
|
+
// which client connected and whether text-only shaping kicked in; if a host
|
|
35
|
+
// renames its clientInfo, this line is where the regression becomes visible.
|
|
36
|
+
server.server.oninitialized = () => {
|
|
37
|
+
const info = server.server.getClientVersion();
|
|
38
|
+
if (info === undefined) return;
|
|
39
|
+
console.error(
|
|
40
|
+
`mcp client: ${info.name} ${info.version ?? "unknown"} — ` +
|
|
41
|
+
(isJsonReplacingClient(info.name)
|
|
42
|
+
? "text-only results (this client replaces content with serialized structuredContent)"
|
|
43
|
+
: "text + structuredContent results"),
|
|
44
|
+
);
|
|
45
|
+
};
|
|
46
|
+
return server;
|
|
47
|
+
});
|
|
30
48
|
const tools = [
|
|
31
49
|
config.search.enabled ? `search (max ${config.search.maxResults} results)` : null,
|
|
32
50
|
config.fetch.enabled ? `fetch (max ${config.fetch.maxChars} chars)` : null,
|
|
33
51
|
]
|
|
34
52
|
.filter(Boolean)
|
|
35
|
-
.join(", ")
|
|
36
|
-
console.error(`deepseek-web-retrieval MCP server on stdio (${tools})`)
|
|
53
|
+
.join(", ");
|
|
54
|
+
console.error(`deepseek-web-retrieval MCP server on stdio (${tools})`);
|
|
37
55
|
|
|
38
|
-
process.on("SIGINT", () => void handle.close())
|
|
39
|
-
process.on("SIGTERM", () => void handle.close())
|
|
56
|
+
process.on("SIGINT", () => void handle.close());
|
|
57
|
+
process.on("SIGTERM", () => void handle.close());
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { McpServer, ServerContext } from "@modelcontextprotocol/server";
|
|
2
|
+
import { CLIENT_INFO_META_KEY } from "@modelcontextprotocol/server";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Clients that discard a result's content blocks and hand the model the serialized
|
|
6
|
+
* `structuredContent` JSON instead. For these the server omits `structuredContent`
|
|
7
|
+
* so the model reads the text blocks. The declared names are research facts —
|
|
8
|
+
* docs/agents/codex.md records where codex-mcp-client comes from.
|
|
9
|
+
*/
|
|
10
|
+
const JSON_REPLACING_CLIENT_NAMES = new Set(["codex-mcp-client"]);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The connected client's declared name. The per-request envelope comes first (the
|
|
14
|
+
* 2026-07-28 era, where clientInfo is a SHOULD, not a MUST); the initialize-scoped
|
|
15
|
+
* accessor covers the handshake era and is backfilled per request on modern
|
|
16
|
+
* connections, so it works as the fallback for both.
|
|
17
|
+
*/
|
|
18
|
+
export function clientNameOf(server: McpServer, ctx: ServerContext): string | undefined {
|
|
19
|
+
const envelope = ctx.mcpReq.envelope as Record<string, unknown> | undefined;
|
|
20
|
+
const name = envelope?.[CLIENT_INFO_META_KEY];
|
|
21
|
+
if (typeof name === "object" && name !== null && "name" in name && typeof name.name === "string") {
|
|
22
|
+
return name.name;
|
|
23
|
+
}
|
|
24
|
+
return server.server.getClientVersion()?.name;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** True when a client declaring this name would replace text content with serialized JSON. */
|
|
28
|
+
export function isJsonReplacingClient(name: string | undefined): boolean {
|
|
29
|
+
return name !== undefined && JSON_REPLACING_CLIENT_NAMES.has(name);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** True when the connected client would replace text content with serialized JSON. */
|
|
33
|
+
export function omitStructuredContent(server: McpServer, ctx: ServerContext): boolean {
|
|
34
|
+
return isJsonReplacingClient(clientNameOf(server, ctx));
|
|
35
|
+
}
|