@cortexkit/opencode-anthropic-auth 1.10.3 → 1.11.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.
@@ -5,7 +5,7 @@ import type { OpenDialogPayload } from '../rpc/protocol.js'
5
5
  type ApplyFn = (
6
6
  command: OpenDialogPayload['command'],
7
7
  args: string,
8
- ) => Promise<{ text: string }>
8
+ ) => Promise<{ text: string; knobs: Record<string, unknown> }>
9
9
 
10
10
  function showText(api: TuiPluginApi, text: string) {
11
11
  api.ui.dialog.setSize('xlarge')
@@ -224,6 +224,483 @@ export function openCommandDialog(
224
224
  return
225
225
  }
226
226
 
227
+ if (payload.command === 'claude-account') {
228
+ const accounts =
229
+ (payload.knobs.accounts as Array<{
230
+ id: string
231
+ label: string
232
+ role: string
233
+ enabled: boolean
234
+ quotaPercent: number | null
235
+ }>) ?? []
236
+
237
+ const updateAccounts = (r: {
238
+ text: string
239
+ knobs: Record<string, unknown>
240
+ }) => {
241
+ const updated = r.knobs.accounts as typeof accounts
242
+ if (updated && updated.length > 0) {
243
+ accounts.length = 0
244
+ accounts.push(...updated)
245
+ }
246
+ }
247
+
248
+ const buildL1 = () => {
249
+ const DialogSelect = api.ui.DialogSelect<string>
250
+ const l1Options: Array<{
251
+ title: string
252
+ value: string
253
+ description?: string
254
+ }> = [
255
+ {
256
+ title: 'Add account\u2026',
257
+ value: '__add__',
258
+ description: 'Add an API key or OAuth fallback account',
259
+ },
260
+ ...accounts.map((a) => {
261
+ const pct =
262
+ a.quotaPercent != null
263
+ ? ` ${Math.round(a.quotaPercent)}%`
264
+ : ' \u2013%'
265
+ const status = !a.enabled ? ' (disabled)' : ''
266
+ return {
267
+ title: `${a.label} [${a.role}]${status}${pct}`,
268
+ value: a.id,
269
+ }
270
+ }),
271
+ ]
272
+ api.ui.dialog.setSize('xlarge')
273
+ api.ui.dialog.replace(() => (
274
+ <DialogSelect
275
+ title='Claude accounts'
276
+ options={l1Options}
277
+ onSelect={(option) => {
278
+ if (option.value === '__add__') {
279
+ openAddType()
280
+ return
281
+ }
282
+ const account = accounts.find((a) => a.id === option.value)
283
+ if (!account) return
284
+ if (account.role === 'main') {
285
+ const pct =
286
+ account.quotaPercent != null
287
+ ? ` ${Math.round(account.quotaPercent)}%`
288
+ : ' \u2013%'
289
+ showText(
290
+ api,
291
+ `${account.label}\nRole: main (read-only)\nQuota:${pct}`,
292
+ )
293
+ return
294
+ }
295
+ openManage(account, false)
296
+ }}
297
+ />
298
+ ))
299
+ }
300
+
301
+ // -- Add type selection (OAuth vs API key) ------------------------------
302
+ const openAddType = () => {
303
+ const DialogSelect = api.ui.DialogSelect<string>
304
+ api.ui.dialog.setSize('xlarge')
305
+ api.ui.dialog.replace(() => (
306
+ <DialogSelect
307
+ title='Add account'
308
+ options={[
309
+ {
310
+ title: 'OAuth (login)',
311
+ value: 'oauth',
312
+ description:
313
+ 'Sign in to Claude via browser — works with Pro, Max, and Team plans',
314
+ },
315
+ {
316
+ title: 'API key',
317
+ value: 'apikey',
318
+ description:
319
+ 'Provide an API key for an Anthropic-compatible endpoint',
320
+ },
321
+ { title: 'Back', value: 'back' },
322
+ ]}
323
+ onSelect={(option) => {
324
+ if (option.value === 'back') {
325
+ buildL1()
326
+ return
327
+ }
328
+ if (option.value === 'apikey') {
329
+ openAddApiKey()
330
+ return
331
+ }
332
+ openAddOAuthStart()
333
+ }}
334
+ />
335
+ ))
336
+ }
337
+
338
+ // -- Add API key (multi-step: key → baseURL → authHeader → label) ------
339
+ const openAddApiKey = () => {
340
+ const collected: {
341
+ apiKey?: string
342
+ baseURL?: string
343
+ authHeader?: string
344
+ label?: string
345
+ } = {}
346
+
347
+ const openApiKeyPrompt = () => {
348
+ const DialogPrompt = api.ui.DialogPrompt
349
+ api.ui.dialog.setSize('xlarge')
350
+ api.ui.dialog.replace(() => (
351
+ <DialogPrompt
352
+ title='Add API key account \u2014 API key'
353
+ description={() => <text>Paste your API key (required).</text>}
354
+ placeholder='sk-ant-...'
355
+ value=''
356
+ onConfirm={(value: string) => {
357
+ const trimmed = value.trim()
358
+ if (!trimmed) {
359
+ openAddType()
360
+ return
361
+ }
362
+ collected.apiKey = trimmed
363
+ openBaseURLPrompt()
364
+ }}
365
+ onCancel={() => openAddType()}
366
+ />
367
+ ))
368
+ }
369
+
370
+ const openBaseURLPrompt = () => {
371
+ const DialogPrompt = api.ui.DialogPrompt
372
+ api.ui.dialog.setSize('xlarge')
373
+ api.ui.dialog.replace(() => (
374
+ <DialogPrompt
375
+ title='Add API key account \u2014 base URL'
376
+ description={() => (
377
+ <text>
378
+ Anthropic-compatible API base URL. Default:
379
+ https://api.kie.ai/claude
380
+ </text>
381
+ )}
382
+ placeholder='https://api.kie.ai/claude'
383
+ value=''
384
+ onConfirm={(value: string) => {
385
+ const trimmed = value.trim()
386
+ collected.baseURL = trimmed || 'https://api.kie.ai/claude'
387
+ openAuthHeaderSelect()
388
+ }}
389
+ onCancel={() => openApiKeyPrompt()}
390
+ />
391
+ ))
392
+ }
393
+
394
+ const openAuthHeaderSelect = () => {
395
+ const DialogSelect = api.ui.DialogSelect<string>
396
+ api.ui.dialog.setSize('xlarge')
397
+ api.ui.dialog.replace(() => (
398
+ <DialogSelect
399
+ title='Add API key account \u2014 auth header'
400
+ options={[
401
+ {
402
+ title: 'Authorization: Bearer (default)',
403
+ value: 'authorization-bearer',
404
+ description: 'Standard bearer token authentication',
405
+ },
406
+ {
407
+ title: 'X-API-Key',
408
+ value: 'x-api-key',
409
+ description: 'Custom header-based API key',
410
+ },
411
+ ]}
412
+ onSelect={(option) => {
413
+ collected.authHeader = option.value as
414
+ | 'authorization-bearer'
415
+ | 'x-api-key'
416
+ openLabelPrompt()
417
+ }}
418
+ />
419
+ ))
420
+ }
421
+
422
+ const openLabelPrompt = () => {
423
+ const DialogPrompt = api.ui.DialogPrompt
424
+ api.ui.dialog.setSize('xlarge')
425
+ api.ui.dialog.replace(() => (
426
+ <DialogPrompt
427
+ title='Add API key account \u2014 label'
428
+ description={() => (
429
+ <text>A short name for this account (optional).</text>
430
+ )}
431
+ placeholder='e.g. Work API'
432
+ value=''
433
+ onConfirm={(value: string) => {
434
+ const trimmed = value.trim()
435
+ collected.label = trimmed || undefined
436
+ const apiKey = collected.apiKey
437
+ if (!apiKey) return
438
+ let args = `add-apikey ${apiKey}`
439
+ if (
440
+ collected.baseURL &&
441
+ collected.baseURL !== 'https://api.kie.ai/claude'
442
+ ) {
443
+ args += ` --base-url ${collected.baseURL}`
444
+ }
445
+ if (
446
+ collected.authHeader &&
447
+ collected.authHeader !== 'authorization-bearer'
448
+ ) {
449
+ args += ` --auth-header ${collected.authHeader}`
450
+ }
451
+ if (collected.label) {
452
+ args += ` --label ${collected.label}`
453
+ }
454
+ void apply('claude-account', args).then((r) => {
455
+ api.ui.toast({ message: r.text })
456
+ updateAccounts(r)
457
+ buildL1()
458
+ })
459
+ }}
460
+ onCancel={() => openAuthHeaderSelect()}
461
+ />
462
+ ))
463
+ }
464
+
465
+ openApiKeyPrompt()
466
+ }
467
+
468
+ // -- Add OAuth (OSC-52 copy + code entry) ------------------------------
469
+ const openAddOAuthStart = () => {
470
+ void apply('claude-account', 'add-oauth-start').then((r) => {
471
+ const oauthUrl = r.knobs.oauthUrl as string | undefined
472
+ updateAccounts(r)
473
+ if (oauthUrl) {
474
+ openOAuthUrlScreen(oauthUrl)
475
+ } else {
476
+ api.ui.toast({ message: r.text })
477
+ buildL1()
478
+ }
479
+ })
480
+ }
481
+
482
+ const openOAuthUrlScreen = (oauthUrl: string) => {
483
+ const DialogSelect = api.ui.DialogSelect<string>
484
+ api.ui.dialog.setSize('xlarge')
485
+ api.ui.dialog.replace(() => (
486
+ <DialogSelect
487
+ title='OAuth sign-in'
488
+ options={[
489
+ {
490
+ title: 'Copy URL to clipboard',
491
+ value: 'copy',
492
+ description: oauthUrl,
493
+ },
494
+ {
495
+ title: 'Enter sign-in code',
496
+ value: 'code',
497
+ description:
498
+ 'Open the URL in your browser, sign in, then paste the callback URL or code',
499
+ },
500
+ { title: 'Cancel', value: 'cancel' },
501
+ ]}
502
+ onSelect={(option) => {
503
+ if (option.value === 'cancel') {
504
+ buildL1()
505
+ return
506
+ }
507
+ if (option.value === 'copy') {
508
+ const ok = api.renderer.copyToClipboardOSC52(oauthUrl)
509
+ if (ok) {
510
+ api.ui.toast({ message: 'URL copied to clipboard' })
511
+ } else {
512
+ api.ui.toast({
513
+ message:
514
+ 'Copy unavailable \u2014 select the URL text above to copy',
515
+ })
516
+ }
517
+ openOAuthUrlScreen(oauthUrl)
518
+ return
519
+ }
520
+ openOAuthCodePrompt(oauthUrl)
521
+ }}
522
+ />
523
+ ))
524
+ }
525
+
526
+ const openOAuthCodePrompt = (oauthUrl: string) => {
527
+ const DialogPrompt = api.ui.DialogPrompt
528
+ api.ui.dialog.setSize('xlarge')
529
+ api.ui.dialog.replace(() => (
530
+ <DialogPrompt
531
+ title='OAuth sign-in \u2014 enter code'
532
+ description={() => (
533
+ <text>
534
+ After signing in you will be redirected. Paste the full callback
535
+ URL or authorization code below.
536
+ </text>
537
+ )}
538
+ placeholder='Paste callback URL or code here'
539
+ value=''
540
+ onConfirm={(value: string) => {
541
+ const trimmed = value.trim()
542
+ if (!trimmed) {
543
+ buildL1()
544
+ return
545
+ }
546
+ openOAuthLabelPrompt(trimmed, oauthUrl)
547
+ }}
548
+ // Step BACK to the sign-in URL screen, not L1: the OAuth session
549
+ // (PKCE verifier/state) is already minted. Returning to L1 would let a
550
+ // retry re-run add-oauth-start and re-mint it, invalidating the URL the
551
+ // user is mid-sign-in with. Same session → same URL is preserved.
552
+ onCancel={() => openOAuthUrlScreen(oauthUrl)}
553
+ />
554
+ ))
555
+ }
556
+
557
+ const openOAuthLabelPrompt = (code: string, oauthUrl: string) => {
558
+ const DialogPrompt = api.ui.DialogPrompt
559
+ api.ui.dialog.setSize('xlarge')
560
+ api.ui.dialog.replace(() => (
561
+ <DialogPrompt
562
+ title='OAuth sign-in \u2014 label'
563
+ description={() => (
564
+ <text>A short name for this account (optional).</text>
565
+ )}
566
+ placeholder='e.g. work'
567
+ value=''
568
+ onConfirm={(value: string) => {
569
+ const label = value.trim()
570
+ const args = label
571
+ ? `add-oauth-finish ${code} --label ${label}`
572
+ : `add-oauth-finish ${code}`
573
+ void apply('claude-account', args).then((r) => {
574
+ api.ui.toast({ message: r.text })
575
+ updateAccounts(r)
576
+ buildL1()
577
+ })
578
+ }}
579
+ // Step BACK to the code prompt, not L1: the user already obtained an
580
+ // auth code to reach this step. Returning to L1 would let a retry
581
+ // re-run add-oauth-start, minting a new PKCE verifier/state that
582
+ // invalidates the code they already have and forces a full re-auth.
583
+ onCancel={() => openOAuthCodePrompt(oauthUrl)}
584
+ />
585
+ ))
586
+ }
587
+
588
+ // -- Manage existing account -------------------------------------------
589
+ const openManage = (
590
+ account: (typeof accounts)[number],
591
+ isMain: boolean,
592
+ ) => {
593
+ const DialogSelect = api.ui.DialogSelect<string>
594
+ const DialogConfirm = api.ui.DialogConfirm
595
+ api.ui.dialog.setSize('xlarge')
596
+
597
+ const options: Array<{
598
+ title: string
599
+ value: string
600
+ description?: string
601
+ }> = []
602
+ if (!isMain) {
603
+ const toggleLabel = account.enabled ? 'Disable' : 'Enable'
604
+ options.push({
605
+ title: toggleLabel,
606
+ value: account.enabled ? 'disable' : 'enable',
607
+ description: account.enabled
608
+ ? 'Stop using this fallback account'
609
+ : 'Allow this fallback account to be used',
610
+ })
611
+ options.push({
612
+ title: 'Move up',
613
+ value: 'move-up',
614
+ description: 'Higher priority in fallback order',
615
+ })
616
+ options.push({
617
+ title: 'Move down',
618
+ value: 'move-down',
619
+ description: 'Lower priority in fallback order',
620
+ })
621
+ options.push({
622
+ title: 'Remove\u2026',
623
+ value: 'remove',
624
+ description: 'Delete this account permanently',
625
+ })
626
+ }
627
+ options.push({ title: 'Back', value: 'back' })
628
+
629
+ api.ui.dialog.replace(() => (
630
+ <DialogSelect
631
+ title={`Manage ${account.label}`}
632
+ options={options}
633
+ onSelect={(option) => {
634
+ if (option.value === 'back') {
635
+ buildL1()
636
+ return
637
+ }
638
+
639
+ if (option.value === 'remove') {
640
+ api.ui.dialog.replace(() => (
641
+ <DialogConfirm
642
+ title={`Remove ${account.label}?`}
643
+ message={`Are you sure you want to remove the fallback account "${account.label}"?`}
644
+ onConfirm={() => {
645
+ void apply('claude-account', `remove ${account.id}`).then(
646
+ (r) => {
647
+ api.ui.toast({ message: r.text })
648
+ updateAccounts(r)
649
+ buildL1()
650
+ },
651
+ )
652
+ }}
653
+ onCancel={() => openManage(account, isMain)}
654
+ />
655
+ ))
656
+ return
657
+ }
658
+
659
+ void apply('claude-account', `${option.value} ${account.id}`).then(
660
+ (r) => {
661
+ api.ui.toast({ message: r.text })
662
+ updateAccounts(r)
663
+ const updatedList = r.knobs.accounts as typeof accounts
664
+ const refreshed =
665
+ (updatedList && updatedList.length > 0
666
+ ? updatedList.find((a) => a.id === account.id)
667
+ : undefined) ?? account
668
+ openManage(refreshed, isMain)
669
+ },
670
+ )
671
+ }}
672
+ />
673
+ ))
674
+ }
675
+
676
+ buildL1()
677
+ return
678
+ }
679
+
680
+ if (payload.command === 'claude-logging') {
681
+ const current = (payload.knobs.level as string) ?? 'info'
682
+ const levels = ['error', 'warn', 'info', 'debug', 'trace']
683
+ const DialogSelect = api.ui.DialogSelect<string>
684
+ api.ui.dialog.setSize('xlarge')
685
+ api.ui.dialog.replace(() => (
686
+ <DialogSelect
687
+ title='Claude log level'
688
+ current={current}
689
+ options={levels.map((level) => ({
690
+ title: level === current ? `\u2022 ${level}` : level,
691
+ value: level,
692
+ }))}
693
+ onSelect={(option) => {
694
+ void apply('claude-logging', String(option.value)).then((r) => {
695
+ api.ui.toast({ message: r.text })
696
+ api.ui.dialog.clear()
697
+ })
698
+ }}
699
+ />
700
+ ))
701
+ return
702
+ }
703
+
227
704
  // fallback for quota (display-only)
228
705
  showText(api, payload.text)
229
706
  }
@@ -272,6 +272,7 @@ export function queueTuiPreferenceUpdate(
272
272
  }
273
273
 
274
274
  const WATCH_DEBOUNCE_MS = 150
275
+ const WATCH_POLL_MS = 100
275
276
 
276
277
  // Watches the directory rather than the file: editors and our own atomic
277
278
  // writes replace the file via rename, which kills file-level watchers.
@@ -296,14 +297,35 @@ export function watchTuiPreferences(onChange: () => void): () => void {
296
297
  const name = basename(file)
297
298
  let timer: ReturnType<typeof nativeSetTimeout> | null = null
298
299
  let lastSeen: string | null = null
299
- // Seed asynchronously; a real change that fires before the seed resolves
300
- // still wins because the debounce re-reads the file fresh and compares
301
- // against `lastSeen` (which will be `null` → does not match → fires).
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.
302
323
  void readFile(file, 'utf8')
303
324
  .then((text) => {
304
- if (lastSeen === null) lastSeen = text
325
+ if (!observedEvent && lastSeen === null) lastSeen = text
305
326
  })
306
327
  .catch(() => {})
328
+
307
329
  try {
308
330
  const watcher = watch(dirname(file), (_event, filename) => {
309
331
  // Exact match against the preferences file name, plus the temp file
@@ -312,21 +334,34 @@ export function watchTuiPreferences(onChange: () => void): () => void {
312
334
  filename === name ||
313
335
  (filename?.startsWith(`${name}.`) && filename.endsWith('.tmp'))
314
336
  if (filename != null && !isOurs) return
315
- if (timer) nativeClearTimeout(timer)
316
- timer = nativeSetTimeout(() => {
317
- timer = null
318
- void readFile(file, 'utf8')
319
- .catch(() => null)
320
- .then((text) => {
321
- if (text === null) return
322
- if (text === lastSeen) return
323
- lastSeen = text
324
- onChange()
325
- })
326
- }, WATCH_DEBOUNCE_MS)
337
+ scheduleCheck()
327
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
+
328
361
  return () => {
362
+ disposed = true
329
363
  if (timer) nativeClearTimeout(timer)
364
+ if (pollTimer) nativeClearTimeout(pollTimer)
330
365
  watcher.close()
331
366
  }
332
367
  } catch {