deepseek-web-retrieval 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +93 -244
  2. package/README.md +16 -11
  3. package/dist/cli.js +1366 -615
  4. package/docs/agents/codex.md +28 -7
  5. package/docs/agents/opencode-v2.md +1 -1
  6. package/docs/agents/pi.md +88 -86
  7. package/docs/design/installer-design.md +72 -9
  8. package/docs/hosts.jsonc +2 -2
  9. package/docs/integration.md +14 -9
  10. package/docs/research/control-matrix.md +48 -13
  11. package/docs/research/mcp-compatibility.md +9 -4
  12. package/examples/pi.settings.jsonc +17 -0
  13. package/index.ts +2 -2
  14. package/package.json +35 -4
  15. package/src/core/auth.ts +4 -6
  16. package/src/core/client.ts +65 -53
  17. package/src/core/constants.ts +24 -24
  18. package/src/core/descriptions.ts +30 -6
  19. package/src/core/domains.ts +56 -67
  20. package/src/core/env.ts +112 -0
  21. package/src/core/format.ts +53 -50
  22. package/src/core/index.ts +9 -9
  23. package/src/core/jsonc.ts +6 -6
  24. package/src/core/object.ts +4 -0
  25. package/src/core/options.ts +20 -25
  26. package/src/core/search.ts +45 -44
  27. package/src/core/transport.ts +109 -113
  28. package/src/core/types.ts +47 -47
  29. package/src/core/url.ts +20 -20
  30. package/src/installer/apply.ts +256 -81
  31. package/src/installer/cli.ts +135 -104
  32. package/src/installer/detect.ts +46 -31
  33. package/src/installer/formats/index.ts +26 -28
  34. package/src/installer/formats/json.ts +81 -77
  35. package/src/installer/formats/rules.ts +55 -28
  36. package/src/installer/formats/toml.ts +88 -81
  37. package/src/installer/formats/value.ts +34 -30
  38. package/src/installer/hosts/codex.ts +100 -54
  39. package/src/installer/hosts/index.ts +126 -41
  40. package/src/installer/hosts/opencode.ts +70 -51
  41. package/src/installer/hosts/pi.ts +269 -75
  42. package/src/installer/hosts/shared.ts +19 -14
  43. package/src/installer/operation-lock.ts +54 -0
  44. package/src/installer/ownership.ts +129 -0
  45. package/src/installer/paths.ts +7 -5
  46. package/src/installer/persistence.ts +66 -0
  47. package/src/installer/render.ts +29 -29
  48. package/src/installer/revert.ts +33 -29
  49. package/src/installer/rulesContent.ts +24 -13
  50. package/src/installer/state.ts +116 -31
  51. package/src/installer/types.ts +74 -56
  52. package/src/installer/uninstall.ts +40 -17
  53. package/src/mcp/auth.ts +7 -8
  54. package/src/mcp/cli.ts +38 -20
  55. package/src/mcp/client.ts +35 -0
  56. package/src/mcp/server.ts +68 -97
  57. package/src/opencode/auth.ts +24 -26
  58. package/src/opencode/plugin.ts +99 -87
  59. package/src/pi/extension.ts +164 -0
  60. package/src/pi/settings.ts +49 -0
  61. package/src/mcp/config.ts +0 -112
@@ -1,154 +1,174 @@
1
- import { homedir } from "node:os"
2
- import { resolve } from "node:path"
3
- import { HOST_PLANNERS, planForHost } from "./hosts"
4
- import { applyPlan } from "./apply"
5
- import { renderPlan } from "./render"
6
- import { listRecords } from "./state"
7
- import { uninstall } from "./uninstall"
8
- import type { HostName, InstallerInput, Scope, Tier } from "./types"
9
-
10
- const HOSTS = Object.keys(HOST_PLANNERS) as readonly HostName[]
11
- const HOST_CHOICES = `<${HOSTS.join("|")}>`
12
- const SCOPES: readonly Scope[] = ["project", "global"]
13
- const TIERS: readonly Tier[] = ["optional", "preferred", "forced", "off"]
1
+ import { homedir } from "node:os";
2
+ import { resolve } from "node:path";
3
+ import { applyPlan } from "./apply";
4
+ import { HOST_PLANNERS, HOST_VERSION_PROBES, planForHost } from "./hosts";
5
+ import { renderPlan } from "./render";
6
+ import { listRecords } from "./state";
7
+ import type { HostName, InstallerInput, Scope, Tier } from "./types";
8
+ import { uninstall } from "./uninstall";
9
+
10
+ const HOSTS = Object.keys(HOST_PLANNERS) as readonly HostName[];
11
+ const HOST_CHOICES = `<${HOSTS.join("|")}>`;
12
+ const SCOPES: readonly Scope[] = ["project", "global"];
13
+ const TIERS: readonly Tier[] = ["optional", "preferred", "forced", "off"];
14
14
 
15
15
  const USAGE = `Usage:
16
16
  deepseek-web-retrieval init --host ${HOST_CHOICES} --scope <project|global> \
17
17
  --search <optional|preferred|forced|off> --fetch <optional|preferred|forced|off> \
18
- [--package <npm ref>] [--cwd <dir>] [--apply]
18
+ [--path <extension|mcp>] [--package <npm ref>] [--cwd <dir>] [--apply]
19
19
  deepseek-web-retrieval uninstall --host ${HOST_CHOICES} --scope <project|global> [--cwd <dir>]
20
20
  deepseek-web-retrieval list [--cwd <dir>]
21
21
 
22
22
  init prints the full plan (every file, old value → new value) and writes nothing
23
- until --apply is passed. uninstall restores what the plan applied.`
23
+ until --apply is passed. uninstall restores what the plan applied. --path picks
24
+ the integration mechanism for hosts with more than one (Pi: extension by
25
+ default, mcp for the stdio MCP server). --package overrides the package ref
26
+ written into host config; the Pi extension path turns it into the packages
27
+ source (a bare name is prefixed with npm:, a ref that already starts with npm:
28
+ is written as-is).`;
24
29
 
25
30
  interface Flags {
26
- readonly values: Map<string, string>
27
- readonly booleans: Set<string>
28
- readonly problems: readonly string[]
31
+ readonly values: Map<string, string>;
32
+ readonly booleans: Set<string>;
33
+ readonly problems: readonly string[];
29
34
  }
30
35
 
31
- const VALUE_FLAGS = new Set(["host", "scope", "search", "fetch", "package", "cwd"])
32
- const BOOLEAN_FLAGS = new Set(["apply"])
36
+ const VALUE_FLAGS = new Set(["host", "scope", "search", "fetch", "path", "package", "cwd"]);
37
+ const BOOLEAN_FLAGS = new Set(["apply"]);
38
+ const PATH_CHOICES: readonly ["extension", "mcp"] = ["extension", "mcp"];
33
39
 
34
40
  function parseFlags(args: readonly string[]): Flags {
35
- const values = new Map<string, string>()
36
- const booleans = new Set<string>()
37
- const problems: string[] = []
41
+ const values = new Map<string, string>();
42
+ const booleans = new Set<string>();
43
+ const problems: string[] = [];
38
44
  for (let i = 0; i < args.length; i += 1) {
39
- const arg = args[i] as string
45
+ const arg = args[i] as string;
40
46
  if (!arg.startsWith("--")) {
41
- problems.push(`unexpected argument "${arg}" (flags only)`)
42
- continue
47
+ problems.push(`unexpected argument "${arg}" (flags only)`);
48
+ continue;
43
49
  }
44
- const eq = arg.indexOf("=")
45
- const name = eq === -1 ? arg.slice(2) : arg.slice(2, eq)
50
+ const eq = arg.indexOf("=");
51
+ const name = eq === -1 ? arg.slice(2) : arg.slice(2, eq);
46
52
  if (BOOLEAN_FLAGS.has(name)) {
47
- booleans.add(name)
48
- continue
53
+ if (eq !== -1) {
54
+ problems.push(`--${name} does not accept a value; use --${name} to enable it or omit it`);
55
+ continue;
56
+ }
57
+ booleans.add(name);
58
+ continue;
49
59
  }
50
60
  if (!VALUE_FLAGS.has(name)) {
51
- problems.push(`unknown flag --${name}`)
52
- continue
61
+ problems.push(`unknown flag --${name}`);
62
+ continue;
53
63
  }
54
64
  if (eq !== -1) {
55
- values.set(name, arg.slice(eq + 1))
56
- continue
65
+ values.set(name, arg.slice(eq + 1));
66
+ continue;
57
67
  }
58
- const next = args[i + 1]
68
+ const next = args[i + 1];
59
69
  if (next === undefined || next.startsWith("--")) {
60
- problems.push(`--${name} requires a value`)
61
- continue
70
+ problems.push(`--${name} requires a value`);
71
+ continue;
62
72
  }
63
- values.set(name, next)
64
- i += 1
73
+ values.set(name, next);
74
+ i += 1;
65
75
  }
66
- return { values, booleans, problems }
76
+ return { values, booleans, problems };
67
77
  }
68
78
 
69
79
  /** Where the CLI writes; injectable so tests can assert command output. */
70
80
  export interface CliIo {
71
- readonly out?: (line: string) => void
72
- readonly err?: (line: string) => void
81
+ readonly out?: (line: string) => void;
82
+ readonly err?: (line: string) => void;
73
83
  }
74
84
 
75
85
  /** CLI entry for the init/uninstall/list subcommands; returns the process exit code. */
76
86
  export async function runInstallerCli(argv: readonly string[], io: CliIo = {}): Promise<number> {
77
- const out = io.out ?? ((line: string) => console.log(line))
78
- const err = io.err ?? ((line: string) => console.error(line))
79
- const [command, ...rest] = argv
87
+ const out = io.out ?? ((line: string) => console.log(line));
88
+ const err = io.err ?? ((line: string) => console.error(line));
89
+ const [command, ...rest] = argv;
80
90
  if (command === undefined || command === "help" || command === "--help") {
81
- out(USAGE)
82
- return command === undefined ? 1 : 0
91
+ out(USAGE);
92
+ return command === undefined ? 1 : 0;
83
93
  }
84
- const flags = parseFlags(rest)
94
+ const flags = parseFlags(rest);
85
95
  if (flags.problems.length > 0) {
86
- for (const problem of flags.problems) err(`error: ${problem}`)
87
- err(`\n${USAGE}`)
88
- return 1
96
+ for (const problem of flags.problems) err(`error: ${problem}`);
97
+ err(`\n${USAGE}`);
98
+ return 1;
89
99
  }
90
- const cwd = resolve(flags.values.get("cwd") ?? process.cwd())
91
- const home = process.env.HOME ?? homedir()
92
- const configHome = resolveConfigHome(home)
100
+ const cwd = resolve(flags.values.get("cwd") ?? process.cwd());
101
+ const home = process.env.HOME ?? homedir();
102
+ const configHome = resolveConfigHome(home);
93
103
 
94
104
  if (command === "list") {
95
- const records = listRecords({ home, cwd, configHome })
105
+ let records: ReturnType<typeof listRecords>;
106
+ try {
107
+ records = listRecords({ home, cwd, configHome });
108
+ } catch (error) {
109
+ err(error instanceof Error ? error.message : String(error));
110
+ return 1;
111
+ }
96
112
  if (records.length === 0) {
97
- out("No installs recorded.")
98
- return 0
113
+ out("No installs recorded.");
114
+ return 0;
99
115
  }
100
116
  for (const record of records) {
101
- out(`${record.host} (${record.scope}) — search=${record.tiers.search}, fetch=${record.tiers.fetch}, installed ${record.createdAt}`)
102
- for (const file of record.files) out(` ${file.created ? "created" : "modified"} ${file.file}`)
117
+ out(
118
+ `${record.host} (${record.scope}) — search=${record.tiers.search}, fetch=${record.tiers.fetch}, installed ${record.createdAt}`,
119
+ );
120
+ for (const file of record.files) out(` ${file.created ? "created" : "modified"} ${file.file}`);
103
121
  }
104
- return 0
122
+ return 0;
105
123
  }
106
124
 
107
125
  if (command === "uninstall") {
108
- const required = requireInput(flags, cwd, home, configHome, false)
109
- if (!required.ok) return usageError(required.problems, err)
126
+ const required = requireInput(flags, cwd, home, configHome, false);
127
+ if (!required.ok) return usageError(required.problems, err);
110
128
  try {
111
- for (const line of uninstall(required.input)) out(line)
112
- return 0
129
+ for (const line of uninstall(required.input)) out(line);
130
+ return 0;
113
131
  } catch (error) {
114
- err(error instanceof Error ? error.message : String(error))
115
- return 1
132
+ err(error instanceof Error ? error.message : String(error));
133
+ return 1;
116
134
  }
117
135
  }
118
136
 
119
137
  if (command !== "init") {
120
- err(`Unknown command "${command}".\n\n${USAGE}`)
121
- return 1
138
+ err(`Unknown command "${command}".\n\n${USAGE}`);
139
+ return 1;
122
140
  }
123
141
 
124
- const required = requireInput(flags, cwd, home, configHome, true)
125
- if (!required.ok) return usageError(required.problems, err)
126
- const { input } = required
127
- const plan = planForHost(input.host, input)
142
+ const required = requireInput(flags, cwd, home, configHome, true);
143
+ if (!required.ok) return usageError(required.problems, err);
144
+ const { input } = required;
145
+ const probe = HOST_VERSION_PROBES[input.host];
146
+ const hostVersion = probe?.(err);
147
+ const plan = planForHost(input.host, hostVersion === undefined ? input : { ...input, hostVersion });
128
148
  if (plan.errors.length > 0) {
129
- for (const error of plan.errors) err(`error: ${error.message}`)
130
- err("Nothing was written.")
131
- return 1
149
+ for (const error of plan.errors) err(`error: ${error.message}`);
150
+ err("Nothing was written.");
151
+ return 1;
132
152
  }
133
- out(renderPlan(plan))
153
+ out(renderPlan(plan));
134
154
  if (!flags.booleans.has("apply")) {
135
- out("\nPlan only — re-run with --apply to write these changes.")
136
- return 0
155
+ out("\nPlan only — re-run with --apply to write these changes.");
156
+ return 0;
137
157
  }
138
158
  try {
139
- const result = applyPlan(plan, input)
140
- for (const line of result.lines) out(line)
141
- return result.ok ? 0 : 1
159
+ const result = applyPlan(plan, input);
160
+ for (const line of result.lines) out(line);
161
+ return result.ok ? 0 : 1;
142
162
  } catch (error) {
143
- err(error instanceof Error ? error.message : String(error))
144
- return 1
163
+ err(error instanceof Error ? error.message : String(error));
164
+ return 1;
145
165
  }
146
166
  }
147
167
 
148
168
  function usageError(problems: readonly string[], err: (line: string) => void): number {
149
- for (const problem of problems) err(`error: ${problem}`)
150
- err(`\n${USAGE}`)
151
- return 1
169
+ for (const problem of problems) err(`error: ${problem}`);
170
+ err(`\n${USAGE}`);
171
+ return 1;
152
172
  }
153
173
 
154
174
  /**
@@ -156,14 +176,14 @@ function usageError(problems: readonly string[], err: (line: string) => void): n
156
176
  * environment read happens here, at the edge, and flows down as explicit input.
157
177
  */
158
178
  function resolveConfigHome(home: string): string | undefined {
159
- const xdg = process.env.XDG_CONFIG_HOME
160
- if (xdg && home === (process.env.HOME ?? "")) return xdg
161
- return undefined
179
+ const xdg = process.env.XDG_CONFIG_HOME;
180
+ if (xdg && home === (process.env.HOME ?? "")) return xdg;
181
+ return undefined;
162
182
  }
163
183
 
164
184
  type RequireInputResult =
165
185
  | { readonly ok: true; readonly input: InstallerInput }
166
- | { readonly ok: false; readonly problems: readonly string[] }
186
+ | { readonly ok: false; readonly problems: readonly string[] };
167
187
 
168
188
  function requireInput(
169
189
  flags: Flags,
@@ -172,33 +192,43 @@ function requireInput(
172
192
  configHome: string | undefined,
173
193
  needsTiers: boolean,
174
194
  ): RequireInputResult {
175
- const problems: string[] = []
176
- const host = flags.values.get("host")
177
- const scope = flags.values.get("scope")
178
- const search = needsTiers ? (flags.values.get("search") ?? "") : "optional"
179
- const fetch = needsTiers ? (flags.values.get("fetch") ?? "") : "optional"
180
- if (host === undefined) problems.push(`--host is required (one of: ${HOSTS.join(", ")})`)
181
- else if (!HOSTS.includes(host as HostName)) problems.push(`invalid --host value "${host}" (one of: ${HOSTS.join(", ")})`)
182
- if (scope === undefined) problems.push(`--scope is required (one of: ${SCOPES.join(", ")})`)
183
- else if (!SCOPES.includes(scope as Scope)) problems.push(`invalid --scope value "${scope}" (one of: ${SCOPES.join(", ")})`)
195
+ const problems: string[] = [];
196
+ const host = flags.values.get("host");
197
+ const scope = flags.values.get("scope");
198
+ const search = needsTiers ? (flags.values.get("search") ?? "") : "optional";
199
+ const fetch = needsTiers ? (flags.values.get("fetch") ?? "") : "optional";
200
+ const path = flags.values.get("path");
201
+ if (host === undefined) problems.push(`--host is required (one of: ${HOSTS.join(", ")})`);
202
+ else if (!HOSTS.includes(host as HostName))
203
+ problems.push(`invalid --host value "${host}" (one of: ${HOSTS.join(", ")})`);
204
+ if (scope === undefined) problems.push(`--scope is required (one of: ${SCOPES.join(", ")})`);
205
+ else if (!SCOPES.includes(scope as Scope))
206
+ problems.push(`invalid --scope value "${scope}" (one of: ${SCOPES.join(", ")})`);
207
+ if (path !== undefined) {
208
+ if (host !== "pi") {
209
+ problems.push(`--path is only supported for host pi (${host ?? "no host"} has a single integration path)`);
210
+ } else if (!(PATH_CHOICES as readonly string[]).includes(path)) {
211
+ problems.push(`invalid --path value "${path}" (one of: ${PATH_CHOICES.join(", ")})`);
212
+ }
213
+ }
184
214
  if (needsTiers) {
185
215
  if (!TIERS.includes(search as Tier)) {
186
216
  problems.push(
187
217
  flags.values.has("search")
188
218
  ? `invalid --search value "${search}" (one of: ${TIERS.join(", ")})`
189
219
  : `--search is required (one of: ${TIERS.join(", ")})`,
190
- )
220
+ );
191
221
  }
192
222
  if (!TIERS.includes(fetch as Tier)) {
193
223
  problems.push(
194
224
  flags.values.has("fetch")
195
225
  ? `invalid --fetch value "${fetch}" (one of: ${TIERS.join(", ")})`
196
226
  : `--fetch is required (one of: ${TIERS.join(", ")})`,
197
- )
227
+ );
198
228
  }
199
229
  }
200
- if (home === "") problems.push("HOME is not set and the user home cannot be resolved; cannot proceed")
201
- if (problems.length > 0) return { ok: false, problems }
230
+ if (home === "") problems.push("HOME is not set and the user home cannot be resolved; cannot proceed");
231
+ if (problems.length > 0) return { ok: false, problems };
202
232
  return {
203
233
  ok: true,
204
234
  input: {
@@ -210,6 +240,7 @@ function requireInput(
210
240
  home,
211
241
  cwd,
212
242
  configHome,
243
+ ...(path !== undefined ? { path: path as "extension" | "mcp" } : {}),
213
244
  },
214
- }
245
+ };
215
246
  }
@@ -1,10 +1,10 @@
1
- import { existsSync, readFileSync } from "node:fs"
2
- import { removeBlock } from "./formats/rules"
3
- import { isPlainObject } from "./formats/value"
4
- import type { HostName } from "./types"
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { removeBlock } from "./formats/rules";
3
+ import { isPlainObject } from "./formats/value";
4
+ import type { HostName } from "./types";
5
5
 
6
6
  /** Shared plan error: both capabilities off leaves nothing to install. */
7
- export const BOTH_CAPABILITIES_OFF = "Both capabilities are off; nothing would be installed."
7
+ export const BOTH_CAPABILITIES_OFF = "Both capabilities are off; nothing would be installed.";
8
8
 
9
9
  /**
10
10
  * Shared Forced-tier cost warning; the README's "Privacy and cost" section carries
@@ -12,20 +12,20 @@ export const BOTH_CAPABILITIES_OFF = "Both capabilities are off; nothing would b
12
12
  */
13
13
  export const FORCED_COST_NOTE =
14
14
  "Forced routing is deterministic, and Search and Fetch are billed per successful request — " +
15
- 'see the README\'s "Privacy and cost" section before applying.'
15
+ 'see the README\'s "Privacy and cost" section before applying.';
16
16
 
17
17
  /** Shared note when a previous deepseek entry is being replaced. */
18
18
  export function replacingExistingEntryNote(path: string): string {
19
- return `An existing ${path} entry is being replaced; its previous form is recorded for uninstall.`
19
+ return `An existing ${path} entry is being replaced; its previous form is recorded for uninstall.`;
20
20
  }
21
21
 
22
22
  /** Shared note for third-party servers left registered. */
23
23
  export function othersStayRegisteredNote(others: readonly string[]): string {
24
- return `Other MCP servers stay registered (${others.join(", ")}); routing to DeepSeek is not exclusive while they offer web tools.`
24
+ return `Other MCP servers stay registered (${others.join(", ")}); routing to DeepSeek is not exclusive while they offer web tools.`;
25
25
  }
26
26
 
27
27
  const WEB_TOOL_HINT =
28
- /(web[_ -]?(search|fetch)|search|fetch|browse|scrape|crawl|tavily|brave|exa|perplexity|firecrawl|jina|serp|duckduckgo|reader)/i
28
+ /(web[_ -]?(search|fetch)|search|fetch|browse|scrape|crawl|tavily|brave|exa|perplexity|firecrawl|jina|serp|duckduckgo|reader)/i;
29
29
 
30
30
  /**
31
31
  * Heuristic web-capability test for a registered third-party MCP server, matching
@@ -34,16 +34,30 @@ const WEB_TOOL_HINT =
34
34
  * "not exclusive while they offer web tools" note.
35
35
  */
36
36
  export function looksWebCapable(name: string, entry: unknown): boolean {
37
- if (WEB_TOOL_HINT.test(name)) return true
38
- if (!isPlainObject(entry)) return false
39
- const command = typeof entry.command === "string" ? entry.command : ""
40
- const url = typeof entry.url === "string" ? entry.url : ""
41
- const args = Array.isArray(entry.args) ? entry.args.filter((arg): arg is string => typeof arg === "string").join(" ") : ""
42
- return WEB_TOOL_HINT.test(`${command} ${args} ${url}`)
37
+ if (WEB_TOOL_HINT.test(name)) return true;
38
+ if (!isPlainObject(entry)) return false;
39
+ const command = typeof entry.command === "string" ? entry.command : "";
40
+ const url = typeof entry.url === "string" ? entry.url : "";
41
+ const args = Array.isArray(entry.args)
42
+ ? entry.args.filter((arg): arg is string => typeof arg === "string").join(" ")
43
+ : "";
44
+ return WEB_TOOL_HINT.test(`${command} ${args} ${url}`);
45
+ }
46
+
47
+ /**
48
+ * Matches a source or package ref that is this package in another form — a fork
49
+ * (`github:owner/deepseek-web-retrieval`), a pinned version
50
+ * (`npm:deepseek-web-retrieval@<version>`), a packed tarball
51
+ * (`npm:file:…/deepseek-web-retrieval-<version>.tgz`) — but not an unrelated
52
+ * package whose name merely contains ours (`deepseek-web-retrieval-extra`).
53
+ * The match is on the package-name boundary, not a raw substring.
54
+ */
55
+ export function looksLikeOwnPackage(source: string): boolean {
56
+ return /(^|[/:@])deepseek-web-retrieval($|[@/]|-\d)/.test(source);
43
57
  }
44
58
 
45
59
  const STEERING_HINT =
46
- /(web[_ ]?(search|fetch)|websearch|webfetch|tavily|exa|brave|perplexity|firecrawl|jina|serp|duckduckgo|google|bing)/i
60
+ /(web[_ ]?(search|fetch)|websearch|webfetch|tavily|exa|brave|perplexity|firecrawl|jina|serp|duckduckgo|google|bing)/i;
47
61
 
48
62
  /**
49
63
  * Detect-phase scan of the rules file the plan would write to: lines steering the
@@ -51,35 +65,36 @@ const STEERING_HINT =
51
65
  * deepseek mentions are ours to make, so both are ignored.
52
66
  */
53
67
  export function findCompetitorSteering(host: HostName, file: string): string[] {
54
- if (!existsSync(file)) return []
55
- let text: string
68
+ if (!existsSync(file)) return [];
69
+ let text: string;
56
70
  try {
57
- text = readFileSync(file, "utf8")
71
+ text = readFileSync(file, "utf8");
58
72
  } catch {
59
- return []
73
+ return [];
60
74
  }
61
75
  return removeBlock(host, text)
62
76
  .split("\n")
63
77
  .map((line) => line.trim())
64
78
  .filter((line) => line.length > 0 && STEERING_HINT.test(line) && !line.includes("deepseek"))
65
- .slice(0, 2)
79
+ .slice(0, 2);
66
80
  }
67
81
 
68
82
  /** Shared note for steering the detect phase found; conflicts are surfaced, not resolved. */
69
83
  export function competitorSteeringNote(file: string, lines: readonly string[]): string {
70
- const quoted = lines.map((line) => `"${line}"`).join(", ")
71
- return `Existing rules in ${file} steer toward other web tools (${quoted}); the plan surfaces this conflict — it does not resolve it.`
84
+ const quoted = lines.map((line) => `"${line}"`).join(", ");
85
+ return `Existing rules in ${file} steer toward other web tools (${quoted}); the plan surfaces this conflict — it does not resolve it.`;
72
86
  }
73
87
 
74
88
  /** Splits other registered servers into web-capable competitors and bystanders. */
75
- export function splitCompetitors(
76
- servers: Record<string, unknown>,
77
- ): { readonly competitors: readonly string[]; readonly bystanders: readonly string[] } {
78
- const competitors: string[] = []
79
- const bystanders: string[] = []
89
+ export function splitCompetitors(servers: Record<string, unknown>): {
90
+ readonly competitors: readonly string[];
91
+ readonly bystanders: readonly string[];
92
+ } {
93
+ const competitors: string[] = [];
94
+ const bystanders: string[] = [];
80
95
  for (const [name, entry] of Object.entries(servers)) {
81
- if (name === "deepseek") continue
82
- ;(looksWebCapable(name, entry) ? competitors : bystanders).push(name)
96
+ if (name === "deepseek") continue;
97
+ (looksWebCapable(name, entry) ? competitors : bystanders).push(name);
83
98
  }
84
- return { competitors, bystanders }
99
+ return { competitors, bystanders };
85
100
  }
@@ -1,33 +1,32 @@
1
- import { mkdirSync, writeFileSync } from "node:fs"
2
- import { dirname } from "node:path"
3
- import type { ConfigOp, FileEdit, HostName, RecordedFile, RecordedOp } from "../types"
4
- import { jsonCodec } from "./json"
5
- import { rulesCodec } from "./rules"
6
- import { tomlCodec } from "./toml"
1
+ import { atomicWrite } from "../persistence";
2
+ import type { ConfigOp, FileEdit, HostName, RecordedFile, RecordedOp } from "../types";
3
+ import { jsonCodec } from "./json";
4
+ import { rulesCodec } from "./rules";
5
+ import { tomlCodec } from "./toml";
7
6
 
8
- export type ConfigFormat = FileEdit["format"]
7
+ export type ConfigFormat = FileEdit["format"];
9
8
 
10
9
  /** A config file as the codec read it: parsed, or carrying the parse error. */
11
10
  export interface LoadedConfig {
12
- readonly exists: boolean
13
- readonly text: string
14
- readonly value: Record<string, unknown>
11
+ readonly exists: boolean;
12
+ readonly text: string;
13
+ readonly value: Record<string, unknown>;
15
14
  /** True when serializing the parsed value would drop comments (TOML only; JSON edits preserve them). */
16
- readonly lossy: boolean
17
- readonly parseError?: string
15
+ readonly lossy: boolean;
16
+ readonly parseError?: string;
18
17
  }
19
18
 
20
19
  /** What applying one edit produced, beyond the new text: everything undo needs. */
21
20
  export interface AppliedText {
22
- readonly text: string
23
- readonly recorded: readonly RecordedOp[]
24
- readonly createdParents: readonly (readonly string[])[]
21
+ readonly text: string;
22
+ readonly recorded: readonly RecordedOp[];
23
+ readonly createdParents: readonly (readonly string[])[];
25
24
  }
26
25
 
27
26
  /** What reverting one recorded file produced: the text plus notes about skipped work. */
28
27
  export interface RevertedText {
29
- readonly text: string
30
- readonly notes: readonly string[]
28
+ readonly text: string;
29
+ readonly notes: readonly string[];
31
30
  }
32
31
 
33
32
  /**
@@ -36,23 +35,22 @@ export interface RevertedText {
36
35
  * revert reverses a recorded file, and isEffectivelyEmpty gates whole-file deletion.
37
36
  */
38
37
  export interface FormatCodec {
39
- readonly format: ConfigFormat
40
- load(file: string): LoadedConfig
41
- apply(text: string, edit: FileEdit): AppliedText
42
- verify(text: string, edit: FileEdit, op?: ConfigOp): boolean
43
- revert(text: string, recorded: RecordedFile): RevertedText
44
- isEffectivelyEmpty(text: string): boolean
38
+ readonly format: ConfigFormat;
39
+ load(file: string): LoadedConfig;
40
+ apply(text: string, edit: FileEdit): AppliedText;
41
+ verify(text: string, edit: FileEdit, op?: ConfigOp): boolean;
42
+ revert(text: string, recorded: RecordedFile): RevertedText;
43
+ isEffectivelyEmpty(text: string): boolean;
45
44
  }
46
45
 
47
46
  /** Shared write step: every codec consumer writes config files through this. */
48
47
  export function writeConfig(file: string, serialized: string): void {
49
- mkdirSync(dirname(file), { recursive: true })
50
- writeFileSync(file, serialized)
48
+ atomicWrite(file, serialized);
51
49
  }
52
50
 
53
51
  /** The one entry point to a format's codec; only the rules codec cares about the host. */
54
52
  export function codecFor(format: ConfigFormat, host: HostName): FormatCodec {
55
- if (format === "json") return jsonCodec
56
- if (format === "toml") return tomlCodec
57
- return rulesCodec(host)
53
+ if (format === "json") return jsonCodec;
54
+ if (format === "toml") return tomlCodec;
55
+ return rulesCodec(host);
58
56
  }