kaizenai 0.1.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 (74) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +246 -0
  3. package/bin/kaizen +15 -0
  4. package/dist/client/apple-touch-icon.png +0 -0
  5. package/dist/client/assets/index-D-ORCGrq.js +603 -0
  6. package/dist/client/assets/index-r28mcHqz.css +32 -0
  7. package/dist/client/favicon.png +0 -0
  8. package/dist/client/fonts/body-medium.woff2 +0 -0
  9. package/dist/client/fonts/body-regular-italic.woff2 +0 -0
  10. package/dist/client/fonts/body-regular.woff2 +0 -0
  11. package/dist/client/fonts/body-semibold.woff2 +0 -0
  12. package/dist/client/index.html +22 -0
  13. package/dist/client/manifest-dark.webmanifest +24 -0
  14. package/dist/client/manifest.webmanifest +24 -0
  15. package/dist/client/pwa-192.png +0 -0
  16. package/dist/client/pwa-512.png +0 -0
  17. package/dist/client/pwa-icon.svg +15 -0
  18. package/dist/client/pwa-splash.png +0 -0
  19. package/dist/client/pwa-splash.svg +15 -0
  20. package/package.json +103 -0
  21. package/src/server/acp-shared.ts +315 -0
  22. package/src/server/agent.ts +1120 -0
  23. package/src/server/attachments.ts +133 -0
  24. package/src/server/backgrounds.ts +74 -0
  25. package/src/server/cli-runtime.ts +333 -0
  26. package/src/server/cli-supervisor.ts +81 -0
  27. package/src/server/cli.ts +68 -0
  28. package/src/server/codex-app-server-protocol.ts +453 -0
  29. package/src/server/codex-app-server.ts +1350 -0
  30. package/src/server/cursor-acp.ts +819 -0
  31. package/src/server/discovery.ts +322 -0
  32. package/src/server/event-store.ts +1369 -0
  33. package/src/server/events.ts +244 -0
  34. package/src/server/external-open.ts +272 -0
  35. package/src/server/gemini-acp.ts +844 -0
  36. package/src/server/gemini-cli.ts +525 -0
  37. package/src/server/generate-title.ts +36 -0
  38. package/src/server/git-manager.ts +79 -0
  39. package/src/server/git-repository.ts +101 -0
  40. package/src/server/harness-types.ts +20 -0
  41. package/src/server/keybindings.ts +177 -0
  42. package/src/server/machine-name.ts +22 -0
  43. package/src/server/paths.ts +112 -0
  44. package/src/server/process-utils.ts +22 -0
  45. package/src/server/project-icon.ts +344 -0
  46. package/src/server/project-metadata.ts +10 -0
  47. package/src/server/provider-catalog.ts +85 -0
  48. package/src/server/provider-settings.ts +155 -0
  49. package/src/server/quick-response.ts +153 -0
  50. package/src/server/read-models.ts +275 -0
  51. package/src/server/recovery.ts +507 -0
  52. package/src/server/restart.ts +30 -0
  53. package/src/server/server.ts +244 -0
  54. package/src/server/terminal-manager.ts +350 -0
  55. package/src/server/theme-settings.ts +179 -0
  56. package/src/server/update-manager.ts +230 -0
  57. package/src/server/usage/base-provider-usage.ts +57 -0
  58. package/src/server/usage/claude-usage.ts +558 -0
  59. package/src/server/usage/codex-usage.ts +144 -0
  60. package/src/server/usage/cursor-browser.ts +120 -0
  61. package/src/server/usage/cursor-cookies.ts +390 -0
  62. package/src/server/usage/cursor-usage.ts +490 -0
  63. package/src/server/usage/gemini-usage.ts +24 -0
  64. package/src/server/usage/provider-usage.ts +61 -0
  65. package/src/server/usage/test-helpers.ts +9 -0
  66. package/src/server/usage/types.ts +54 -0
  67. package/src/server/usage/utils.ts +325 -0
  68. package/src/server/ws-router.ts +717 -0
  69. package/src/shared/branding.ts +83 -0
  70. package/src/shared/dev-ports.ts +43 -0
  71. package/src/shared/ports.ts +2 -0
  72. package/src/shared/protocol.ts +152 -0
  73. package/src/shared/tools.ts +251 -0
  74. package/src/shared/types.ts +1028 -0
@@ -0,0 +1,344 @@
1
+ import { readdirSync, readFileSync, statSync } from "node:fs"
2
+ import path from "node:path"
3
+ import { PROJECT_METADATA_DIR_NAME } from "../shared/branding"
4
+ import { resolveLocalPath } from "./paths"
5
+ import { getProjectMetadataCandidateRelativePath } from "./project-metadata"
6
+
7
+ const MAX_ICON_BYTES = 128 * 1024
8
+ const ICON_CANDIDATE_PATHS = [
9
+ "icon.svg",
10
+ "icon.png",
11
+ "icon.jpg",
12
+ "icon.jpeg",
13
+ "icon.webp",
14
+ "icon.gif",
15
+ "icon.avif",
16
+ "icon.ico",
17
+ getProjectMetadataCandidateRelativePath("icon.svg"),
18
+ getProjectMetadataCandidateRelativePath("icon.png"),
19
+ getProjectMetadataCandidateRelativePath("icon.jpg"),
20
+ getProjectMetadataCandidateRelativePath("icon.jpeg"),
21
+ getProjectMetadataCandidateRelativePath("icon.webp"),
22
+ getProjectMetadataCandidateRelativePath("icon.gif"),
23
+ getProjectMetadataCandidateRelativePath("icon.avif"),
24
+ getProjectMetadataCandidateRelativePath("icon.ico"),
25
+ "favicon.svg",
26
+ "favicon.png",
27
+ "favicon.jpg",
28
+ "favicon.jpeg",
29
+ "favicon.webp",
30
+ "favicon.gif",
31
+ "favicon.avif",
32
+ "favicon.ico",
33
+ "public/favicon.svg",
34
+ "public/favicon.png",
35
+ "public/favicon.jpg",
36
+ "public/favicon.jpeg",
37
+ "public/favicon.webp",
38
+ "public/favicon.gif",
39
+ "public/favicon.avif",
40
+ "public/favicon.ico",
41
+ "public/icon.svg",
42
+ "public/icon.png",
43
+ "public/icon.jpg",
44
+ "public/icon.jpeg",
45
+ "public/icon.webp",
46
+ "public/icon.gif",
47
+ "public/icon.avif",
48
+ "public/icon.ico",
49
+ "app/favicon.svg",
50
+ "app/favicon.png",
51
+ "app/favicon.jpg",
52
+ "app/favicon.jpeg",
53
+ "app/favicon.webp",
54
+ "app/favicon.gif",
55
+ "app/favicon.avif",
56
+ "app/favicon.ico",
57
+ "app/icon.svg",
58
+ "app/icon.png",
59
+ "app/icon.jpg",
60
+ "app/icon.jpeg",
61
+ "app/icon.webp",
62
+ "app/icon.gif",
63
+ "app/icon.avif",
64
+ "app/icon.ico",
65
+ "src/favicon.svg",
66
+ "src/favicon.png",
67
+ "src/favicon.jpg",
68
+ "src/favicon.jpeg",
69
+ "src/favicon.webp",
70
+ "src/favicon.gif",
71
+ "src/favicon.avif",
72
+ "src/favicon.ico",
73
+ "src/app/favicon.svg",
74
+ "src/app/favicon.png",
75
+ "src/app/favicon.jpg",
76
+ "src/app/favicon.jpeg",
77
+ "src/app/favicon.webp",
78
+ "src/app/favicon.gif",
79
+ "src/app/favicon.avif",
80
+ "src/app/favicon.ico",
81
+ "src/app/icon.svg",
82
+ "src/app/icon.png",
83
+ "src/app/icon.jpg",
84
+ "src/app/icon.jpeg",
85
+ "src/app/icon.webp",
86
+ "src/app/icon.gif",
87
+ "src/app/icon.avif",
88
+ "src/app/icon.ico",
89
+ ]
90
+ const ICON_SOURCE_FILES = [
91
+ "index.html",
92
+ "public/index.html",
93
+ "app/root.tsx",
94
+ "src/root.tsx",
95
+ "app/routes/__root.tsx",
96
+ "src/routes/__root.tsx",
97
+ ]
98
+ const LINK_ICON_HTML_RE = /<link[^>]*rel=["'](?:icon|shortcut icon)["'][^>]*href=["']([^"'?]+)/i
99
+ const LINK_ICON_OBJ_RE = /rel:\s*["'](?:icon|shortcut icon)["'][^}]*href:\s*["']([^"'?]+)/i
100
+
101
+ function getMimeType(extension: string) {
102
+ switch (extension) {
103
+ case ".svg":
104
+ return "image/svg+xml"
105
+ case ".png":
106
+ return "image/png"
107
+ case ".ico":
108
+ return "image/vnd.microsoft.icon"
109
+ case ".jpg":
110
+ case ".jpeg":
111
+ return "image/jpeg"
112
+ case ".webp":
113
+ return "image/webp"
114
+ case ".gif":
115
+ return "image/gif"
116
+ case ".avif":
117
+ return "image/avif"
118
+ default:
119
+ return null
120
+ }
121
+ }
122
+
123
+ function compareIconNames(a: string, b: string) {
124
+ const aExt = path.extname(a).toLowerCase()
125
+ const bExt = path.extname(b).toLowerCase()
126
+ const aIndex = getExtensionPriority(aExt)
127
+ const bIndex = getExtensionPriority(bExt)
128
+ if (aIndex !== bIndex) return aIndex - bIndex
129
+ return a.localeCompare(b)
130
+ }
131
+
132
+ function getExtensionPriority(extension: string) {
133
+ switch (extension) {
134
+ case ".svg":
135
+ return 0
136
+ case ".png":
137
+ return 1
138
+ case ".jpg":
139
+ case ".jpeg":
140
+ return 2
141
+ case ".webp":
142
+ return 3
143
+ case ".gif":
144
+ return 4
145
+ case ".avif":
146
+ return 5
147
+ case ".ico":
148
+ return 6
149
+ default:
150
+ return Number.MAX_SAFE_INTEGER
151
+ }
152
+ }
153
+
154
+ function findFixedIconCandidates(projectPath: string) {
155
+ try {
156
+ const existingPaths = new Map<string, string>()
157
+
158
+ for (const relativePath of ICON_CANDIDATE_PATHS) {
159
+ const extension = path.extname(relativePath).toLowerCase()
160
+ if (getMimeType(extension) === null) continue
161
+
162
+ const absolutePath = resolveCaseInsensitiveProjectPath(projectPath, relativePath)
163
+ if (!absolutePath) continue
164
+ existingPaths.set(relativePath, absolutePath)
165
+ }
166
+
167
+ return [...existingPaths.entries()]
168
+ .sort((a, b) => {
169
+ const pathCompare = ICON_CANDIDATE_PATHS.indexOf(a[0]) - ICON_CANDIDATE_PATHS.indexOf(b[0])
170
+ if (pathCompare !== 0) return pathCompare
171
+ return compareIconNames(a[0], b[0])
172
+ })
173
+ .map(([, absolutePath]) => absolutePath)
174
+ } catch {
175
+ return []
176
+ }
177
+ }
178
+
179
+ function findAppWorkspaceIconCandidates(projectPath: string) {
180
+ const appsPath = path.join(projectPath, "apps")
181
+
182
+ try {
183
+ const appDirectories = readdirSync(appsPath, { withFileTypes: true })
184
+ .filter((entry) => entry.isDirectory())
185
+ .map((entry) => entry.name)
186
+ .sort((a, b) => a.localeCompare(b))
187
+
188
+ const existingPaths = new Map<string, string>()
189
+
190
+ for (const appDirectory of appDirectories) {
191
+ for (const relativePath of ICON_CANDIDATE_PATHS) {
192
+ if (relativePath.startsWith(`${PROJECT_METADATA_DIR_NAME}/`)) continue
193
+ const appRelativePath = path.join("apps", appDirectory, relativePath)
194
+ const extension = path.extname(appRelativePath).toLowerCase()
195
+ if (getMimeType(extension) === null) continue
196
+
197
+ const absolutePath = resolveCaseInsensitiveProjectPath(projectPath, appRelativePath)
198
+ if (!absolutePath) continue
199
+ existingPaths.set(appRelativePath, absolutePath)
200
+ }
201
+ }
202
+
203
+ return [...existingPaths.entries()]
204
+ .sort((a, b) => {
205
+ const [aPath] = a
206
+ const [bPath] = b
207
+ const aRelative = aPath.replace(/^apps\/[^/]+\//, "")
208
+ const bRelative = bPath.replace(/^apps\/[^/]+\//, "")
209
+ const pathCompare = ICON_CANDIDATE_PATHS.indexOf(aRelative) - ICON_CANDIDATE_PATHS.indexOf(bRelative)
210
+ if (pathCompare !== 0) return pathCompare
211
+ return compareIconNames(aPath, bPath)
212
+ })
213
+ .map(([, absolutePath]) => absolutePath)
214
+ } catch {
215
+ return []
216
+ }
217
+ }
218
+
219
+ function resolveCaseInsensitiveProjectPath(projectPath: string, relativePath: string) {
220
+ const segments = relativePath.split("/").filter(Boolean)
221
+ let currentPath = projectPath
222
+
223
+ for (const [index, segment] of segments.entries()) {
224
+ let entries: string[]
225
+ try {
226
+ entries = readdirSync(currentPath)
227
+ } catch {
228
+ return null
229
+ }
230
+
231
+ const matchedEntry = entries.find((entry) => entry.toLowerCase() === segment.toLowerCase())
232
+ if (!matchedEntry) {
233
+ return null
234
+ }
235
+
236
+ currentPath = path.join(currentPath, matchedEntry)
237
+
238
+ try {
239
+ const stat = statSync(currentPath)
240
+ const isLast = index === segments.length - 1
241
+ if (isLast) {
242
+ return stat.isFile() ? currentPath : null
243
+ }
244
+ if (!stat.isDirectory()) {
245
+ return null
246
+ }
247
+ } catch {
248
+ return null
249
+ }
250
+ }
251
+
252
+ return null
253
+ }
254
+
255
+ function extractDeclaredIconHref(source: string) {
256
+ const htmlMatch = source.match(LINK_ICON_HTML_RE)
257
+ if (htmlMatch?.[1]) return htmlMatch[1]
258
+ const objMatch = source.match(LINK_ICON_OBJ_RE)
259
+ if (objMatch?.[1]) return objMatch[1]
260
+ return null
261
+ }
262
+
263
+ function resolveDeclaredIconCandidates(projectPath: string, href: string) {
264
+ const cleanHref = href.replace(/^\//, "")
265
+ return [
266
+ path.join(projectPath, "public", cleanHref),
267
+ path.join(projectPath, cleanHref),
268
+ ]
269
+ }
270
+
271
+ function findDeclaredIconCandidates(projectPath: string) {
272
+ const candidates: string[] = []
273
+
274
+ for (const sourceFile of ICON_SOURCE_FILES) {
275
+ const sourcePath = path.join(projectPath, sourceFile)
276
+
277
+ try {
278
+ const source = readFileSync(sourcePath, "utf8")
279
+ const href = extractDeclaredIconHref(source)
280
+ if (!href) continue
281
+
282
+ for (const candidatePath of resolveDeclaredIconCandidates(projectPath, href)) {
283
+ const extension = path.extname(candidatePath).toLowerCase()
284
+ if (getMimeType(extension) === null) continue
285
+ if (!candidates.includes(candidatePath)) {
286
+ candidates.push(candidatePath)
287
+ }
288
+ }
289
+ } catch {
290
+ continue
291
+ }
292
+ }
293
+
294
+ return candidates
295
+ }
296
+
297
+ function tryReadImageDataUrl(filePath: string) {
298
+ try {
299
+ const stat = statSync(filePath)
300
+ if (!stat.isFile() || stat.size > MAX_ICON_BYTES) {
301
+ return null
302
+ }
303
+
304
+ const extension = path.extname(filePath).toLowerCase()
305
+ const mimeType = getMimeType(extension)
306
+ if (!mimeType) {
307
+ return null
308
+ }
309
+
310
+ if (extension === ".svg") {
311
+ const svg = readFileSync(filePath, "utf8").trim()
312
+ if (!svg.includes("<svg")) {
313
+ return null
314
+ }
315
+
316
+ return `data:${mimeType};utf8,${encodeURIComponent(svg)}`
317
+ }
318
+
319
+ const image = readFileSync(filePath)
320
+ return `data:${mimeType};base64,${image.toString("base64")}`
321
+ } catch {
322
+ return null
323
+ }
324
+
325
+ return null
326
+ }
327
+
328
+ export function resolveProjectIconDataUrl(localPath: string): string | null {
329
+ const projectPath = resolveLocalPath(localPath)
330
+ const candidatePaths = [
331
+ ...findFixedIconCandidates(projectPath),
332
+ ...findAppWorkspaceIconCandidates(projectPath),
333
+ ...findDeclaredIconCandidates(projectPath),
334
+ ]
335
+
336
+ for (const candidatePath of candidatePaths) {
337
+ const dataUrl = tryReadImageDataUrl(candidatePath)
338
+ if (dataUrl) {
339
+ return dataUrl
340
+ }
341
+ }
342
+
343
+ return null
344
+ }
@@ -0,0 +1,10 @@
1
+ import path from "node:path"
2
+ import { PROJECT_METADATA_DIR_NAME } from "../shared/branding"
3
+
4
+ export function getProjectMetadataDirPath(localPath: string) {
5
+ return path.join(localPath, PROJECT_METADATA_DIR_NAME)
6
+ }
7
+
8
+ export function getProjectMetadataCandidateRelativePath(fileName: string) {
9
+ return path.posix.join(PROJECT_METADATA_DIR_NAME, fileName)
10
+ }
@@ -0,0 +1,85 @@
1
+ import type {
2
+ AgentProvider,
3
+ ClaudeModelOptions,
4
+ ClaudeContextWindow,
5
+ CodexModelOptions,
6
+ CursorModelOptions,
7
+ GeminiModelOptions,
8
+ ModelOptions,
9
+ ProviderCatalogEntry,
10
+ ServiceTier,
11
+ } from "../shared/types"
12
+ import {
13
+ getProviderCatalog,
14
+ getProviderDefaultModelOptions,
15
+ DEFAULT_CLAUDE_MODEL_OPTIONS,
16
+ normalizeClaudeContextWindow,
17
+ isClaudeReasoningEffort,
18
+ isCodexReasoningEffort,
19
+ isGeminiThinkingMode,
20
+ normalizeCursorModelId,
21
+ } from "../shared/types"
22
+
23
+ export function getServerProviderCatalog(provider: AgentProvider): ProviderCatalogEntry {
24
+ return getProviderCatalog(provider)
25
+ }
26
+
27
+ export function normalizeServerModel(provider: AgentProvider, model?: string): string {
28
+ if (provider === "cursor") {
29
+ return normalizeCursorModelId(model)
30
+ }
31
+ const catalog = getServerProviderCatalog(provider)
32
+ if (model && catalog.models.some((candidate) => candidate.id === model)) {
33
+ return model
34
+ }
35
+ return catalog.defaultModel
36
+ }
37
+
38
+ export function normalizeClaudeModelOptions(
39
+ model: string,
40
+ modelOptions?: ModelOptions,
41
+ legacyEffort?: string
42
+ ): ClaudeModelOptions {
43
+ const reasoningEffort = modelOptions?.claude?.reasoningEffort
44
+ return {
45
+ reasoningEffort: isClaudeReasoningEffort(reasoningEffort)
46
+ ? reasoningEffort
47
+ : isClaudeReasoningEffort(legacyEffort)
48
+ ? legacyEffort
49
+ : DEFAULT_CLAUDE_MODEL_OPTIONS.reasoningEffort,
50
+ contextWindow: normalizeClaudeContextWindow(model, modelOptions?.claude?.contextWindow as ClaudeContextWindow | undefined),
51
+ }
52
+ }
53
+
54
+ export function normalizeCodexModelOptions(modelOptions?: ModelOptions, legacyEffort?: string): CodexModelOptions {
55
+ const defaultOptions = getProviderDefaultModelOptions("codex")
56
+ const reasoningEffort = modelOptions?.codex?.reasoningEffort
57
+ return {
58
+ reasoningEffort: isCodexReasoningEffort(reasoningEffort)
59
+ ? reasoningEffort
60
+ : isCodexReasoningEffort(legacyEffort)
61
+ ? legacyEffort
62
+ : defaultOptions.reasoningEffort,
63
+ fastMode: typeof modelOptions?.codex?.fastMode === "boolean"
64
+ ? modelOptions.codex.fastMode
65
+ : defaultOptions.fastMode,
66
+ }
67
+ }
68
+
69
+ export function normalizeGeminiModelOptions(modelOptions?: ModelOptions): GeminiModelOptions {
70
+ const defaultOptions = getProviderDefaultModelOptions("gemini")
71
+ return {
72
+ thinkingMode: isGeminiThinkingMode(modelOptions?.gemini?.thinkingMode)
73
+ ? modelOptions.gemini.thinkingMode
74
+ : defaultOptions.thinkingMode,
75
+ }
76
+ }
77
+
78
+ export function normalizeCursorModelOptions(modelOptions?: ModelOptions): CursorModelOptions {
79
+ void modelOptions
80
+ return { ...getProviderDefaultModelOptions("cursor") }
81
+ }
82
+
83
+ export function codexServiceTierFromModelOptions(modelOptions: CodexModelOptions): ServiceTier | undefined {
84
+ return modelOptions.fastMode ? "fast" : undefined
85
+ }
@@ -0,0 +1,155 @@
1
+ import { watch, type FSWatcher } from "node:fs"
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises"
3
+ import { homedir } from "node:os"
4
+ import path from "node:path"
5
+ import { getProviderSettingsFilePath, LOG_PREFIX } from "../shared/branding"
6
+ import {
7
+ DEFAULT_PROVIDER_SETTINGS,
8
+ type AgentProvider,
9
+ type ProviderSettingsEntry,
10
+ type ProviderSettingsSnapshot,
11
+ } from "../shared/types"
12
+
13
+ const PROVIDER_IDS = Object.keys(DEFAULT_PROVIDER_SETTINGS) as AgentProvider[]
14
+
15
+ type ProviderSettingsFile = Partial<Record<AgentProvider, Partial<ProviderSettingsEntry>>>
16
+
17
+ export class ProviderSettingsManager {
18
+ readonly filePath: string
19
+ private watcher: FSWatcher | null = null
20
+ private snapshot: ProviderSettingsSnapshot
21
+ private readonly listeners = new Set<(snapshot: ProviderSettingsSnapshot) => void>()
22
+
23
+ constructor(filePath = getProviderSettingsFilePath(homedir())) {
24
+ this.filePath = filePath
25
+ this.snapshot = createDefaultSnapshot(this.filePath)
26
+ }
27
+
28
+ async initialize() {
29
+ await mkdir(path.dirname(this.filePath), { recursive: true })
30
+ const file = Bun.file(this.filePath)
31
+ if (!(await file.exists())) {
32
+ await writeFile(this.filePath, `${JSON.stringify(DEFAULT_PROVIDER_SETTINGS, null, 2)}\n`, "utf8")
33
+ }
34
+ await this.reload()
35
+ this.startWatching()
36
+ }
37
+
38
+ dispose() {
39
+ this.watcher?.close()
40
+ this.watcher = null
41
+ this.listeners.clear()
42
+ }
43
+
44
+ getSnapshot() {
45
+ return this.snapshot
46
+ }
47
+
48
+ onChange(listener: (snapshot: ProviderSettingsSnapshot) => void) {
49
+ this.listeners.add(listener)
50
+ return () => {
51
+ this.listeners.delete(listener)
52
+ }
53
+ }
54
+
55
+ async reload() {
56
+ const nextSnapshot = await readProviderSettingsSnapshot(this.filePath)
57
+ this.setSnapshot(nextSnapshot)
58
+ }
59
+
60
+ async write(settings: ProviderSettingsSnapshot["settings"]) {
61
+ const nextSnapshot = normalizeProviderSettings(settings, this.filePath)
62
+ await mkdir(path.dirname(this.filePath), { recursive: true })
63
+ await writeFile(this.filePath, `${JSON.stringify(nextSnapshot.settings, null, 2)}\n`, "utf8")
64
+ this.setSnapshot(nextSnapshot)
65
+ return nextSnapshot
66
+ }
67
+
68
+ private setSnapshot(snapshot: ProviderSettingsSnapshot) {
69
+ this.snapshot = snapshot
70
+ for (const listener of this.listeners) {
71
+ listener(snapshot)
72
+ }
73
+ }
74
+
75
+ private startWatching() {
76
+ this.watcher?.close()
77
+ try {
78
+ this.watcher = watch(path.dirname(this.filePath), { persistent: false }, (_eventType, filename) => {
79
+ if (filename && filename !== path.basename(this.filePath)) {
80
+ return
81
+ }
82
+ void this.reload().catch((error: unknown) => {
83
+ console.warn(`${LOG_PREFIX} Failed to reload provider settings:`, error)
84
+ })
85
+ })
86
+ } catch (error) {
87
+ console.warn(`${LOG_PREFIX} Failed to watch provider settings file:`, error)
88
+ this.watcher = null
89
+ }
90
+ }
91
+ }
92
+
93
+ export async function readProviderSettingsSnapshot(filePath: string): Promise<ProviderSettingsSnapshot> {
94
+ try {
95
+ const text = await readFile(filePath, "utf8")
96
+ if (!text.trim()) {
97
+ return createDefaultSnapshot(filePath, "Provider settings file was empty. Using defaults.")
98
+ }
99
+ const parsed = JSON.parse(text) as ProviderSettingsFile
100
+ return normalizeProviderSettings(parsed, filePath)
101
+ } catch (error) {
102
+ if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
103
+ return createDefaultSnapshot(filePath)
104
+ }
105
+ if (error instanceof SyntaxError) {
106
+ return createDefaultSnapshot(filePath, "Provider settings file is invalid JSON. Using defaults.")
107
+ }
108
+ throw error
109
+ }
110
+ }
111
+
112
+ export function normalizeProviderSettings(
113
+ value: ProviderSettingsFile | null | undefined,
114
+ filePath = getProviderSettingsFilePath(homedir())
115
+ ): ProviderSettingsSnapshot {
116
+ const source = value && typeof value === "object" && !Array.isArray(value) ? value : {}
117
+
118
+ const settings = {} as ProviderSettingsSnapshot["settings"]
119
+ for (const provider of PROVIDER_IDS) {
120
+ const rawValue = source[provider]
121
+ const defaultValue = DEFAULT_PROVIDER_SETTINGS[provider]
122
+
123
+ settings[provider] = {
124
+ active: typeof rawValue?.active === "boolean" ? rawValue.active : defaultValue.active,
125
+ }
126
+ }
127
+
128
+ return {
129
+ settings,
130
+ warning: null,
131
+ filePathDisplay: formatDisplayPath(filePath),
132
+ }
133
+ }
134
+
135
+ function createDefaultSnapshot(filePath: string, warning: string | null = null): ProviderSettingsSnapshot {
136
+ return {
137
+ settings: {
138
+ claude: { ...DEFAULT_PROVIDER_SETTINGS.claude },
139
+ codex: { ...DEFAULT_PROVIDER_SETTINGS.codex },
140
+ gemini: { ...DEFAULT_PROVIDER_SETTINGS.gemini },
141
+ cursor: { ...DEFAULT_PROVIDER_SETTINGS.cursor },
142
+ },
143
+ warning,
144
+ filePathDisplay: formatDisplayPath(filePath),
145
+ }
146
+ }
147
+
148
+ function formatDisplayPath(filePath: string) {
149
+ const homePath = homedir()
150
+ if (filePath === homePath) return "~"
151
+ if (filePath.startsWith(`${homePath}${path.sep}`)) {
152
+ return `~${filePath.slice(homePath.length)}`
153
+ }
154
+ return filePath
155
+ }