ultraclaude-agent 0.0.19 → 0.0.21

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,597 @@
1
+ // Claude Code credential profile management
2
+ // Manages named profiles for Claude Code's OAuth credentials (~/.claude/.credentials.json)
3
+
4
+ import { homedir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { readFile, writeFile, rename, mkdir, unlink, readdir, stat } from 'node:fs/promises';
7
+ import { spawn, execFileSync } from 'node:child_process';
8
+ import { logger } from './logger.js';
9
+ import type {
10
+ ClaudeOAuthCredentials,
11
+ ClaudeProfile,
12
+ ClaudeProfileActive,
13
+ ClaudeAuthIdentity,
14
+ } from '@ultra-claude/shared';
15
+
16
+ // --- Paths ---
17
+
18
+ const CLAUDE_DIR = join(homedir(), '.claude');
19
+ const CREDENTIALS_FILE = join(CLAUDE_DIR, '.credentials.json');
20
+ const PROFILES_DIR = join(homedir(), '.claude', 'ultra', 'claude-profiles');
21
+ const BACKUPS_DIR = join(PROFILES_DIR, 'backups');
22
+ const ACTIVE_FILE = join(PROFILES_DIR, 'active.json');
23
+
24
+ export { CREDENTIALS_FILE, PROFILES_DIR, BACKUPS_DIR, ACTIVE_FILE };
25
+
26
+ // --- Self-trigger flag ---
27
+ // Set before switch-initiated credential writes, checked by the daemon watcher
28
+ // to skip processing for self-initiated changes.
29
+
30
+ let selfTriggered = false;
31
+
32
+ export function setSelfTriggerFlag(): void {
33
+ selfTriggered = true;
34
+ }
35
+
36
+ export function clearSelfTriggerFlag(): void {
37
+ selfTriggered = false;
38
+ }
39
+
40
+ export function isSelfTriggered(): boolean {
41
+ return selfTriggered;
42
+ }
43
+
44
+ // --- Atomic write helper ---
45
+
46
+ async function atomicWriteJson(filePath: string, data: unknown): Promise<void> {
47
+ const dir = join(filePath, '..');
48
+ await mkdir(dir, { recursive: true, mode: 0o700 });
49
+ const tmpFile = `${filePath}.tmp`;
50
+ await writeFile(tmpFile, JSON.stringify(data, null, 2), { encoding: 'utf8', mode: 0o600 });
51
+ await rename(tmpFile, filePath);
52
+ }
53
+
54
+ // --- JSON read helper ---
55
+
56
+ async function readJsonFile<T>(filePath: string): Promise<T | null> {
57
+ try {
58
+ const content = await readFile(filePath, 'utf8');
59
+ if (!content.trim()) return null;
60
+ return JSON.parse(content) as T;
61
+ } catch (e: unknown) {
62
+ if ((e as NodeJS.ErrnoException).code === 'ENOENT') return null;
63
+ logger.warn({ err: e, filePath }, 'Failed to read/parse JSON file');
64
+ return null;
65
+ }
66
+ }
67
+
68
+ // --- Claude CLI detection ---
69
+
70
+ function findClaudeBinary(): string | null {
71
+ try {
72
+ return execFileSync('which', ['claude'], {
73
+ encoding: 'utf8',
74
+ stdio: 'pipe',
75
+ }).trim();
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ // --- Identity resolution ---
82
+
83
+ /**
84
+ * Run `claude auth status` to get the current account identity.
85
+ * Tries JSON output first, falls back to text parsing.
86
+ */
87
+ export async function runClaudeAuthStatus(): Promise<ClaudeAuthIdentity | null> {
88
+ const log = logger.child({ op: 'claudeAuthStatus' });
89
+
90
+ if (!findClaudeBinary()) {
91
+ log.error('Claude CLI not found in PATH. Install from https://docs.anthropic.com/en/docs/claude-code');
92
+ return null;
93
+ }
94
+
95
+ return new Promise((resolve) => {
96
+ const child = spawn('claude', ['auth', 'status'], { stdio: 'pipe' });
97
+ let stdout = '';
98
+ let stderr = '';
99
+
100
+ child.stdout!.on('data', (d: Buffer) => { stdout += d.toString(); });
101
+ child.stderr!.on('data', (d: Buffer) => { stderr += d.toString(); });
102
+
103
+ const timer = setTimeout(() => {
104
+ child.kill('SIGTERM');
105
+ log.error('claude auth status timed out');
106
+ resolve(null);
107
+ }, 10_000);
108
+
109
+ child.on('error', (err) => {
110
+ clearTimeout(timer);
111
+ log.error({ err }, 'Failed to spawn claude auth status');
112
+ resolve(null);
113
+ });
114
+
115
+ child.on('close', (code) => {
116
+ clearTimeout(timer);
117
+
118
+ if (code !== 0) {
119
+ log.warn({ code, stderr }, 'claude auth status exited with non-zero code');
120
+ // Still try to parse — some versions return non-zero when not logged in
121
+ }
122
+
123
+ const combined = stdout + stderr;
124
+
125
+ // Try JSON parse first (if --json flag was used or output is JSON)
126
+ try {
127
+ const json = JSON.parse(combined.trim());
128
+ const email = json.email ?? json.primaryEmail ?? json.emailAddress ?? null;
129
+ if (email) {
130
+ resolve({
131
+ email,
132
+ orgName: json.orgName ?? json.org_name ?? '',
133
+ subscriptionType: json.subscriptionType ?? json.subscription_type ?? '',
134
+ loggedIn: json.loggedIn ?? json.logged_in ?? true,
135
+ });
136
+ return;
137
+ }
138
+ } catch {
139
+ // Not JSON — fall through to text parsing
140
+ }
141
+
142
+ // Text parsing fallback
143
+ const identity = parseAuthStatusText(combined);
144
+ if (identity) {
145
+ resolve(identity);
146
+ } else {
147
+ log.warn({ output: combined.slice(0, 500) }, 'Could not parse claude auth status output');
148
+ resolve(null);
149
+ }
150
+ });
151
+ });
152
+ }
153
+
154
+ /**
155
+ * Parse text output from `claude auth status`.
156
+ * Handles various output formats:
157
+ * "Logged in as: user@example.com"
158
+ * "Email: user@example.com"
159
+ * "Org: MyOrg"
160
+ * "Subscription: team"
161
+ */
162
+ function parseAuthStatusText(text: string): ClaudeAuthIdentity | null {
163
+ const emailMatch = text.match(/(?:Logged in as|Email)[:\s]+(\S+@\S+)/i);
164
+ if (!emailMatch) return null;
165
+
166
+ const orgMatch = text.match(/Org(?:anization)?[:\s]+(.+)/i);
167
+ const subMatch = text.match(/Subscription[:\s]+(\S+)/i);
168
+ const notLoggedIn = /not logged in/i.test(text);
169
+
170
+ return {
171
+ email: emailMatch[1]!,
172
+ orgName: orgMatch?.[1]?.trim() ?? '',
173
+ subscriptionType: subMatch?.[1]?.trim() ?? '',
174
+ loggedIn: !notLoggedIn,
175
+ };
176
+ }
177
+
178
+ // --- Profile CRUD ---
179
+
180
+ export function profilePath(name: string): string {
181
+ return join(PROFILES_DIR, `${name}.json`);
182
+ }
183
+
184
+ export async function loadProfile(name: string): Promise<ClaudeProfile | null> {
185
+ return readJsonFile<ClaudeProfile>(profilePath(name));
186
+ }
187
+
188
+ export async function saveProfile(profile: ClaudeProfile): Promise<void> {
189
+ await atomicWriteJson(profilePath(profile.name), profile);
190
+ }
191
+
192
+ export async function deleteProfileFile(name: string): Promise<boolean> {
193
+ try {
194
+ await unlink(profilePath(name));
195
+ return true;
196
+ } catch (e: unknown) {
197
+ if ((e as NodeJS.ErrnoException).code === 'ENOENT') return false;
198
+ throw e;
199
+ }
200
+ }
201
+
202
+ export async function listProfiles(): Promise<ClaudeProfile[]> {
203
+ const profiles: ClaudeProfile[] = [];
204
+ try {
205
+ const entries = await readdir(PROFILES_DIR);
206
+ for (const entry of entries) {
207
+ if (!entry.endsWith('.json') || entry === 'active.json') continue;
208
+ const profile = await readJsonFile<ClaudeProfile>(join(PROFILES_DIR, entry));
209
+ if (profile?.name) {
210
+ profiles.push(profile);
211
+ }
212
+ }
213
+ } catch (e: unknown) {
214
+ if ((e as NodeJS.ErrnoException).code !== 'ENOENT') {
215
+ logger.warn({ err: e }, 'Failed to list profiles');
216
+ }
217
+ }
218
+ return profiles.sort((a, b) => a.name.localeCompare(b.name));
219
+ }
220
+
221
+ // --- Active profile tracking ---
222
+
223
+ export async function loadActiveProfile(): Promise<ClaudeProfileActive | null> {
224
+ return readJsonFile<ClaudeProfileActive>(ACTIVE_FILE);
225
+ }
226
+
227
+ export async function setActiveProfile(name: string): Promise<void> {
228
+ const active: ClaudeProfileActive = {
229
+ profile: name,
230
+ switchedAt: new Date().toISOString(),
231
+ };
232
+ await atomicWriteJson(ACTIVE_FILE, active);
233
+ }
234
+
235
+ export async function clearActiveProfile(): Promise<void> {
236
+ try {
237
+ await unlink(ACTIVE_FILE);
238
+ } catch (e: unknown) {
239
+ if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e;
240
+ }
241
+ }
242
+
243
+ // --- Credential file I/O ---
244
+
245
+ export async function readCredentialsFile(): Promise<ClaudeOAuthCredentials | null> {
246
+ return readJsonFile<ClaudeOAuthCredentials>(CREDENTIALS_FILE);
247
+ }
248
+
249
+ /**
250
+ * Write credentials to ~/.claude/.credentials.json using atomic temp+rename.
251
+ * Pure write — does NOT manage the self-trigger flag. Callers that need to
252
+ * suppress watcher processing (e.g., switchProfile) must call setSelfTriggerFlag()
253
+ * themselves before calling this function.
254
+ */
255
+ export async function writeCredentialsFile(credentials: ClaudeOAuthCredentials): Promise<void> {
256
+ await atomicWriteJson(CREDENTIALS_FILE, credentials);
257
+ }
258
+
259
+ // --- Backup management ---
260
+
261
+ export async function createBackup(credentials: ClaudeOAuthCredentials): Promise<string> {
262
+ await mkdir(BACKUPS_DIR, { recursive: true, mode: 0o700 });
263
+ const timestamp = new Date().toISOString().replace(/:/g, '-').replace(/\.\d+Z$/, 'Z');
264
+ const backupPath = join(BACKUPS_DIR, `${timestamp}.json`);
265
+ await writeFile(backupPath, JSON.stringify(credentials, null, 2), { encoding: 'utf8', mode: 0o600 });
266
+ return backupPath;
267
+ }
268
+
269
+ export async function pruneBackups(maxAgeDays = 7): Promise<number> {
270
+ const log = logger.child({ op: 'pruneBackups' });
271
+ const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000;
272
+ const cutoff = Date.now() - maxAgeMs;
273
+ let pruned = 0;
274
+
275
+ try {
276
+ const entries = await readdir(BACKUPS_DIR);
277
+ for (const entry of entries) {
278
+ if (!entry.endsWith('.json')) continue;
279
+ const filePath = join(BACKUPS_DIR, entry);
280
+ try {
281
+ const fileStat = await stat(filePath);
282
+ if (fileStat.mtimeMs < cutoff) {
283
+ await unlink(filePath);
284
+ pruned++;
285
+ }
286
+ } catch (statErr: unknown) {
287
+ log.debug({ err: statErr, filePath }, 'Could not stat backup file — skipping');
288
+ }
289
+ }
290
+ if (pruned > 0) {
291
+ log.info({ pruned }, `Pruned ${pruned} old backup(s)`);
292
+ }
293
+ } catch (e: unknown) {
294
+ if ((e as NodeJS.ErrnoException).code !== 'ENOENT') {
295
+ log.warn({ err: e }, 'Failed to prune backups');
296
+ }
297
+ }
298
+
299
+ return pruned;
300
+ }
301
+
302
+ // --- Token expiry status ---
303
+
304
+ export interface ExpiryStatus {
305
+ label: string;
306
+ status: 'valid' | 'expiring' | 'expired';
307
+ }
308
+
309
+ export function getExpiryStatus(profile: ClaudeProfile): ExpiryStatus {
310
+ const expiresAt = profile.credentials.claudeAiOauth?.expiresAt;
311
+ if (!expiresAt) return { label: '? unknown', status: 'valid' };
312
+
313
+ const now = Date.now();
314
+ const diff = expiresAt - now;
315
+
316
+ if (diff < 0) {
317
+ return { label: '\u2717 expired', status: 'expired' };
318
+ }
319
+ if (diff < 2 * 60 * 60 * 1000) {
320
+ const minutes = Math.round(diff / 60_000);
321
+ if (minutes < 60) {
322
+ return { label: `\u26a0 expires in ${minutes}m`, status: 'expiring' };
323
+ }
324
+ const hours = Math.round(minutes / 60);
325
+ return { label: `\u26a0 expires in ${hours}h`, status: 'expiring' };
326
+ }
327
+
328
+ return { label: '\u2713 valid', status: 'valid' };
329
+ }
330
+
331
+ // --- High-level operations ---
332
+
333
+ /**
334
+ * Switch to a named credential profile.
335
+ * Atomically writes the profile's credentials to ~/.claude/.credentials.json,
336
+ * updates active.json, then runs `claude auth status` to verify and refresh tokens.
337
+ */
338
+ export async function switchProfile(name: string): Promise<{ success: boolean; message: string }> {
339
+ const log = logger.child({ op: 'switchProfile', name });
340
+
341
+ const profile = await loadProfile(name);
342
+ if (!profile) {
343
+ return { success: false, message: `Profile "${name}" not found` };
344
+ }
345
+
346
+ // Set self-trigger flag so the daemon watcher skips this write
347
+ setSelfTriggerFlag();
348
+ log.info({ email: profile.email }, 'Switching to profile');
349
+ await writeCredentialsFile(profile.credentials);
350
+ await setActiveProfile(name);
351
+
352
+ // Verify and potentially refresh tokens via auth status
353
+ const identity = await runClaudeAuthStatus();
354
+ if (!identity || !identity.loggedIn) {
355
+ log.warn('Auth status verification failed after switch — profile may need re-login');
356
+ return {
357
+ success: false,
358
+ message: `Switched to "${name}" but verification failed. Tokens may be expired — run \`claude login ${name}\` to re-authenticate.`,
359
+ };
360
+ }
361
+
362
+ // Update profile with potentially refreshed credentials and identity
363
+ const freshCredentials = await readCredentialsFile();
364
+ if (freshCredentials) {
365
+ const updatedProfile: ClaudeProfile = {
366
+ ...profile,
367
+ email: identity.email,
368
+ orgName: identity.orgName,
369
+ subscriptionType: identity.subscriptionType,
370
+ savedAt: new Date().toISOString(),
371
+ credentials: freshCredentials,
372
+ };
373
+ await saveProfile(updatedProfile);
374
+ }
375
+
376
+ return {
377
+ success: true,
378
+ message: `Switched to "${name}" (${identity.email})`,
379
+ };
380
+ }
381
+
382
+ /**
383
+ * Managed login flow: spawn `claude auth login`, wait for completion,
384
+ * capture identity via `claude auth status`, save as named profile.
385
+ */
386
+ export async function loginProfile(
387
+ name: string,
388
+ email?: string,
389
+ ): Promise<{ success: boolean; message: string }> {
390
+ const log = logger.child({ op: 'loginProfile', name });
391
+
392
+ if (!findClaudeBinary()) {
393
+ return {
394
+ success: false,
395
+ message: 'Claude CLI not found in PATH. Install from https://docs.anthropic.com/en/docs/claude-code',
396
+ };
397
+ }
398
+
399
+ // Backup current credentials before login
400
+ const currentCreds = await readCredentialsFile();
401
+ if (currentCreds) {
402
+ await createBackup(currentCreds);
403
+ }
404
+
405
+ // Build args — add --email if profile already has one or caller provides it
406
+ const args = ['auth', 'login'];
407
+ const existingProfile = await loadProfile(name);
408
+ const loginEmail = email ?? existingProfile?.email;
409
+ if (loginEmail) {
410
+ args.push('--email', loginEmail);
411
+ }
412
+
413
+ log.info({ email: loginEmail }, 'Starting managed login');
414
+
415
+ // Spawn interactive login
416
+ const exitCode = await new Promise<number>((resolve, reject) => {
417
+ const child = spawn('claude', args, { stdio: 'inherit' });
418
+
419
+ const timer = setTimeout(() => {
420
+ child.kill('SIGTERM');
421
+ reject(new Error('Login timed out after 2 minutes'));
422
+ }, 120_000);
423
+
424
+ child.on('error', (err) => {
425
+ clearTimeout(timer);
426
+ reject(err);
427
+ });
428
+
429
+ child.on('close', (code) => {
430
+ clearTimeout(timer);
431
+ resolve(code ?? 1);
432
+ });
433
+ });
434
+
435
+ if (exitCode !== 0) {
436
+ return { success: false, message: `Login process exited with code ${exitCode}` };
437
+ }
438
+
439
+ // Capture identity
440
+ const identity = await runClaudeAuthStatus();
441
+ if (!identity || !identity.loggedIn) {
442
+ return { success: false, message: 'Login completed but could not verify identity' };
443
+ }
444
+
445
+ // Read new credentials and save profile
446
+ const credentials = await readCredentialsFile();
447
+ if (!credentials) {
448
+ return { success: false, message: 'Login completed but could not read credentials file' };
449
+ }
450
+
451
+ const profile: ClaudeProfile = {
452
+ name,
453
+ email: identity.email,
454
+ orgName: identity.orgName,
455
+ subscriptionType: identity.subscriptionType,
456
+ savedAt: new Date().toISOString(),
457
+ credentials,
458
+ };
459
+
460
+ await saveProfile(profile);
461
+ await setActiveProfile(name);
462
+
463
+ log.info({ email: identity.email, org: identity.orgName }, 'Login complete, profile saved');
464
+
465
+ return {
466
+ success: true,
467
+ message: `Logged in and saved as "${name}" (${identity.email}, ${identity.orgName})`,
468
+ };
469
+ }
470
+
471
+ /**
472
+ * Save current credentials as a named profile without login.
473
+ */
474
+ export async function saveCurrentAsProfile(name: string): Promise<{ success: boolean; message: string }> {
475
+ const credentials = await readCredentialsFile();
476
+ if (!credentials) {
477
+ return { success: false, message: 'No credentials file found at ~/.claude/.credentials.json' };
478
+ }
479
+
480
+ const identity = await runClaudeAuthStatus();
481
+ if (!identity) {
482
+ return { success: false, message: 'Could not determine account identity — is Claude CLI installed?' };
483
+ }
484
+
485
+ const profile: ClaudeProfile = {
486
+ name,
487
+ email: identity.email,
488
+ orgName: identity.orgName,
489
+ subscriptionType: identity.subscriptionType,
490
+ savedAt: new Date().toISOString(),
491
+ credentials,
492
+ };
493
+
494
+ await saveProfile(profile);
495
+ await setActiveProfile(name);
496
+
497
+ return {
498
+ success: true,
499
+ message: `Saved current credentials as "${name}" (${identity.email})`,
500
+ };
501
+ }
502
+
503
+ /**
504
+ * Delete a named profile. Warns if it's the active profile.
505
+ */
506
+ export async function deleteProfileByName(name: string): Promise<{ success: boolean; message: string; wasActive: boolean }> {
507
+ const profile = await loadProfile(name);
508
+ if (!profile) {
509
+ return { success: false, message: `Profile "${name}" not found`, wasActive: false };
510
+ }
511
+
512
+ const active = await loadActiveProfile();
513
+ const wasActive = active?.profile === name;
514
+
515
+ await deleteProfileFile(name);
516
+
517
+ if (wasActive) {
518
+ await clearActiveProfile();
519
+ }
520
+
521
+ return {
522
+ success: true,
523
+ message: wasActive
524
+ ? `Deleted profile "${name}" (was active — no profile is now active)`
525
+ : `Deleted profile "${name}"`,
526
+ wasActive,
527
+ };
528
+ }
529
+
530
+ /**
531
+ * Get current Claude auth status for display.
532
+ */
533
+ export async function getClaudeStatus(): Promise<{ success: boolean; message: string; identity?: ClaudeAuthIdentity }> {
534
+ const identity = await runClaudeAuthStatus();
535
+ if (!identity) {
536
+ return { success: false, message: 'Could not get Claude auth status — is Claude CLI installed and in PATH?' };
537
+ }
538
+
539
+ return { success: true, message: '', identity };
540
+ }
541
+
542
+ // --- Credential watcher handler (called from daemon.ts) ---
543
+
544
+ /**
545
+ * Handle an external credential file change detected by the daemon watcher.
546
+ * Creates a backup, identifies the account, and updates the matching profile.
547
+ */
548
+ export async function handleCredentialChange(): Promise<void> {
549
+ const log = logger.child({ op: 'credentialChange' });
550
+
551
+ // Check self-trigger flag
552
+ if (isSelfTriggered()) {
553
+ clearSelfTriggerFlag();
554
+ log.debug('Skipping self-triggered credential change');
555
+ return;
556
+ }
557
+
558
+ // Read the new credentials
559
+ const credentials = await readCredentialsFile();
560
+ if (!credentials) {
561
+ log.warn('Credential file changed but could not be read');
562
+ return;
563
+ }
564
+
565
+ // Create backup
566
+ await createBackup(credentials);
567
+
568
+ // Identify the account
569
+ const identity = await runClaudeAuthStatus();
570
+ if (!identity) {
571
+ log.warn('Could not identify account after credential change');
572
+ return;
573
+ }
574
+
575
+ // Compare against saved profiles
576
+ const profiles = await listProfiles();
577
+ const matchingProfile = profiles.find((p) => p.email === identity.email);
578
+
579
+ if (matchingProfile) {
580
+ // Update the matching profile's credentials and savedAt
581
+ const updated: ClaudeProfile = {
582
+ ...matchingProfile,
583
+ savedAt: new Date().toISOString(),
584
+ credentials,
585
+ };
586
+ await saveProfile(updated);
587
+ log.info(
588
+ { profile: matchingProfile.name, email: identity.email },
589
+ `Updated profile "${matchingProfile.name}" with refreshed credentials`,
590
+ );
591
+ } else {
592
+ log.warn(
593
+ { email: identity.email },
594
+ `Unknown Claude account detected: ${identity.email}. Run \`claude save <name>\` to save it.`,
595
+ );
596
+ }
597
+ }