deepseek-web-retrieval 0.2.0 → 0.3.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 (53) hide show
  1. package/CHANGELOG.md +187 -1
  2. package/README.md +13 -9
  3. package/dist/cli.js +4228 -178
  4. package/docs/adr/0001-refuse-drift-between-plan-and-apply.md +29 -0
  5. package/docs/agents/codex.md +193 -0
  6. package/docs/agents/domain.md +51 -0
  7. package/docs/agents/issue-tracker.md +45 -0
  8. package/docs/{opencode-v2.md → agents/opencode-v2.md} +30 -7
  9. package/docs/{pi.md → agents/pi.md} +35 -7
  10. package/docs/agents/triage-labels.md +15 -0
  11. package/docs/design/installer-design.md +184 -0
  12. package/docs/hosts.jsonc +59 -33
  13. package/docs/integration.md +45 -52
  14. package/docs/research/control-matrix.md +257 -0
  15. package/docs/{mcp-compatibility.md → research/mcp-compatibility.md} +39 -108
  16. package/package.json +5 -3
  17. package/src/core/auth.ts +1 -1
  18. package/src/core/client.ts +42 -249
  19. package/src/core/constants.ts +5 -0
  20. package/src/core/descriptions.ts +19 -0
  21. package/src/core/format.ts +31 -1
  22. package/src/core/jsonc.ts +16 -0
  23. package/src/core/options.ts +2 -3
  24. package/src/core/search.ts +5 -7
  25. package/src/core/transport.ts +253 -0
  26. package/src/core/types.ts +13 -11
  27. package/src/installer/apply.ts +176 -0
  28. package/src/installer/cli.ts +215 -0
  29. package/src/installer/detect.ts +85 -0
  30. package/src/installer/formats/index.ts +58 -0
  31. package/src/installer/formats/json.ts +151 -0
  32. package/src/installer/formats/rules.ts +62 -0
  33. package/src/installer/formats/toml.ts +163 -0
  34. package/src/installer/formats/value.ts +57 -0
  35. package/src/installer/hosts/codex.ts +128 -0
  36. package/src/installer/hosts/index.ts +134 -0
  37. package/src/installer/hosts/opencode.ts +118 -0
  38. package/src/installer/hosts/pi.ts +115 -0
  39. package/src/installer/hosts/shared.ts +31 -0
  40. package/src/installer/paths.ts +18 -0
  41. package/src/installer/render.ts +62 -0
  42. package/src/installer/revert.ts +61 -0
  43. package/src/installer/rulesContent.ts +32 -0
  44. package/src/installer/state.ts +63 -0
  45. package/src/installer/types.ts +126 -0
  46. package/src/installer/uninstall.ts +33 -0
  47. package/src/mcp/auth.ts +2 -1
  48. package/src/mcp/cli.ts +21 -5
  49. package/src/mcp/config.ts +70 -27
  50. package/src/mcp/server.ts +81 -65
  51. package/src/opencode/auth.ts +3 -2
  52. package/src/opencode/plugin.ts +93 -56
  53. package/src/mcp/format.ts +0 -31
@@ -0,0 +1,85 @@
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
+
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."
8
+
9
+ /**
10
+ * Shared Forced-tier cost warning; the README's "Privacy and cost" section carries
11
+ * the detail, so every host planner points at the same anchor.
12
+ */
13
+ export const FORCED_COST_NOTE =
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.'
16
+
17
+ /** Shared note when a previous deepseek entry is being replaced. */
18
+ export function replacingExistingEntryNote(path: string): string {
19
+ return `An existing ${path} entry is being replaced; its previous form is recorded for uninstall.`
20
+ }
21
+
22
+ /** Shared note for third-party servers left registered. */
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.`
25
+ }
26
+
27
+ const WEB_TOOL_HINT =
28
+ /(web[_ -]?(search|fetch)|search|fetch|browse|scrape|crawl|tavily|brave|exa|perplexity|firecrawl|jina|serp|duckduckgo|reader)/i
29
+
30
+ /**
31
+ * Heuristic web-capability test for a registered third-party MCP server, matching
32
+ * the server's name plus its command/args/url. A false positive only costs a plan
33
+ * note or an exclusion the user sees before --apply; a miss stays covered by the
34
+ * "not exclusive while they offer web tools" note.
35
+ */
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}`)
43
+ }
44
+
45
+ const STEERING_HINT =
46
+ /(web[_ ]?(search|fetch)|websearch|webfetch|tavily|exa|brave|perplexity|firecrawl|jina|serp|duckduckgo|google|bing)/i
47
+
48
+ /**
49
+ * Detect-phase scan of the rules file the plan would write to: lines steering the
50
+ * model toward another web tool. The installer's own marked block is excluded, and
51
+ * deepseek mentions are ours to make, so both are ignored.
52
+ */
53
+ export function findCompetitorSteering(host: HostName, file: string): string[] {
54
+ if (!existsSync(file)) return []
55
+ let text: string
56
+ try {
57
+ text = readFileSync(file, "utf8")
58
+ } catch {
59
+ return []
60
+ }
61
+ return removeBlock(host, text)
62
+ .split("\n")
63
+ .map((line) => line.trim())
64
+ .filter((line) => line.length > 0 && STEERING_HINT.test(line) && !line.includes("deepseek"))
65
+ .slice(0, 2)
66
+ }
67
+
68
+ /** Shared note for steering the detect phase found; conflicts are surfaced, not resolved. */
69
+ 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.`
72
+ }
73
+
74
+ /** 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[] = []
80
+ for (const [name, entry] of Object.entries(servers)) {
81
+ if (name === "deepseek") continue
82
+ ;(looksWebCapable(name, entry) ? competitors : bystanders).push(name)
83
+ }
84
+ return { competitors, bystanders }
85
+ }
@@ -0,0 +1,58 @@
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"
7
+
8
+ export type ConfigFormat = FileEdit["format"]
9
+
10
+ /** A config file as the codec read it: parsed, or carrying the parse error. */
11
+ export interface LoadedConfig {
12
+ readonly exists: boolean
13
+ readonly text: string
14
+ readonly value: Record<string, unknown>
15
+ /** True when serializing the parsed value would drop comments (TOML only; JSON edits preserve them). */
16
+ readonly lossy: boolean
17
+ readonly parseError?: string
18
+ }
19
+
20
+ /** What applying one edit produced, beyond the new text: everything undo needs. */
21
+ export interface AppliedText {
22
+ readonly text: string
23
+ readonly recorded: readonly RecordedOp[]
24
+ readonly createdParents: readonly (readonly string[])[]
25
+ }
26
+
27
+ /** What reverting one recorded file produced: the text plus notes about skipped work. */
28
+ export interface RevertedText {
29
+ readonly text: string
30
+ readonly notes: readonly string[]
31
+ }
32
+
33
+ /**
34
+ * One codec per config format, behind a single registry: load parses, apply edits,
35
+ * verify checks postconditions (per op for json/toml, block presence for rules),
36
+ * revert reverses a recorded file, and isEffectivelyEmpty gates whole-file deletion.
37
+ */
38
+ 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
45
+ }
46
+
47
+ /** Shared write step: every codec consumer writes config files through this. */
48
+ export function writeConfig(file: string, serialized: string): void {
49
+ mkdirSync(dirname(file), { recursive: true })
50
+ writeFileSync(file, serialized)
51
+ }
52
+
53
+ /** The one entry point to a format's codec; only the rules codec cares about the host. */
54
+ export function codecFor(format: ConfigFormat, host: HostName): FormatCodec {
55
+ if (format === "json") return jsonCodec
56
+ if (format === "toml") return tomlCodec
57
+ return rulesCodec(host)
58
+ }
@@ -0,0 +1,151 @@
1
+ import { existsSync, readFileSync } from "node:fs"
2
+ import { applyEdits, modify, parse, type JSONPath } from "jsonc-parser/lib/esm/main.js"
3
+ import { parseJsonc } from "../../core/jsonc"
4
+ import type { ConfigOp, RecordedOp } from "../types"
5
+ import type { AppliedText, FormatCodec, LoadedConfig } from "./index"
6
+ import { appendEntries, deepEqual, isPlainObject, valueAt } from "./value"
7
+
8
+ const JSON_FORMATTING = { insertSpaces: true, tabSize: 2, insertFinalNewline: false } as const
9
+
10
+ /** Reads a JSONC config file; a missing file starts empty, an unparsable one carries the error. */
11
+ function loadJson(file: string): LoadedConfig {
12
+ if (!existsSync(file)) return { exists: false, text: "", value: {}, lossy: false }
13
+ const text = readFileSync(file, "utf8")
14
+ try {
15
+ return { exists: true, text, value: parseJsonc(text) as Record<string, unknown>, lossy: false }
16
+ } catch (error) {
17
+ const parseError = error instanceof Error ? error.message : String(error)
18
+ return { exists: true, text, value: {}, lossy: false, parseError }
19
+ }
20
+ }
21
+
22
+ /** Reads the value at a dotted path from JSON text, or undefined when absent. */
23
+ function jsonGet(text: string, path: readonly string[]): unknown {
24
+ const root = parse(text, undefined, { allowTrailingComma: true }) as Record<string, unknown>
25
+ return valueAt(root, path)
26
+ }
27
+
28
+ export interface JsonOpsResult {
29
+ readonly text: string
30
+ readonly recorded: RecordedOp[]
31
+ /** Path prefixes the ops had to create; uninstall removes them when left empty. */
32
+ readonly createdParents: readonly (readonly string[])[]
33
+ }
34
+
35
+ /**
36
+ * Applies ops to JSONC text with jsonc-parser's comment-preserving edits.
37
+ * Ops are idempotent: `set` writes the value, `append-array` skips deep-equal entries.
38
+ * Each op is recorded with its previous value (or the entries actually appended)
39
+ * for surgical undo.
40
+ */
41
+ export function applyJsonOps(text: string, ops: readonly ConfigOp[]): JsonOpsResult {
42
+ let current = text.trim().length > 0 ? text : "{}\n"
43
+ const recorded: RecordedOp[] = []
44
+ const createdParents: (readonly string[])[] = []
45
+ for (const op of ops) {
46
+ const path = op.path as unknown as JSONPath
47
+ if (op.op === "set") {
48
+ current = ensureParents(current, op.path, createdParents)
49
+ const previous = jsonGet(current, op.path)
50
+ current = edit(current, path, op.value)
51
+ recorded.push({ op: "set", path: op.path, applied: structuredClone(op.value), previous })
52
+ continue
53
+ }
54
+ current = ensureParents(current, op.path, createdParents)
55
+ const existing = jsonGet(current, op.path)
56
+ const { list, appended } = appendEntries(existing, op.entries ?? [], op.path)
57
+ if (existing === undefined) {
58
+ current = edit(current, path, [])
59
+ createdParents.push(op.path)
60
+ }
61
+ let index = list.length
62
+ for (const entry of appended) {
63
+ current = edit(current, [...path, index] as unknown as JSONPath, entry)
64
+ index += 1
65
+ }
66
+ recorded.push({ op: "append-array", path: op.path, applied: appended, previous: undefined })
67
+ }
68
+ return { text: current, recorded, createdParents }
69
+ }
70
+
71
+ /** Reverses recorded ops on current JSON text; returns the text plus notes about skipped work. */
72
+ export function revertJsonOps(text: string, ops: readonly RecordedOp[], createdParents: readonly (readonly string[])[]): { text: string; notes: string[] } {
73
+ let current = text
74
+ const notes: string[] = []
75
+ for (const record of [...ops].reverse()) {
76
+ const path = record.path as unknown as JSONPath
77
+ if (record.op === "set") {
78
+ const now = jsonGet(current, record.path)
79
+ if (now === undefined) continue
80
+ if (!deepEqual(now, record.applied)) {
81
+ notes.push(`${record.path.join(".")}: current value differs from what the installer wrote; left untouched`)
82
+ continue
83
+ }
84
+ current = record.previous === undefined ? edit(current, path, void 0) : edit(current, path, record.previous)
85
+ } else {
86
+ const entries = Array.isArray(record.applied) ? record.applied : []
87
+ for (const entry of [...entries].reverse()) {
88
+ const list = jsonGet(current, record.path)
89
+ if (!Array.isArray(list)) continue
90
+ const index = list.findIndex((item) => deepEqual(item, entry))
91
+ if (index === -1) continue
92
+ current = edit(current, [...path, index] as unknown as JSONPath, void 0)
93
+ }
94
+ }
95
+ }
96
+ for (const parent of [...createdParents].reverse()) {
97
+ const value = jsonGet(current, parent)
98
+ const isEmptyObject = isPlainObject(value) && Object.keys(value).length === 0
99
+ const isEmptyArray = Array.isArray(value) && value.length === 0
100
+ if (isEmptyObject || isEmptyArray) {
101
+ current = edit(current, parent as unknown as JSONPath, void 0)
102
+ }
103
+ }
104
+ return { text: current, notes }
105
+ }
106
+
107
+ function edit(text: string, path: JSONPath, value: unknown): string {
108
+ return applyEdits(text, modify(text, path, value as never, { formattingOptions: JSON_FORMATTING }))
109
+ }
110
+
111
+ /** Creates missing path prefixes as objects (or arrays when the next segment is an index). */
112
+ function ensureParents(text: string, path: readonly string[], created: (readonly string[])[]): string {
113
+ let current = text
114
+ for (let depth = 0; depth < path.length - 1; depth += 1) {
115
+ const prefix = path.slice(0, depth + 1)
116
+ const existing = jsonGet(current, prefix)
117
+ if (existing !== undefined && typeof existing !== "object") {
118
+ throw new Error(`cannot descend into ${prefix.join(".")}: it is a ${typeof existing}, not an object`)
119
+ }
120
+ if (existing !== undefined) continue
121
+ const nextSegmentIsIndex = Number.isInteger(Number(path[depth + 1]))
122
+ const placeholder = nextSegmentIsIndex ? [] : {}
123
+ current = edit(current, prefix as unknown as JSONPath, placeholder)
124
+ created.push(prefix)
125
+ }
126
+ return current
127
+ }
128
+
129
+ function jsonOpHolds(text: string, op: ConfigOp): boolean {
130
+ if (op.op === "set") return deepEqual(jsonGet(text, op.path), op.value)
131
+ const list = jsonGet(text, op.path)
132
+ return Array.isArray(list) && (op.entries ?? []).every((entry) => list.some((item) => deepEqual(item, entry)))
133
+ }
134
+
135
+ /** The JSON codec: comment-preserving edits on JSONC text. */
136
+ export const jsonCodec: FormatCodec = {
137
+ format: "json",
138
+ load: loadJson,
139
+ apply: (text, edit): AppliedText => {
140
+ if (edit.format !== "json") throw new Error(`json codec cannot apply a ${edit.format} edit`)
141
+ const result = applyJsonOps(text, edit.ops)
142
+ return { text: result.text, recorded: result.recorded, createdParents: result.createdParents }
143
+ },
144
+ verify: (text, edit, op) => {
145
+ if (edit.format !== "json") throw new Error(`json codec cannot verify a ${edit.format} edit`)
146
+ const ops = op === undefined ? edit.ops : [op]
147
+ return ops.every((candidate) => jsonOpHolds(text, candidate))
148
+ },
149
+ revert: (text, recorded) => revertJsonOps(text, recorded.ops, recorded.createdParents ?? []),
150
+ isEffectivelyEmpty: (text) => text.replace(/[{}\s]/g, "").length === 0,
151
+ }
@@ -0,0 +1,62 @@
1
+ import { existsSync, readFileSync } from "node:fs"
2
+ import type { HostName } from "../types"
3
+ import type { AppliedText, FormatCodec } from "./index"
4
+
5
+ /** Marked-block markers are per host, so multiple hosts can share one rules file. */
6
+ function rulesMarkers(host: HostName): { readonly begin: string; readonly end: string } {
7
+ return {
8
+ begin: `<!-- deepseek-web-retrieval:${host}:rules:begin -->`,
9
+ end: `<!-- deepseek-web-retrieval:${host}:rules:end -->`,
10
+ }
11
+ }
12
+
13
+ /** Wraps rule lines in the host's marked block that apply upserts and uninstall remove. */
14
+ export function buildRulesBlock(host: HostName, lines: readonly string[]): string {
15
+ const markers = rulesMarkers(host)
16
+ return [markers.begin, ...lines, markers.end].join("\n")
17
+ }
18
+
19
+ /** Replaces the host's existing marked block, or appends one; keeps surrounding text intact. */
20
+ export function upsertBlock(host: HostName, text: string, block: string): string {
21
+ const without = removeBlock(host, text)
22
+ const trimmed = without.trimEnd()
23
+ return trimmed ? `${trimmed}\n\n${block}\n` : `${block}\n`
24
+ }
25
+
26
+ /** Removes the host's marked block if present; returns the text unchanged otherwise. */
27
+ export function removeBlock(host: HostName, text: string): string {
28
+ const markers = rulesMarkers(host)
29
+ const start = text.indexOf(markers.begin)
30
+ if (start === -1) return text
31
+ const end = text.indexOf(markers.end)
32
+ if (end === -1) return text.slice(0, start)
33
+ let after = text.slice(end + markers.end.length)
34
+ // Drop the blank lines the upsert added around the block.
35
+ after = after.replace(/^\n{1,2}/, "")
36
+ const before = text.slice(0, start)
37
+ return before.endsWith("\n\n") ? `${before.slice(0, -1)}${after}` : before + after
38
+ }
39
+
40
+ export function hasBlock(host: HostName, text: string): boolean {
41
+ const markers = rulesMarkers(host)
42
+ return text.includes(markers.begin) && text.includes(markers.end)
43
+ }
44
+
45
+ /** A rules codec is per host: its markers decide which block upsert, verify, and revert touch. */
46
+ export function rulesCodec(host: HostName): FormatCodec {
47
+ return {
48
+ format: "rules",
49
+ load: (file) =>
50
+ existsSync(file)
51
+ ? { exists: true, text: readFileSync(file, "utf8"), value: {}, lossy: false }
52
+ : { exists: false, text: "", value: {}, lossy: false },
53
+ apply: (text, edit): AppliedText => {
54
+ if (edit.format !== "rules") throw new Error(`rules codec cannot apply a ${edit.format} edit`)
55
+ return { text: upsertBlock(host, text, edit.block), recorded: [], createdParents: [] }
56
+ },
57
+ // Rules files are markdown: the block's presence is the only postcondition.
58
+ verify: (text) => hasBlock(host, text),
59
+ revert: (text) => ({ text: removeBlock(host, text), notes: [] }),
60
+ isEffectivelyEmpty: (text) => text.trim().length === 0,
61
+ }
62
+ }
@@ -0,0 +1,163 @@
1
+ import { existsSync, readFileSync } from "node:fs"
2
+ import { parse as parseToml, stringify as stringifyTomlValue } from "smol-toml"
3
+ import type { ConfigOp, RecordedOp } from "../types"
4
+ import type { AppliedText, FormatCodec, LoadedConfig } from "./index"
5
+ import { appendEntries, deepEqual, isPlainObject, removeAt, valueAt } from "./value"
6
+
7
+ /** Reads a TOML config file; a missing file starts empty, an unparsable one carries the error. */
8
+ function loadToml(file: string): LoadedConfig {
9
+ if (!existsSync(file)) return { exists: false, text: "", value: {}, lossy: false }
10
+ const text = readFileSync(file, "utf8")
11
+ try {
12
+ return { exists: true, text, value: parseToml(text) as Record<string, unknown>, lossy: hasTomlComment(text) }
13
+ } catch (error) {
14
+ const parseError = error instanceof Error ? error.message : String(error)
15
+ return { exists: true, text, value: {}, lossy: false, parseError }
16
+ }
17
+ }
18
+
19
+ /**
20
+ * String-aware TOML comment detection: any `#` outside basic ("") or literal ('')
21
+ * strings counts, including trailing comments like `key = "x" # note`.
22
+ */
23
+ export function hasTomlComment(text: string): boolean {
24
+ let inBasic = false
25
+ let inLiteral = false
26
+ for (let i = 0; i < text.length; i += 1) {
27
+ const ch = text[i] as string
28
+ if (inBasic) {
29
+ if (ch === "\\") i += 1
30
+ else if (ch === '"') inBasic = false
31
+ continue
32
+ }
33
+ if (inLiteral) {
34
+ if (ch === "'") inLiteral = false
35
+ continue
36
+ }
37
+ if (ch === '"') inBasic = true
38
+ else if (ch === "'") inLiteral = true
39
+ else if (ch === "#") return true
40
+ }
41
+ return false
42
+ }
43
+
44
+ function serializeToml(value: Record<string, unknown>): string {
45
+ return `${stringifyTomlValue(value)}\n`
46
+ }
47
+
48
+ /**
49
+ * Applies ops to a parsed TOML value, returning the mutated value plus one record
50
+ * per op with the previous value for surgical undo, and the path prefixes created.
51
+ */
52
+ function applyTomlOps(value: Record<string, unknown>, ops: readonly ConfigOp[]): { value: Record<string, unknown>; recorded: RecordedOp[]; createdParents: (readonly string[])[] } {
53
+ const target: Record<string, unknown> = structuredClone(value)
54
+ const recorded: RecordedOp[] = []
55
+ const createdParents: (readonly string[])[] = []
56
+ for (const op of ops) {
57
+ const parent = navigate(target, op.path, createdParents)
58
+ const key = op.path[op.path.length - 1]
59
+ if (op.op === "set") {
60
+ const previous = Object.prototype.hasOwnProperty.call(parent, key) ? parent[key] : undefined
61
+ parent[key] = structuredClone(op.value)
62
+ recorded.push({ op: "set", path: op.path, applied: structuredClone(op.value), previous })
63
+ continue
64
+ }
65
+ const existing = parent[key]
66
+ if (existing === undefined) createdParents.push(op.path)
67
+ const { list, appended } = appendEntries(existing, op.entries ?? [], op.path)
68
+ parent[key] = [...list, ...appended.map((entry) => structuredClone(entry))]
69
+ recorded.push({ op: "append-array", path: op.path, applied: appended, previous: undefined })
70
+ }
71
+ return { value: target, recorded, createdParents }
72
+ }
73
+
74
+ function navigate(root: Record<string, unknown>, path: readonly string[], created: (readonly string[])[]): Record<string, unknown> {
75
+ let node = root
76
+ for (let depth = 0; depth < path.length - 1; depth += 1) {
77
+ const key = path[depth] as string
78
+ const next: unknown = node[key]
79
+ if (next === undefined) {
80
+ node[key] = {}
81
+ created.push(path.slice(0, depth + 1))
82
+ } else if (!isPlainObject(next)) {
83
+ throw new Error(
84
+ `cannot descend into ${path.slice(0, depth + 1).join(".")}: it is ${Array.isArray(next) ? "an array" : `a ${typeof next}`}, not a table`,
85
+ )
86
+ }
87
+ node = node[key] as Record<string, unknown>
88
+ }
89
+ return node
90
+ }
91
+
92
+ /** Surgical undo on a parsed TOML object; user edits made after install win. */
93
+ function revertTomlOps(value: Record<string, unknown>, ops: readonly RecordedOp[]): string[] {
94
+ const notes: string[] = []
95
+ for (const record of [...ops].reverse()) {
96
+ if (record.op === "set") {
97
+ const now = valueAt(value, record.path)
98
+ if (now === undefined) continue
99
+ if (!deepEqual(now, record.applied)) {
100
+ notes.push(`${record.path.join(".")}: current value differs from what the installer wrote; left untouched`)
101
+ continue
102
+ }
103
+ if (record.previous === undefined) {
104
+ removeAt(value, record.path)
105
+ } else {
106
+ setValueAt(value, record.path, record.previous)
107
+ }
108
+ } else {
109
+ const entries = Array.isArray(record.applied) ? record.applied : []
110
+ for (const entry of entries) {
111
+ const list = valueAt(value, record.path)
112
+ if (!Array.isArray(list)) continue
113
+ const index = list.findIndex((item) => deepEqual(item, entry))
114
+ if (index === -1) continue
115
+ list.splice(index, 1)
116
+ }
117
+ }
118
+ }
119
+ return notes
120
+ }
121
+
122
+ /** Removes path prefixes the installer created when they are left empty, newest first. */
123
+ function pruneCreatedParents(value: Record<string, unknown>, parents: readonly (readonly string[])[]): void {
124
+ for (const parent of [...parents].reverse()) {
125
+ const current = valueAt(value, parent)
126
+ if (isPlainObject(current) && Object.keys(current).length === 0) removeAt(value, parent)
127
+ }
128
+ }
129
+
130
+ function setValueAt(value: Record<string, unknown>, path: readonly string[], replacement: unknown): void {
131
+ const parent = valueAt(value, path.slice(0, -1))
132
+ if (isPlainObject(parent)) parent[path[path.length - 1] as string] = replacement
133
+ }
134
+
135
+ function tomlOpHolds(text: string, op: ConfigOp): boolean {
136
+ const value = parseToml(text) as Record<string, unknown>
137
+ const current = valueAt(value, op.path)
138
+ if (op.op === "set") return deepEqual(current, op.value)
139
+ return Array.isArray(current) && (op.entries ?? []).every((entry) => current.some((item) => deepEqual(item, entry)))
140
+ }
141
+
142
+ /** The TOML codec: ops on a parsed value, serialized lossily (comments drop on rewrite). */
143
+ export const tomlCodec: FormatCodec = {
144
+ format: "toml",
145
+ load: loadToml,
146
+ apply: (text, edit): AppliedText => {
147
+ if (edit.format !== "toml") throw new Error(`toml codec cannot apply a ${edit.format} edit`)
148
+ const result = applyTomlOps(parseToml(text) as Record<string, unknown>, edit.ops)
149
+ return { text: serializeToml(result.value), recorded: result.recorded, createdParents: result.createdParents }
150
+ },
151
+ verify: (text, edit, op) => {
152
+ if (edit.format !== "toml") throw new Error(`toml codec cannot verify a ${edit.format} edit`)
153
+ const ops = op === undefined ? edit.ops : [op]
154
+ return ops.every((candidate) => tomlOpHolds(text, candidate))
155
+ },
156
+ revert: (text, recorded) => {
157
+ const value = parseToml(text) as Record<string, unknown>
158
+ const notes = revertTomlOps(value, recorded.ops)
159
+ pruneCreatedParents(value, recorded.createdParents ?? [])
160
+ return { text: serializeToml(value), notes }
161
+ },
162
+ isEffectivelyEmpty: (text) => Object.keys(parseToml(text) as Record<string, unknown>).length === 0,
163
+ }
@@ -0,0 +1,57 @@
1
+ export function isPlainObject(value: unknown): value is Record<string, unknown> {
2
+ return typeof value === "object" && value !== null && !Array.isArray(value)
3
+ }
4
+
5
+ export function deepEqual(a: unknown, b: unknown): boolean {
6
+ if (a === b) return true
7
+ if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false
8
+ const aArray = Array.isArray(a)
9
+ const bArray = Array.isArray(b)
10
+ if (aArray !== bArray) return false
11
+ if (aArray && bArray) {
12
+ const x = a as unknown[]
13
+ const y = b as unknown[]
14
+ return x.length === y.length && x.every((item, index) => deepEqual(item, y[index]))
15
+ }
16
+ const x = a as Record<string, unknown>
17
+ const y = b as Record<string, unknown>
18
+ const xKeys = Object.keys(x)
19
+ const yKeys = Object.keys(y)
20
+ return xKeys.length === yKeys.length && xKeys.every((key) => deepEqual(x[key], y[key]))
21
+ }
22
+
23
+ /** Reads the value at a dotted path from a parsed object, or undefined when absent. */
24
+ export function valueAt(value: Record<string, unknown>, path: readonly string[]): unknown {
25
+ let node: unknown = value
26
+ for (const key of path) {
27
+ if (Array.isArray(node)) {
28
+ const index = Number(key)
29
+ node = Number.isInteger(index) ? node[index] : undefined
30
+ } else if (isPlainObject(node)) {
31
+ node = node[key]
32
+ } else {
33
+ return undefined
34
+ }
35
+ if (node === undefined) return undefined
36
+ }
37
+ return node
38
+ }
39
+
40
+ export function removeAt(value: Record<string, unknown>, path: readonly string[]): void {
41
+ const parent = valueAt(value, path.slice(0, -1))
42
+ if (isPlainObject(parent)) delete parent[path[path.length - 1] as string]
43
+ }
44
+
45
+ /**
46
+ * Shared append-array step for both config formats: rejects a non-array at the
47
+ * path and dedupes entries already present, so applied records list only what
48
+ * was actually appended.
49
+ */
50
+ export function appendEntries(existing: unknown, entries: readonly unknown[], path: readonly string[]): { readonly list: readonly unknown[]; readonly appended: readonly unknown[] } {
51
+ if (existing !== undefined && !Array.isArray(existing)) {
52
+ throw new Error(`cannot append to ${path.join(".")}: it is a ${typeof existing}, not an array`)
53
+ }
54
+ const list = Array.isArray(existing) ? existing : []
55
+ const appended = entries.filter((entry) => !list.some((item) => deepEqual(item, entry)))
56
+ return { list, appended }
57
+ }