@phnx-labs/agents-cli 1.14.5 → 1.14.6

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.14.6
4
+
5
+ **Fix: OAuth token refresh now persists to Keychain**
6
+
7
+ - Fixed bug where refreshed Claude OAuth tokens were used but never saved back to macOS Keychain
8
+ - Previously, agents-cli would refresh expired tokens on each run but discard them, eventually exhausting the refresh token
9
+ - Now refreshed `accessToken`, `refreshToken`, and `expiresAt` are written back to Keychain after successful refresh
10
+ - Accounts will stay healthy across runs without requiring re-login
11
+
3
12
  ## 1.14.5
4
13
 
5
14
  **Browser: custom binary and Electron app support**
package/dist/lib/usage.js CHANGED
@@ -211,7 +211,7 @@ async function getClaudeUsageInfo(options) {
211
211
  if (!isClaudeUsageOrgMatch(requestedOrgId, liveOrgId)) {
212
212
  return { snapshot: null, error: null };
213
213
  }
214
- const accessToken = await getClaudeAccessToken(oauth);
214
+ const accessToken = await getClaudeAccessToken(oauth, options?.home);
215
215
  if (!accessToken) {
216
216
  return { snapshot: null, error: null };
217
217
  }
@@ -343,9 +343,14 @@ function normalizeClaudeWindow(window, key, label, shortLabel) {
343
343
  windowMinutes: inferWindowMinutes(key),
344
344
  };
345
345
  }
346
+ let warnedNonDarwin = false;
346
347
  /** Load Claude OAuth credentials from the macOS Keychain. */
347
348
  export async function loadClaudeOauth(home) {
348
349
  if (process.platform !== 'darwin') {
350
+ if (!warnedNonDarwin && process.stderr.isTTY) {
351
+ process.stderr.write('[agents] Usage tracking requires macOS Keychain. Skipped on this platform.\n');
352
+ warnedNonDarwin = true;
353
+ }
349
354
  return null;
350
355
  }
351
356
  try {
@@ -374,6 +379,74 @@ export async function loadClaudeOauth(home) {
374
379
  return null;
375
380
  }
376
381
  }
382
+ /**
383
+ * Save Claude OAuth credentials to the macOS Keychain.
384
+ * Reads the existing payload, merges the new OAuth fields, and writes back.
385
+ */
386
+ async function saveClaudeOauth(home, credentials) {
387
+ if (process.platform !== 'darwin') {
388
+ return false;
389
+ }
390
+ try {
391
+ const account = os.userInfo().username;
392
+ const service = getClaudeKeychainService(home);
393
+ // Read existing payload to preserve other fields
394
+ let existingPayload = {};
395
+ try {
396
+ const { stdout } = await execFileAsync('security', [
397
+ 'find-generic-password',
398
+ '-a',
399
+ account,
400
+ '-s',
401
+ service,
402
+ '-w',
403
+ ]);
404
+ existingPayload = JSON.parse(stdout.trim());
405
+ }
406
+ catch {
407
+ // No existing entry, start fresh
408
+ }
409
+ // Merge new credentials into existing payload
410
+ const newPayload = {
411
+ ...existingPayload,
412
+ claudeAiOauth: {
413
+ ...existingPayload.claudeAiOauth,
414
+ accessToken: credentials.accessToken,
415
+ refreshToken: credentials.refreshToken,
416
+ expiresAt: credentials.expiresAt,
417
+ scopes: credentials.scopes ?? existingPayload.claudeAiOauth?.scopes,
418
+ },
419
+ };
420
+ const payloadJson = JSON.stringify(newPayload);
421
+ // Delete existing entry first (security add-generic-password -U can fail)
422
+ try {
423
+ await execFileAsync('security', [
424
+ 'delete-generic-password',
425
+ '-a',
426
+ account,
427
+ '-s',
428
+ service,
429
+ ]);
430
+ }
431
+ catch {
432
+ // Entry might not exist, ignore
433
+ }
434
+ // Add updated entry
435
+ await execFileAsync('security', [
436
+ 'add-generic-password',
437
+ '-a',
438
+ account,
439
+ '-s',
440
+ service,
441
+ '-w',
442
+ payloadJson,
443
+ ]);
444
+ return true;
445
+ }
446
+ catch {
447
+ return false;
448
+ }
449
+ }
377
450
  /**
378
451
  * Derive the Keychain service name for a Claude home directory.
379
452
  * Managed (non-default) homes get a hash suffix for isolation.
@@ -493,8 +566,8 @@ function isCachedUsageWindowFresh(window, capturedAt, now) {
493
566
  }
494
567
  return true;
495
568
  }
496
- /** Obtain a valid access token, refreshing if expired. */
497
- async function getClaudeAccessToken(oauth) {
569
+ /** Obtain a valid access token, refreshing if expired. Saves refreshed tokens to Keychain. */
570
+ async function getClaudeAccessToken(oauth, home) {
498
571
  const accessToken = oauth.accessToken?.trim();
499
572
  if (!accessToken) {
500
573
  return null;
@@ -507,7 +580,12 @@ async function getClaudeAccessToken(oauth) {
507
580
  return null;
508
581
  }
509
582
  const refreshed = await refreshClaudeToken(oauth);
510
- return refreshed?.accessToken?.trim() || null;
583
+ if (!refreshed?.accessToken) {
584
+ return null;
585
+ }
586
+ // Persist refreshed credentials to Keychain so they survive across runs
587
+ await saveClaudeOauth(home, refreshed);
588
+ return refreshed.accessToken.trim();
511
589
  }
512
590
  /** Refresh an expired Claude OAuth access token using the refresh token. */
513
591
  async function refreshClaudeToken(oauth) {
@@ -547,7 +625,7 @@ export async function isClaudeAuthValid(home) {
547
625
  const oauth = await loadClaudeOauth(home);
548
626
  if (!oauth)
549
627
  return false;
550
- const token = await getClaudeAccessToken(oauth);
628
+ const token = await getClaudeAccessToken(oauth, home);
551
629
  return token !== null;
552
630
  }
553
631
  /** Build a User-Agent string for Claude API requests. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.14.5",
3
+ "version": "1.14.6",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",