deepseek-web-retrieval 0.2.0 → 0.4.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 (54) hide show
  1. package/CHANGELOG.md +204 -1
  2. package/README.md +55 -28
  3. package/dist/cli.js +4224 -184
  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} +34 -11
  9. package/docs/{pi.md → agents/pi.md} +39 -11
  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/docs/research/search-api-defaults.md +309 -0
  17. package/package.json +5 -3
  18. package/src/core/auth.ts +1 -1
  19. package/src/core/client.ts +42 -249
  20. package/src/core/constants.ts +14 -3
  21. package/src/core/descriptions.ts +19 -0
  22. package/src/core/format.ts +31 -1
  23. package/src/core/jsonc.ts +16 -0
  24. package/src/core/options.ts +3 -4
  25. package/src/core/search.ts +5 -7
  26. package/src/core/transport.ts +253 -0
  27. package/src/core/types.ts +13 -11
  28. package/src/installer/apply.ts +176 -0
  29. package/src/installer/cli.ts +215 -0
  30. package/src/installer/detect.ts +85 -0
  31. package/src/installer/formats/index.ts +58 -0
  32. package/src/installer/formats/json.ts +151 -0
  33. package/src/installer/formats/rules.ts +62 -0
  34. package/src/installer/formats/toml.ts +163 -0
  35. package/src/installer/formats/value.ts +57 -0
  36. package/src/installer/hosts/codex.ts +128 -0
  37. package/src/installer/hosts/index.ts +134 -0
  38. package/src/installer/hosts/opencode.ts +118 -0
  39. package/src/installer/hosts/pi.ts +115 -0
  40. package/src/installer/hosts/shared.ts +31 -0
  41. package/src/installer/paths.ts +18 -0
  42. package/src/installer/render.ts +62 -0
  43. package/src/installer/revert.ts +61 -0
  44. package/src/installer/rulesContent.ts +32 -0
  45. package/src/installer/state.ts +63 -0
  46. package/src/installer/types.ts +126 -0
  47. package/src/installer/uninstall.ts +33 -0
  48. package/src/mcp/auth.ts +2 -1
  49. package/src/mcp/cli.ts +21 -5
  50. package/src/mcp/config.ts +63 -32
  51. package/src/mcp/server.ts +81 -65
  52. package/src/opencode/auth.ts +3 -2
  53. package/src/opencode/plugin.ts +93 -56
  54. package/src/mcp/format.ts +0 -31
@@ -0,0 +1,253 @@
1
+ import { MAX_ERROR_BODY_CHARS, MAX_RESPONSE_BYTES, MAX_RETRY_DELAY_MS } from "./constants"
2
+ import { formatApiError, formatTimeoutError, redactSecrets, truncate } from "./format"
3
+ import type { RequestAuth, RetryOptions } from "./types"
4
+
5
+ /** Everything one POST needs; `fetchImpl` is injectable so tests need no monkey-patching. */
6
+ export interface TransportOptions {
7
+ readonly signal: AbortSignal
8
+ readonly auth: RequestAuth
9
+ readonly retry: RetryOptions
10
+ readonly timeoutMs: number
11
+ readonly fetchImpl?: typeof fetch
12
+ }
13
+
14
+ export class DeepSeekApiError extends Error {
15
+ readonly operation: string
16
+ readonly status: number
17
+ readonly body: string
18
+ readonly retryable: boolean
19
+ readonly retryAfterMs?: number
20
+
21
+ constructor(input: {
22
+ operation: string
23
+ status: number
24
+ body: string
25
+ retryAfterMs?: number
26
+ }) {
27
+ const body = redactSecrets(input.body)
28
+ super(formatApiError(input.operation, input.status, body))
29
+ this.name = "DeepSeekApiError"
30
+ this.operation = input.operation
31
+ this.status = input.status
32
+ this.body = body
33
+ this.retryable =
34
+ input.status === 408 ||
35
+ input.status === 425 ||
36
+ input.status === 429 ||
37
+ (input.status >= 500 && input.status <= 599)
38
+ this.retryAfterMs = input.retryAfterMs
39
+ }
40
+ }
41
+
42
+ export async function postJson<T>(
43
+ endpoint: string,
44
+ body: unknown,
45
+ operation: string,
46
+ opts: TransportOptions,
47
+ ): Promise<T> {
48
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch
49
+ const attempts = opts.retry.maxRetries + 1
50
+ let lastError: unknown
51
+
52
+ for (let attempt = 0; attempt < attempts; attempt++) {
53
+ try {
54
+ return await requestOnce<T>(endpoint, body, operation, opts, fetchImpl)
55
+ } catch (error) {
56
+ lastError = error
57
+ if (!(error instanceof DeepSeekApiError) || !error.retryable || attempt === attempts - 1) {
58
+ throw error
59
+ }
60
+ await sleep(retryDelay(error.retryAfterMs, opts.retry.baseDelayMs, attempt), opts.signal)
61
+ }
62
+ }
63
+
64
+ throw lastError
65
+ }
66
+
67
+ async function requestOnce<T>(
68
+ endpoint: string,
69
+ body: unknown,
70
+ operation: string,
71
+ opts: TransportOptions,
72
+ fetchImpl: typeof fetch,
73
+ ): Promise<T> {
74
+ return fetchWithTimeout(
75
+ endpoint,
76
+ {
77
+ method: "POST",
78
+ headers: {
79
+ Authorization: `Bearer ${opts.auth.apiKey}`,
80
+ "Content-Type": "application/json",
81
+ },
82
+ body: JSON.stringify(body),
83
+ },
84
+ opts.signal,
85
+ opts.timeoutMs,
86
+ operation,
87
+ fetchImpl,
88
+ async (response) => {
89
+ if (!response.ok) {
90
+ const text = await readBoundedText(response, MAX_ERROR_BODY_CHARS)
91
+ throw new DeepSeekApiError({
92
+ operation,
93
+ status: response.status,
94
+ body: text,
95
+ retryAfterMs: parseRetryAfter(response.headers.get("retry-after")),
96
+ })
97
+ }
98
+
99
+ const text = await readResponseText(response, MAX_RESPONSE_BYTES, operation)
100
+ if (!text.trim()) throw new Error(`${operation} returned an empty response body`)
101
+
102
+ try {
103
+ return JSON.parse(text) as T
104
+ } catch {
105
+ throw new Error(`${operation} returned invalid JSON: ${truncate(redactSecrets(text), 500)}`)
106
+ }
107
+ },
108
+ )
109
+ }
110
+
111
+ /**
112
+ * Runs the request and the callback that consumes its body inside one
113
+ * timeout/abort scope. The timer must stay active while the response body is
114
+ * read, otherwise a slow or stalled body can outlive the configured timeout.
115
+ */
116
+ async function fetchWithTimeout<T>(
117
+ endpoint: string,
118
+ init: RequestInit,
119
+ signal: AbortSignal,
120
+ timeoutMs: number,
121
+ operation: string,
122
+ fetchImpl: typeof fetch,
123
+ handleResponse: (response: Response) => Promise<T>,
124
+ ): Promise<T> {
125
+ const controller = new AbortController()
126
+ let timedOut = false
127
+ const forwardAbort = () => controller.abort(signal.reason)
128
+ if (signal.aborted) forwardAbort()
129
+ else signal.addEventListener("abort", forwardAbort, { once: true })
130
+
131
+ const timer = signal.aborted
132
+ ? undefined
133
+ : setTimeout(() => {
134
+ timedOut = true
135
+ controller.abort(new Error("timeout"))
136
+ }, timeoutMs)
137
+
138
+ try {
139
+ const response = await fetchImpl(endpoint, { ...init, signal: controller.signal })
140
+ return await handleResponse(response)
141
+ } catch (error) {
142
+ if (timedOut) throw new Error(formatTimeoutError(operation, timeoutMs))
143
+ if (signal.aborted) throw abortReason(signal, operation)
144
+ throw error
145
+ } finally {
146
+ if (timer !== undefined) clearTimeout(timer)
147
+ signal.removeEventListener("abort", forwardAbort)
148
+ }
149
+ }
150
+
151
+ export function parseRetryAfter(value: string | null): number | undefined {
152
+ if (!value) return undefined
153
+ const trimmed = value.trim()
154
+ if (!trimmed) return undefined
155
+
156
+ if (/^\d+$/.test(trimmed)) {
157
+ return Math.min(MAX_RETRY_DELAY_MS, Number(trimmed) * 1000)
158
+ }
159
+
160
+ const timestamp = Date.parse(trimmed)
161
+ if (!Number.isFinite(timestamp)) return undefined
162
+ return Math.min(MAX_RETRY_DELAY_MS, Math.max(0, timestamp - Date.now()))
163
+ }
164
+
165
+ export function retryDelay(retryAfterMs: number | undefined, baseDelayMs: number, attempt: number): number {
166
+ if (retryAfterMs !== undefined) return retryAfterMs
167
+ return Math.min(MAX_RETRY_DELAY_MS, baseDelayMs * 2 ** attempt)
168
+ }
169
+
170
+ function sleep(ms: number, signal: AbortSignal): Promise<void> {
171
+ if (ms <= 0) return Promise.resolve()
172
+ return new Promise((resolve, reject) => {
173
+ const timer = setTimeout(() => {
174
+ signal.removeEventListener("abort", onAbort)
175
+ resolve()
176
+ }, ms)
177
+ const onAbort = () => {
178
+ clearTimeout(timer)
179
+ reject(abortReason(signal, "DeepSeek request"))
180
+ }
181
+ if (signal.aborted) {
182
+ clearTimeout(timer)
183
+ reject(abortReason(signal, "DeepSeek request"))
184
+ return
185
+ }
186
+ signal.addEventListener("abort", onAbort, { once: true })
187
+ })
188
+ }
189
+
190
+ function abortReason(signal: AbortSignal, operation: string): unknown {
191
+ return signal.reason instanceof Error ? signal.reason : new Error(`${operation} request aborted`)
192
+ }
193
+
194
+ /** Reads at most `maxChars + 1` characters, enough for formatApiError to add its marker. */
195
+ async function readBoundedText(response: Response, maxChars: number): Promise<string> {
196
+ if (!response.body) return truncate(await response.text(), maxChars + 1)
197
+
198
+ const reader = response.body.getReader()
199
+ const decoder = new TextDecoder()
200
+ const limit = maxChars + 1
201
+ let text = ""
202
+ let chars = 0
203
+ try {
204
+ while (chars < limit) {
205
+ const { done, value } = await reader.read()
206
+ if (done) break
207
+ const chunk = decoder.decode(value, { stream: true })
208
+ text += chunk
209
+ chars += Array.from(chunk).length
210
+ }
211
+ text += decoder.decode()
212
+ } finally {
213
+ await reader.cancel().catch(() => undefined)
214
+ reader.releaseLock()
215
+ }
216
+ return truncate(text, limit)
217
+ }
218
+
219
+ /** Reads a successful response body, rejecting payloads above `maxBytes`. */
220
+ async function readResponseText(
221
+ response: Response,
222
+ maxBytes: number,
223
+ operation: string,
224
+ ): Promise<string> {
225
+ if (!response.body) {
226
+ const text = await response.text()
227
+ if (new TextEncoder().encode(text).byteLength > maxBytes) {
228
+ throw new Error(`${operation} response exceeded ${maxBytes} bytes`)
229
+ }
230
+ return text
231
+ }
232
+
233
+ const reader = response.body.getReader()
234
+ const decoder = new TextDecoder()
235
+ let text = ""
236
+ let bytes = 0
237
+ try {
238
+ while (true) {
239
+ const { done, value } = await reader.read()
240
+ if (done) break
241
+ bytes += value.byteLength
242
+ if (bytes > maxBytes) {
243
+ throw new Error(`${operation} response exceeded ${maxBytes} bytes`)
244
+ }
245
+ text += decoder.decode(value, { stream: true })
246
+ }
247
+ text += decoder.decode()
248
+ } finally {
249
+ await reader.cancel().catch(() => undefined)
250
+ reader.releaseLock()
251
+ }
252
+ return text
253
+ }
package/src/core/types.ts CHANGED
@@ -15,7 +15,7 @@ export interface SearchOptions {
15
15
  readonly language?: string
16
16
  readonly maxSummaryChars: number
17
17
  readonly timeoutMs: number
18
- readonly setDefault: boolean
18
+ readonly enabled: boolean
19
19
  }
20
20
 
21
21
  export interface FetchOptions {
@@ -23,9 +23,8 @@ export interface FetchOptions {
23
23
  readonly allowedDomains: readonly string[]
24
24
  readonly blockedDomains: readonly string[]
25
25
  readonly timeoutMs: number
26
- readonly overrideWebfetch: boolean
27
- readonly registerTool: boolean
28
26
  readonly blockPrivateHosts: boolean
27
+ readonly enabled: boolean
29
28
  }
30
29
 
31
30
  export interface AuthOptions {
@@ -54,12 +53,12 @@ export interface DeepSeekSearchResult {
54
53
  readonly date?: string | null
55
54
  }
56
55
 
57
- /** Matches the OpenCode v2 WebSearch.Result shape. */
58
- export interface WebSearchResult {
56
+ /** A neutral, host-agnostic search result. */
57
+ export interface SearchResult {
59
58
  readonly url: string
60
59
  readonly title?: string
61
60
  readonly content?: string
62
- readonly time: { readonly published?: number }
61
+ readonly published?: number
63
62
  }
64
63
 
65
64
  export interface FetchedPage {
@@ -73,11 +72,14 @@ export interface FormattedFetchContent {
73
72
  readonly truncated: boolean
74
73
  }
75
74
 
76
- export interface RequestAuth {
77
- readonly apiKey: string
75
+ /** A fetched page formatted for the model, with truncation reported. */
76
+ export interface FetchedDocument {
77
+ readonly url: string
78
+ readonly title: string
79
+ readonly content: string
80
+ readonly truncated: boolean
78
81
  }
79
82
 
80
- export interface RetryConfig {
81
- readonly maxRetries: number
82
- readonly baseDelayMs: number
83
+ export interface RequestAuth {
84
+ readonly apiKey: string
83
85
  }
@@ -0,0 +1,176 @@
1
+ import { existsSync, readFileSync } from "node:fs"
2
+ import { codecFor, writeConfig } from "./formats"
3
+ import { deepEqual, valueAt } from "./formats/value"
4
+ import { revertRecordedFile } from "./revert"
5
+ import { readRecord, writeRecord } from "./state"
6
+ import type { ConfigOp, FileEdit, InstallPlan, InstallRecord, InstallerInput, RecordedFile } from "./types"
7
+
8
+ export interface ApplyResult {
9
+ readonly lines: string[]
10
+ readonly ok: boolean
11
+ }
12
+
13
+ interface PreparedEdit {
14
+ readonly edit: FileEdit
15
+ readonly recorded: RecordedFile
16
+ readonly write: () => void
17
+ }
18
+
19
+ /**
20
+ * Applies a plan: refuses first when the config drifted since the plan was
21
+ * rendered (the plan and the record must describe one plan-time snapshot —
22
+ * ADR-0001), prepares every edit in memory (a malformed existing config fails
23
+ * before any file is written), writes the files, reverts artifacts orphaned by
24
+ * a tier change, records surgical-undo state, and verifies the result.
25
+ */
26
+ export function applyPlan(plan: InstallPlan, input: InstallerInput): ApplyResult {
27
+ if (plan.errors.length > 0) {
28
+ throw new Error(`refusing to apply a plan with errors: ${plan.errors.map((error) => error.message).join("; ")}`)
29
+ }
30
+ assertNoDrift(plan)
31
+ const previous = readRecord(input)
32
+ const prepared = plan.edits.map((edit) => prepareEdit(edit, plan.host))
33
+ const files: RecordedFile[] = []
34
+ const lines: string[] = []
35
+ for (const item of prepared) {
36
+ item.write()
37
+ files.push(mergeRecorded(item.recorded, previous?.files.find((file) => file.file === item.edit.file)))
38
+ lines.push(`${item.recorded.created ? "created" : "updated"} ${item.edit.file}`)
39
+ }
40
+ if ((plan.reverts ?? []).length > 0) {
41
+ lines.push(...applyReverts(plan))
42
+ }
43
+ const record: InstallRecord = {
44
+ version: 1,
45
+ host: plan.host,
46
+ scope: plan.scope,
47
+ tiers: plan.tiers,
48
+ createdAt: new Date().toISOString(),
49
+ files,
50
+ }
51
+ writeRecord(input, record)
52
+ const verification = verifyEdits(plan.edits, plan.host)
53
+ lines.push(...verification.lines)
54
+ return { lines, ok: verification.ok }
55
+ }
56
+
57
+ /**
58
+ * ADR-0001: every set op the plan previewed must still find the value it showed.
59
+ * A file that changed between plan review and apply refuses the whole apply with
60
+ * re-run guidance, so an interim user edit is never silently overwritten and the
61
+ * record's snapshots stay provably equal to what the user approved.
62
+ */
63
+ function assertNoDrift(plan: InstallPlan): void {
64
+ const drifts: string[] = []
65
+ for (const edit of plan.edits) {
66
+ if (edit.format === "rules") continue
67
+ const loaded = codecFor(edit.format, plan.host).load(edit.file)
68
+ if (loaded.parseError) {
69
+ drifts.push(`${edit.file} no longer parses (${loaded.parseError})`)
70
+ continue
71
+ }
72
+ for (const op of edit.ops) {
73
+ if (op.op !== "set") continue
74
+ const current = valueAt(loaded.value, op.path)
75
+ if (!deepEqual(current, op.previous)) {
76
+ drifts.push(`${edit.file}: ${op.path.join(".")} changed since the plan was rendered`)
77
+ }
78
+ }
79
+ }
80
+ if (drifts.length > 0) {
81
+ throw new Error(
82
+ `refusing to apply — the config changed since the plan was rendered: ${drifts.join("; ")}. ` +
83
+ "Re-run init to re-plan against the current files.",
84
+ )
85
+ }
86
+ }
87
+
88
+ function prepareEdit(edit: FileEdit, host: InstallPlan["host"]): PreparedEdit {
89
+ const codec = codecFor(edit.format, host)
90
+ const loaded = codec.load(edit.file)
91
+ if (loaded.parseError) {
92
+ throw new Error(`${edit.file} does not parse: ${loaded.parseError}`)
93
+ }
94
+ const applied = codec.apply(loaded.text, edit)
95
+ const recorded: RecordedFile =
96
+ edit.format === "rules"
97
+ ? { file: edit.file, format: "rules", created: !loaded.exists, ops: [], blockApplied: edit.block }
98
+ : { file: edit.file, format: edit.format, created: !loaded.exists, ops: applied.recorded, createdParents: applied.createdParents }
99
+ return {
100
+ edit,
101
+ recorded,
102
+ write: () => writeConfig(edit.file, applied.text),
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Reverts artifacts from a superseded init: shown in the plan, undone here, gone
108
+ * from the record. Files the new plan still claims revert partially (never
109
+ * deleted); files it no longer wants revert wholly and are deleted when the
110
+ * installer created them and they are left empty.
111
+ */
112
+ function applyReverts(plan: InstallPlan): string[] {
113
+ const lines: string[] = []
114
+ for (const revert of plan.reverts ?? []) {
115
+ const whole = !plan.edits.some((edit) => edit.file === revert.file)
116
+ lines.push(...revertRecordedFile(revert, plan.host, { label: " (previous init)", whole }))
117
+ }
118
+ return lines
119
+ }
120
+
121
+ /**
122
+ * Merges a re-apply with the record it supersedes: a set op whose previous value is
123
+ * what an earlier run applied inherits that run's snapshot, appended arrays
124
+ * accumulate, and the created flag keeps the first install's truth.
125
+ */
126
+ function mergeRecorded(current: RecordedFile, before: RecordedFile | undefined): RecordedFile {
127
+ if (before === undefined) return current
128
+ const ops = current.ops.map((op) => {
129
+ const old = before.ops.find((candidate) => candidate.op === op.op && candidate.path.join("\u0000") === op.path.join("\u0000"))
130
+ if (old === undefined) return op
131
+ if (op.op === "set" && old.op === "set" && deepEqual(op.previous, old.applied)) {
132
+ return { ...op, previous: old.previous }
133
+ }
134
+ if (op.op === "append-array" && old.op === "append-array") {
135
+ return { ...op, applied: [...(old.applied as unknown[]), ...(op.applied as unknown[])] }
136
+ }
137
+ return op
138
+ })
139
+ return {
140
+ ...current,
141
+ created: before.created,
142
+ ops,
143
+ createdParents: [...(before.createdParents ?? []), ...(current.createdParents ?? [])],
144
+ }
145
+ }
146
+
147
+ function verifyEdits(edits: readonly FileEdit[], host: InstallPlan["host"]): { lines: string[]; ok: boolean } {
148
+ const lines: string[] = []
149
+ let ok = true
150
+ for (const edit of edits) {
151
+ if (!existsSync(edit.file)) {
152
+ lines.push(`FAIL ${edit.file} does not exist after apply`)
153
+ ok = false
154
+ continue
155
+ }
156
+ const codec = codecFor(edit.format, host)
157
+ const text = readFileSync(edit.file, "utf8")
158
+ if (edit.format === "rules") {
159
+ const good = codec.verify(text, edit)
160
+ lines.push(`${good ? "pass" : "FAIL"} ${edit.file}: rules block ${good ? "present" : "missing"}`)
161
+ ok = ok && good
162
+ continue
163
+ }
164
+ for (const op of edit.ops) {
165
+ const good = codec.verify(text, edit, op)
166
+ lines.push(`${good ? "pass" : "FAIL"} ${edit.file}: ${describeOp(op)} ${good ? "holds" : "missing"}`)
167
+ ok = ok && good
168
+ }
169
+ }
170
+ return { lines, ok }
171
+ }
172
+
173
+ function describeOp(op: ConfigOp): string {
174
+ if (op.op === "set") return `set ${op.path.join(".")}`
175
+ return `append ${op.path.join(".")}`
176
+ }
@@ -0,0 +1,215 @@
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"]
14
+
15
+ const USAGE = `Usage:
16
+ deepseek-web-retrieval init --host ${HOST_CHOICES} --scope <project|global> \
17
+ --search <optional|preferred|forced|off> --fetch <optional|preferred|forced|off> \
18
+ [--package <npm ref>] [--cwd <dir>] [--apply]
19
+ deepseek-web-retrieval uninstall --host ${HOST_CHOICES} --scope <project|global> [--cwd <dir>]
20
+ deepseek-web-retrieval list [--cwd <dir>]
21
+
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.`
24
+
25
+ interface Flags {
26
+ readonly values: Map<string, string>
27
+ readonly booleans: Set<string>
28
+ readonly problems: readonly string[]
29
+ }
30
+
31
+ const VALUE_FLAGS = new Set(["host", "scope", "search", "fetch", "package", "cwd"])
32
+ const BOOLEAN_FLAGS = new Set(["apply"])
33
+
34
+ function parseFlags(args: readonly string[]): Flags {
35
+ const values = new Map<string, string>()
36
+ const booleans = new Set<string>()
37
+ const problems: string[] = []
38
+ for (let i = 0; i < args.length; i += 1) {
39
+ const arg = args[i] as string
40
+ if (!arg.startsWith("--")) {
41
+ problems.push(`unexpected argument "${arg}" (flags only)`)
42
+ continue
43
+ }
44
+ const eq = arg.indexOf("=")
45
+ const name = eq === -1 ? arg.slice(2) : arg.slice(2, eq)
46
+ if (BOOLEAN_FLAGS.has(name)) {
47
+ booleans.add(name)
48
+ continue
49
+ }
50
+ if (!VALUE_FLAGS.has(name)) {
51
+ problems.push(`unknown flag --${name}`)
52
+ continue
53
+ }
54
+ if (eq !== -1) {
55
+ values.set(name, arg.slice(eq + 1))
56
+ continue
57
+ }
58
+ const next = args[i + 1]
59
+ if (next === undefined || next.startsWith("--")) {
60
+ problems.push(`--${name} requires a value`)
61
+ continue
62
+ }
63
+ values.set(name, next)
64
+ i += 1
65
+ }
66
+ return { values, booleans, problems }
67
+ }
68
+
69
+ /** Where the CLI writes; injectable so tests can assert command output. */
70
+ export interface CliIo {
71
+ readonly out?: (line: string) => void
72
+ readonly err?: (line: string) => void
73
+ }
74
+
75
+ /** CLI entry for the init/uninstall/list subcommands; returns the process exit code. */
76
+ 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
80
+ if (command === undefined || command === "help" || command === "--help") {
81
+ out(USAGE)
82
+ return command === undefined ? 1 : 0
83
+ }
84
+ const flags = parseFlags(rest)
85
+ if (flags.problems.length > 0) {
86
+ for (const problem of flags.problems) err(`error: ${problem}`)
87
+ err(`\n${USAGE}`)
88
+ return 1
89
+ }
90
+ const cwd = resolve(flags.values.get("cwd") ?? process.cwd())
91
+ const home = process.env.HOME ?? homedir()
92
+ const configHome = resolveConfigHome(home)
93
+
94
+ if (command === "list") {
95
+ const records = listRecords({ home, cwd, configHome })
96
+ if (records.length === 0) {
97
+ out("No installs recorded.")
98
+ return 0
99
+ }
100
+ 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}`)
103
+ }
104
+ return 0
105
+ }
106
+
107
+ if (command === "uninstall") {
108
+ const required = requireInput(flags, cwd, home, configHome, false)
109
+ if (!required.ok) return usageError(required.problems, err)
110
+ try {
111
+ for (const line of uninstall(required.input)) out(line)
112
+ return 0
113
+ } catch (error) {
114
+ err(error instanceof Error ? error.message : String(error))
115
+ return 1
116
+ }
117
+ }
118
+
119
+ if (command !== "init") {
120
+ err(`Unknown command "${command}".\n\n${USAGE}`)
121
+ return 1
122
+ }
123
+
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)
128
+ if (plan.errors.length > 0) {
129
+ for (const error of plan.errors) err(`error: ${error.message}`)
130
+ err("Nothing was written.")
131
+ return 1
132
+ }
133
+ out(renderPlan(plan))
134
+ if (!flags.booleans.has("apply")) {
135
+ out("\nPlan only — re-run with --apply to write these changes.")
136
+ return 0
137
+ }
138
+ try {
139
+ const result = applyPlan(plan, input)
140
+ for (const line of result.lines) out(line)
141
+ return result.ok ? 0 : 1
142
+ } catch (error) {
143
+ err(error instanceof Error ? error.message : String(error))
144
+ return 1
145
+ }
146
+ }
147
+
148
+ 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
152
+ }
153
+
154
+ /**
155
+ * XDG_CONFIG_HOME is honored only when it belongs to the real home; the one
156
+ * environment read happens here, at the edge, and flows down as explicit input.
157
+ */
158
+ 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
162
+ }
163
+
164
+ type RequireInputResult =
165
+ | { readonly ok: true; readonly input: InstallerInput }
166
+ | { readonly ok: false; readonly problems: readonly string[] }
167
+
168
+ function requireInput(
169
+ flags: Flags,
170
+ cwd: string,
171
+ home: string,
172
+ configHome: string | undefined,
173
+ needsTiers: boolean,
174
+ ): 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(", ")})`)
184
+ if (needsTiers) {
185
+ if (!TIERS.includes(search as Tier)) {
186
+ problems.push(
187
+ flags.values.has("search")
188
+ ? `invalid --search value "${search}" (one of: ${TIERS.join(", ")})`
189
+ : `--search is required (one of: ${TIERS.join(", ")})`,
190
+ )
191
+ }
192
+ if (!TIERS.includes(fetch as Tier)) {
193
+ problems.push(
194
+ flags.values.has("fetch")
195
+ ? `invalid --fetch value "${fetch}" (one of: ${TIERS.join(", ")})`
196
+ : `--fetch is required (one of: ${TIERS.join(", ")})`,
197
+ )
198
+ }
199
+ }
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 }
202
+ return {
203
+ ok: true,
204
+ input: {
205
+ host: host as HostName,
206
+ scope: scope as Scope,
207
+ search: search as Tier,
208
+ fetch: fetch as Tier,
209
+ packageRef: flags.values.get("package") ?? "deepseek-web-retrieval",
210
+ home,
211
+ cwd,
212
+ configHome,
213
+ },
214
+ }
215
+ }