@cortexkit/opencode-anthropic-auth 1.15.0 → 1.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,370 @@
1
+ import { watch } from 'node:fs'
2
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
3
+ import { homedir } from 'node:os'
4
+ import { basename, dirname, join } from 'node:path'
5
+ import {
6
+ clearTimeout as nativeClearTimeout,
7
+ setTimeout as nativeSetTimeout,
8
+ } from 'node:timers'
9
+ import { applyEdits, modify, type ParseError, parse } from 'jsonc-parser'
10
+
11
+ export const TUI_PREFS_FILE_ENV = 'OPENCODE_TUI_PREFERENCES_FILE'
12
+ const FILE_NAME = 'tui-preferences.jsonc'
13
+
14
+ // Shared preferences file for opencode TUI plugins. One top-level key per
15
+ // plugin (short name, e.g. "anthropic-auth"). The file is optional: every
16
+ // reader must fall back to defaults when it is missing or malformed.
17
+ export function getTuiPreferencesFile(): string {
18
+ const override = process.env[TUI_PREFS_FILE_ENV]
19
+ if (override) return override
20
+ const configDir =
21
+ process.env.OPENCODE_CONFIG_DIR ||
22
+ join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'opencode')
23
+ return join(configDir, FILE_NAME)
24
+ }
25
+
26
+ function isRecord(value: unknown): value is Record<string, unknown> {
27
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
28
+ }
29
+
30
+ // Tolerant read: missing file, parse errors, or a non-object root all resolve
31
+ // to {} so the sidebar never crashes on user-edited content. jsonc-parser's
32
+ // fault-tolerant parse can still hand back a partial object for an
33
+ // unterminated/bracketed file or trailing garbage, so we collect errors and
34
+ // treat any reported fault as malformed.
35
+ export async function readTuiPreferencesFile(): Promise<
36
+ Record<string, unknown>
37
+ > {
38
+ try {
39
+ const raw = await readFile(getTuiPreferencesFile(), 'utf8')
40
+ const errors: ParseError[] = []
41
+ const root: unknown = parse(raw, errors, { allowTrailingComma: true })
42
+ if (errors.length > 0) return {}
43
+ return isRecord(root) ? root : {}
44
+ } catch {
45
+ return {}
46
+ }
47
+ }
48
+
49
+ export const PLUGIN_KEY = 'anthropic-auth'
50
+ export const DEFAULT_SLOT_ORDER = 160
51
+
52
+ export interface AnthropicAuthTuiPrefs {
53
+ forceToTop: boolean
54
+ order: number
55
+ startCollapsed: boolean
56
+ rememberCollapsed: boolean
57
+ // null = never persisted; seed the UI from startCollapsed instead.
58
+ collapsed: boolean | null
59
+ pollMs: number
60
+ refreshDebounceMs: number
61
+ header: {
62
+ label: string
63
+ showVersion: boolean
64
+ }
65
+ sections: {
66
+ quota: boolean
67
+ fallbackAccounts: boolean
68
+ routing: boolean
69
+ cache: boolean
70
+ health: boolean
71
+ pacing: boolean
72
+ }
73
+ appearance: {
74
+ barWidth: number
75
+ barFilledChar: string
76
+ barEmptyChar: string
77
+ warnThreshold: number
78
+ errorThreshold: number
79
+ }
80
+ }
81
+
82
+ export type AppearancePrefs = AnthropicAuthTuiPrefs['appearance']
83
+
84
+ export const DEFAULT_PREFS: AnthropicAuthTuiPrefs = {
85
+ forceToTop: false,
86
+ order: DEFAULT_SLOT_ORDER,
87
+ startCollapsed: false,
88
+ rememberCollapsed: true,
89
+ collapsed: null,
90
+ pollMs: 1500,
91
+ refreshDebounceMs: 200,
92
+ header: { label: 'CLAUDE', showVersion: true },
93
+ sections: {
94
+ quota: true,
95
+ fallbackAccounts: true,
96
+ routing: true,
97
+ cache: true,
98
+ health: true,
99
+ pacing: true,
100
+ },
101
+ appearance: {
102
+ barWidth: 10,
103
+ barFilledChar: '\u2588',
104
+ barEmptyChar: '\u2591',
105
+ warnThreshold: 50,
106
+ errorThreshold: 80,
107
+ },
108
+ }
109
+
110
+ function bool(value: unknown, fallback: boolean): boolean {
111
+ return typeof value === 'boolean' ? value : fallback
112
+ }
113
+
114
+ function int(
115
+ value: unknown,
116
+ fallback: number,
117
+ min: number,
118
+ max: number,
119
+ ): number {
120
+ if (typeof value !== 'number' || !Number.isFinite(value)) return fallback
121
+ return Math.min(Math.max(Math.round(value), min), max)
122
+ }
123
+
124
+ function label(value: unknown, fallback: string, maxLength: number): string {
125
+ if (typeof value !== 'string' || value.length === 0) return fallback
126
+ return value.slice(0, maxLength)
127
+ }
128
+
129
+ function char(value: unknown, fallback: string): string {
130
+ if (typeof value !== 'string') return fallback
131
+ const first = [...value][0]
132
+ return first ?? fallback
133
+ }
134
+
135
+ // Per-key validation: every value is independently clamped/defaulted so one
136
+ // bad entry never poisons the rest. Never throws.
137
+ export function resolveAnthropicAuthPrefs(
138
+ root: Record<string, unknown>,
139
+ ): AnthropicAuthTuiPrefs {
140
+ const entry = root[PLUGIN_KEY]
141
+ if (!isRecord(entry)) return structuredClone(DEFAULT_PREFS)
142
+
143
+ const d = DEFAULT_PREFS
144
+ const header = isRecord(entry.header) ? entry.header : {}
145
+ const sections = isRecord(entry.sections) ? entry.sections : {}
146
+ const appearance = isRecord(entry.appearance) ? entry.appearance : {}
147
+
148
+ const warnThreshold = int(
149
+ appearance.warnThreshold,
150
+ d.appearance.warnThreshold,
151
+ 0,
152
+ 99,
153
+ )
154
+ const errorThreshold = Math.max(
155
+ int(appearance.errorThreshold, d.appearance.errorThreshold, 0, 100),
156
+ Math.min(warnThreshold + 1, 100),
157
+ )
158
+
159
+ return {
160
+ forceToTop: bool(entry.forceToTop, d.forceToTop),
161
+ order: int(entry.order, d.order, -10000, 10000),
162
+ startCollapsed: bool(entry.startCollapsed, d.startCollapsed),
163
+ rememberCollapsed: bool(entry.rememberCollapsed, d.rememberCollapsed),
164
+ collapsed: typeof entry.collapsed === 'boolean' ? entry.collapsed : null,
165
+ pollMs: int(entry.pollMs, d.pollMs, 500, 30000),
166
+ refreshDebounceMs: int(
167
+ entry.refreshDebounceMs,
168
+ d.refreshDebounceMs,
169
+ 50,
170
+ 5000,
171
+ ),
172
+ header: {
173
+ label: label(header.label, d.header.label, 20),
174
+ showVersion: bool(header.showVersion, d.header.showVersion),
175
+ },
176
+ sections: {
177
+ quota: bool(sections.quota, d.sections.quota),
178
+ fallbackAccounts: bool(
179
+ sections.fallbackAccounts,
180
+ d.sections.fallbackAccounts,
181
+ ),
182
+ routing: bool(sections.routing, d.sections.routing),
183
+ cache: bool(sections.cache, d.sections.cache),
184
+ health: bool(sections.health, d.sections.health),
185
+ pacing: bool(sections.pacing, d.sections.pacing),
186
+ },
187
+ appearance: {
188
+ barWidth: int(appearance.barWidth, d.appearance.barWidth, 4, 40),
189
+ barFilledChar: char(appearance.barFilledChar, d.appearance.barFilledChar),
190
+ barEmptyChar: char(appearance.barEmptyChar, d.appearance.barEmptyChar),
191
+ warnThreshold,
192
+ errorThreshold,
193
+ },
194
+ }
195
+ }
196
+
197
+ const FORCE_TOP_BASE = -100000
198
+
199
+ // Shared forceToTop convention: forced plugins sort below FORCE_TOP_BASE,
200
+ // ordered among themselves by their top-level key's position in the file, so
201
+ // users reprioritize by reordering keys. The user-facing `order` knob clamps
202
+ // to -10000..10000, strictly above the forced band, so a manual order can
203
+ // never beat forceToTop. Host slots render ascending by order; opencode's
204
+ // built-ins occupy 100-500.
205
+ //
206
+ // Key-naming requirement: plugin keys must be non-integer-like short names
207
+ // (e.g. "anthropic-auth"). JavaScript object key iteration order hoists
208
+ // integer-like keys ("0", "1", "42") ahead of any string keys, which would
209
+ // skew the indexOf-based ordering of forced plugins. The shared
210
+ // `tui-preferences.jsonc` convention requires non-numeric names, so this
211
+ // implementation does not paper over the JS quirk.
212
+ export function computeEffectiveOrder(
213
+ root: Record<string, unknown>,
214
+ pluginKey: string,
215
+ defaultOrder: number,
216
+ ): number {
217
+ const entry = root[pluginKey]
218
+ if (!isRecord(entry)) return defaultOrder
219
+ if (entry.forceToTop === true) {
220
+ return FORCE_TOP_BASE + Object.keys(root).indexOf(pluginKey)
221
+ }
222
+ return int(entry.order, defaultOrder, -10000, 10000)
223
+ }
224
+
225
+ const TEMPLATE = `// Shared preferences for opencode TUI plugins.
226
+ // One top-level key per plugin (short name). See each plugin's README for
227
+ // its supported settings. This file is safe to hand-edit; plugins update
228
+ // individual keys in place and preserve comments.
229
+ {}
230
+ `
231
+
232
+ type JsonValue = string | number | boolean | null
233
+
234
+ async function writePreference(
235
+ pluginKey: string,
236
+ path: string[],
237
+ value: JsonValue,
238
+ ): Promise<void> {
239
+ const file = getTuiPreferencesFile()
240
+ await mkdir(dirname(file), { recursive: true })
241
+ let text: string
242
+ try {
243
+ text = await readFile(file, 'utf8')
244
+ } catch {
245
+ text = ''
246
+ }
247
+ if (text.trim() === '') text = TEMPLATE
248
+ const edits = modify(text, [pluginKey, ...path], value, {
249
+ formattingOptions: { insertSpaces: true, tabSize: 2 },
250
+ })
251
+ const next = applyEdits(text, edits)
252
+ const tmp = `${file}.${process.pid}.tmp`
253
+ await writeFile(tmp, next, 'utf8')
254
+ await rename(tmp, file)
255
+ }
256
+
257
+ let writeChain: Promise<void> = Promise.resolve()
258
+
259
+ // Writes are serialized on a promise chain: each update re-reads the file,
260
+ // applies a minimal comment-preserving edit to one property, and replaces the
261
+ // file atomically (temp + rename in the same directory). Best-effort by
262
+ // design — preferences are never worth crashing the TUI over.
263
+ export function queueTuiPreferenceUpdate(
264
+ pluginKey: string,
265
+ path: string[],
266
+ value: JsonValue,
267
+ ): Promise<void> {
268
+ writeChain = writeChain
269
+ .then(() => writePreference(pluginKey, path, value))
270
+ .catch(() => {})
271
+ return writeChain
272
+ }
273
+
274
+ const WATCH_DEBOUNCE_MS = 150
275
+ const WATCH_POLL_MS = 100
276
+
277
+ // Watches the directory rather than the file: editors and our own atomic
278
+ // writes replace the file via rename, which kills file-level watchers.
279
+ //
280
+ // Filtering is two-stage:
281
+ // 1. Filename pre-filter: only debounce events for the preferences file
282
+ // name, or our atomic-write temp file. This is a cheap first pass that
283
+ // drops the common case (unrelated sibling files).
284
+ // 2. Content check inside the debounce: after the timer fires, re-read
285
+ // the file and compare it against the last seen content. Only fire
286
+ // `onChange` when the content actually changed. This is the authority.
287
+ // Some platforms (notably macOS FSEvents and a few inotify backends)
288
+ // can misattribute a rename of an unrelated sibling file to the
289
+ // real preferences filename in addition to emitting the sibling's
290
+ // own event, so a name-only filter still produces a stray callback.
291
+ // A content comparison is robust against that, against coalesced
292
+ // events, and against mtime granularity.
293
+ //
294
+ // Returns a disposer; never throws.
295
+ export function watchTuiPreferences(onChange: () => void): () => void {
296
+ const file = getTuiPreferencesFile()
297
+ const name = basename(file)
298
+ let timer: ReturnType<typeof nativeSetTimeout> | null = null
299
+ let lastSeen: string | null = null
300
+ let observedEvent = false
301
+ let disposed = false
302
+
303
+ const checkForChange = async () => {
304
+ const text = await readFile(file, 'utf8').catch(() => null)
305
+ if (disposed || text === null) return
306
+ if (text === lastSeen) return
307
+ lastSeen = text
308
+ onChange()
309
+ }
310
+
311
+ const scheduleCheck = () => {
312
+ observedEvent = true
313
+ if (timer) nativeClearTimeout(timer)
314
+ timer = nativeSetTimeout(() => {
315
+ timer = null
316
+ void checkForChange()
317
+ }, WATCH_DEBOUNCE_MS)
318
+ }
319
+
320
+ // Seed asynchronously. If a real file event arrives before the read resolves,
321
+ // do not let the seed overwrite that pending change; the debounced re-read
322
+ // will compare against the previous value and fire.
323
+ void readFile(file, 'utf8')
324
+ .then((text) => {
325
+ if (!observedEvent && lastSeen === null) lastSeen = text
326
+ })
327
+ .catch(() => {})
328
+
329
+ try {
330
+ const watcher = watch(dirname(file), (_event, filename) => {
331
+ // Exact match against the preferences file name, plus the temp file
332
+ // we use for atomic writes (carrying our pid + `.tmp`).
333
+ const isOurs =
334
+ filename === name ||
335
+ (filename?.startsWith(`${name}.`) && filename.endsWith('.tmp'))
336
+ if (filename != null && !isOurs) return
337
+ scheduleCheck()
338
+ })
339
+
340
+ // Bun's fs.watch can miss atomic temp-file rename updates on some
341
+ // backends, so keep a low-cost polling loop as the correctness path.
342
+ // Use recursive timers instead of fs.watchFile because watchFile can keep
343
+ // Bun test processes alive long after unwatchFile().
344
+ let pollTimer: ReturnType<typeof nativeSetTimeout> | null = null
345
+ let pollInFlight = false
346
+ const schedulePoll = () => {
347
+ if (disposed || pollTimer) return
348
+ pollTimer = nativeSetTimeout(() => {
349
+ pollTimer = null
350
+ if (disposed || pollInFlight) return
351
+ pollInFlight = true
352
+ void checkForChange().finally(() => {
353
+ pollInFlight = false
354
+ schedulePoll()
355
+ })
356
+ }, WATCH_POLL_MS)
357
+ pollTimer.unref?.()
358
+ }
359
+ schedulePoll()
360
+
361
+ return () => {
362
+ disposed = true
363
+ if (timer) nativeClearTimeout(timer)
364
+ if (pollTimer) nativeClearTimeout(pollTimer)
365
+ watcher.close()
366
+ }
367
+ } catch {
368
+ return () => {}
369
+ }
370
+ }