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.
package/src/cli.ts CHANGED
@@ -23,6 +23,16 @@ import { startDaemon, forkDaemon, getDaemonStatus } from './daemon.js';
23
23
  import { installService, isServiceInstalled, getServiceType } from './service.js';
24
24
  import { initialSync, createSnapshot as createSnapshotOnServer } from './sync.js';
25
25
  import { startRepl } from './repl.js';
26
+ import {
27
+ listProfiles,
28
+ switchProfile,
29
+ loginProfile,
30
+ saveCurrentAsProfile,
31
+ deleteProfileByName,
32
+ getClaudeStatus,
33
+ getExpiryStatus,
34
+ loadActiveProfile,
35
+ } from './claude-profiles.js';
26
36
  import { logger, initMultistreamLogger } from './logger.js';
27
37
  import type { AgentCredentials } from '@ultra-claude/shared';
28
38
 
@@ -506,4 +516,111 @@ program
506
516
 
507
517
  // unlink removed — projects are auto-discovered from ~/.claude/projects/
508
518
 
519
+ // --- claude-profile ---
520
+
521
+ const claudeProfile = program
522
+ .command('claude-profile')
523
+ .description('Manage Claude Code credential profiles');
524
+
525
+ claudeProfile
526
+ .command('list')
527
+ .description('List all saved Claude credential profiles')
528
+ .action(async () => {
529
+ const profiles = await listProfiles();
530
+ if (profiles.length === 0) {
531
+ console.log('No saved Claude profiles.');
532
+ return;
533
+ }
534
+
535
+ const active = await loadActiveProfile();
536
+
537
+ console.log('');
538
+ console.log(
539
+ ' ' +
540
+ 'Name'.padEnd(16) +
541
+ 'Email'.padEnd(30) +
542
+ 'Org'.padEnd(16) +
543
+ 'Subscription'.padEnd(14) +
544
+ 'Active'.padEnd(8) +
545
+ 'Token',
546
+ );
547
+ console.log(' ' + '-'.repeat(90));
548
+
549
+ for (const profile of profiles) {
550
+ const isActive = active?.profile === profile.name;
551
+ const expiry = getExpiryStatus(profile);
552
+ console.log(
553
+ ' ' +
554
+ profile.name.padEnd(16) +
555
+ profile.email.padEnd(30) +
556
+ (profile.orgName || '-').padEnd(16) +
557
+ (profile.subscriptionType || '-').padEnd(14) +
558
+ (isActive ? '*' : '').padEnd(8) +
559
+ expiry.label,
560
+ );
561
+ }
562
+ console.log('');
563
+ });
564
+
565
+ claudeProfile
566
+ .command('switch')
567
+ .argument('<name>', 'Profile name to switch to')
568
+ .description('Switch to a saved Claude credential profile')
569
+ .action(async (name: string) => {
570
+ const result = await switchProfile(name);
571
+ console.log(result.message);
572
+ process.exitCode = result.success ? 0 : 1;
573
+ });
574
+
575
+ claudeProfile
576
+ .command('login')
577
+ .argument('<name>', 'Profile name to save as')
578
+ .option('--email <email>', 'Pre-populate login email')
579
+ .description('Open browser login and save as named profile')
580
+ .action(async (name: string, options: { email?: string }) => {
581
+ const result = await loginProfile(name, options.email);
582
+ console.log(result.message);
583
+ process.exitCode = result.success ? 0 : 1;
584
+ });
585
+
586
+ claudeProfile
587
+ .command('save')
588
+ .argument('<name>', 'Profile name')
589
+ .description('Save current credentials as a named profile')
590
+ .action(async (name: string) => {
591
+ const result = await saveCurrentAsProfile(name);
592
+ console.log(result.message);
593
+ process.exitCode = result.success ? 0 : 1;
594
+ });
595
+
596
+ claudeProfile
597
+ .command('delete')
598
+ .argument('<name>', 'Profile name to delete')
599
+ .description('Delete a saved credential profile')
600
+ .action(async (name: string) => {
601
+ const result = await deleteProfileByName(name);
602
+ if (result.wasActive) {
603
+ console.log(`Warning: "${name}" was the active profile.`);
604
+ }
605
+ console.log(result.message);
606
+ process.exitCode = result.success ? 0 : 1;
607
+ });
608
+
609
+ claudeProfile
610
+ .command('status')
611
+ .description('Show current Claude auth status')
612
+ .action(async () => {
613
+ const result = await getClaudeStatus();
614
+ if (!result.success) {
615
+ console.log(result.message);
616
+ process.exitCode = 1;
617
+ return;
618
+ }
619
+ const id = result.identity!;
620
+ console.log(`Email: ${id.email}`);
621
+ console.log(`Org: ${id.orgName || '-'}`);
622
+ console.log(`Subscription: ${id.subscriptionType || '-'}`);
623
+ console.log(`Logged in: ${id.loggedIn ? 'yes' : 'no'}`);
624
+ });
625
+
509
626
  await program.parseAsync(process.argv);
package/src/daemon.ts CHANGED
@@ -30,6 +30,7 @@ import { startUsageWatcher, type UsageWatcher } from './usage-sync.js';
30
30
  import { startStatusLoop, readStatusFile, removeStatusFile, type StatusLoop, type StatusFileData } from './status.js';
31
31
  import { isServiceActive } from './service.js';
32
32
  import * as socketClient from './socket-client.js';
33
+ import { CREDENTIALS_FILE, handleCredentialChange, pruneBackups } from './claude-profiles.js';
33
34
  import { logger } from './logger.js';
34
35
  import { ok, err, type Result, type AgentCredentials } from '@ultra-claude/shared';
35
36
  import type { ProjectRegistryEntry } from '@ultra-claude/shared';
@@ -92,6 +93,11 @@ let registryWatcher: ReturnType<typeof chokidar.watch> | null = null;
92
93
  let discoveryTimer: ReturnType<typeof setInterval> | null = null;
93
94
  let versionWatcherTimer: ReturnType<typeof setInterval> | null = null;
94
95
  let usageWatcher: UsageWatcher | null = null;
96
+ interface CredentialWatcher {
97
+ close(): Promise<void>;
98
+ }
99
+ let credentialWatcher: CredentialWatcher | null = null;
100
+ let credentialPruneTimer: ReturnType<typeof setInterval> | null = null;
95
101
  let statusLoop: StatusLoop | null = null;
96
102
  let running = false;
97
103
  let daemonStartedAt: string | null = null;
@@ -193,6 +199,19 @@ export async function startDaemon(): Promise<Result<void, StartDaemonError>> {
193
199
  // Start global usage watcher (watches ~/.claude/ultra/ for usage-status.json and accounts/)
194
200
  usageWatcher = startUsageWatcher();
195
201
 
202
+ // Start credential watcher for Claude Code profiles
203
+ credentialWatcher = startCredentialWatcher();
204
+
205
+ // Prune old credential backups on startup, then daily
206
+ pruneBackups().catch((pruneErr: unknown) => {
207
+ log.error({ err: pruneErr }, 'Initial backup prune failed');
208
+ });
209
+ credentialPruneTimer = setInterval(() => {
210
+ pruneBackups().catch((pruneErr: unknown) => {
211
+ log.error({ err: pruneErr }, 'Daily backup prune failed');
212
+ });
213
+ }, 24 * 60 * 60 * 1000);
214
+
196
215
  // Load initial registry and start watchers
197
216
  const registry = await loadRegistry();
198
217
  log.info({ projectCount: registry.projects.length }, 'Registry loaded');
@@ -267,6 +286,16 @@ export async function stopDaemon(): Promise<void> {
267
286
  usageWatcher = null;
268
287
  }
269
288
 
289
+ // Close credential watcher and prune timer
290
+ if (credentialWatcher) {
291
+ await credentialWatcher.close();
292
+ credentialWatcher = null;
293
+ }
294
+ if (credentialPruneTimer) {
295
+ clearInterval(credentialPruneTimer);
296
+ credentialPruneTimer = null;
297
+ }
298
+
270
299
  // Close registry watcher
271
300
  if (registryWatcher) {
272
301
  await registryWatcher.close();
@@ -694,5 +723,56 @@ function startVersionWatcher(serverUrl: string): ReturnType<typeof setInterval>
694
723
  }, 60_000);
695
724
  }
696
725
 
726
+ // --- Credential watcher ---
727
+
728
+ /**
729
+ * Watch ~/.claude/.credentials.json for external changes.
730
+ * Creates timestamped backups, identifies the account, and updates matching profiles.
731
+ * Self-trigger detection skips processing for switch-initiated writes.
732
+ * Returns a lifecycle object with close() that cancels pending debounce timers.
733
+ */
734
+ function startCredentialWatcher(): CredentialWatcher {
735
+ const log = logger.child({ op: 'credentialWatcher' });
736
+
737
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
738
+
739
+ const watcher = chokidar.watch(CREDENTIALS_FILE, {
740
+ persistent: true,
741
+ ignoreInitial: true,
742
+ awaitWriteFinish: {
743
+ stabilityThreshold: 300,
744
+ pollInterval: 100,
745
+ },
746
+ });
747
+
748
+ watcher
749
+ .on('change', () => {
750
+ // Debounce rapid changes
751
+ if (debounceTimer) clearTimeout(debounceTimer);
752
+ debounceTimer = setTimeout(() => {
753
+ debounceTimer = null;
754
+ handleCredentialChange().catch((changeErr: unknown) => {
755
+ log.error({ err: changeErr }, 'Credential change handler failed');
756
+ });
757
+ }, 500);
758
+ })
759
+ .on('error', (watcherErr) => {
760
+ log.error({ err: watcherErr }, 'Credential watcher error');
761
+ });
762
+
763
+ log.info({ path: CREDENTIALS_FILE }, 'Credential watcher started');
764
+
765
+ return {
766
+ async close() {
767
+ if (debounceTimer) {
768
+ clearTimeout(debounceTimer);
769
+ debounceTimer = null;
770
+ }
771
+ await watcher.close();
772
+ log.info('Credential watcher stopped');
773
+ },
774
+ };
775
+ }
776
+
697
777
  /** Expose LOADED_VERSION for use by other modules (e.g., status) */
698
778
  export { LOADED_VERSION };
package/src/repl.ts CHANGED
@@ -23,6 +23,17 @@ import { login } from './auth.js';
23
23
  import { stopDaemon, getDaemonStatus, isRunningInProcess, forkDaemon } from './daemon.js';
24
24
  import { isServiceInstalled } from './service.js';
25
25
  import { initialSync, createSnapshot as createSnapshotOnServer } from './sync.js';
26
+ import {
27
+ listProfiles,
28
+ switchProfile,
29
+ loginProfile,
30
+ saveCurrentAsProfile,
31
+ deleteProfileByName,
32
+ getClaudeStatus,
33
+ getExpiryStatus,
34
+ loadActiveProfile,
35
+ loadProfile,
36
+ } from './claude-profiles.js';
26
37
  import { logger } from './logger.js';
27
38
  import type { AgentCredentials, ProjectRegistryEntry } from '@ultra-claude/shared';
28
39
 
@@ -516,6 +527,130 @@ async function cmdReset(_args: string[], ctx: ReplContext): Promise<void> {
516
527
  ctx.rl.close();
517
528
  }
518
529
 
530
+ async function cmdClaude(args: string[], _ctx: ReplContext): Promise<void> {
531
+ const subcmd = args[0]?.toLowerCase();
532
+ const subArgs = args.slice(1);
533
+
534
+ switch (subcmd) {
535
+ case 'list': {
536
+ const profiles = await listProfiles();
537
+ if (profiles.length === 0) {
538
+ console.log('No saved Claude profiles. Run "claude save <name>" or "claude login <name>" to create one.');
539
+ return;
540
+ }
541
+
542
+ const active = await loadActiveProfile();
543
+
544
+ // Table header
545
+ console.log('');
546
+ console.log(
547
+ ' ' +
548
+ 'Name'.padEnd(16) +
549
+ 'Email'.padEnd(30) +
550
+ 'Org'.padEnd(16) +
551
+ 'Subscription'.padEnd(14) +
552
+ 'Active'.padEnd(8) +
553
+ 'Token',
554
+ );
555
+ console.log(' ' + '-'.repeat(90));
556
+
557
+ for (const profile of profiles) {
558
+ const isActive = active?.profile === profile.name;
559
+ const expiry = getExpiryStatus(profile);
560
+ console.log(
561
+ ' ' +
562
+ profile.name.padEnd(16) +
563
+ profile.email.padEnd(30) +
564
+ (profile.orgName || '-').padEnd(16) +
565
+ (profile.subscriptionType || '-').padEnd(14) +
566
+ (isActive ? '*' : '').padEnd(8) +
567
+ expiry.label,
568
+ );
569
+ }
570
+ console.log('');
571
+ break;
572
+ }
573
+
574
+ case 'switch': {
575
+ const name = subArgs[0];
576
+ if (!name) {
577
+ console.log('Usage: claude switch <name>');
578
+ return;
579
+ }
580
+ const result = await switchProfile(name);
581
+ console.log(result.message);
582
+ break;
583
+ }
584
+
585
+ case 'login': {
586
+ const name = subArgs[0];
587
+ if (!name) {
588
+ console.log('Usage: claude login <name> [--email <email>]');
589
+ return;
590
+ }
591
+ const emailIdx = subArgs.indexOf('--email');
592
+ const email = emailIdx >= 0 ? subArgs[emailIdx + 1] : undefined;
593
+ const result = await loginProfile(name, email);
594
+ console.log(result.message);
595
+ break;
596
+ }
597
+
598
+ case 'save': {
599
+ const name = subArgs[0];
600
+ if (!name) {
601
+ console.log('Usage: claude save <name>');
602
+ return;
603
+ }
604
+ const result = await saveCurrentAsProfile(name);
605
+ console.log(result.message);
606
+ break;
607
+ }
608
+
609
+ case 'delete': {
610
+ const name = subArgs[0];
611
+ if (!name) {
612
+ console.log('Usage: claude delete <name>');
613
+ return;
614
+ }
615
+ const result = await deleteProfileByName(name);
616
+ if (result.wasActive) {
617
+ console.log(`Warning: "${name}" was the active profile.`);
618
+ }
619
+ console.log(result.message);
620
+ break;
621
+ }
622
+
623
+ case 'status': {
624
+ const result = await getClaudeStatus();
625
+ if (!result.success) {
626
+ console.log(result.message);
627
+ return;
628
+ }
629
+ const id = result.identity!;
630
+ console.log('');
631
+ console.log(` Email: ${id.email}`);
632
+ console.log(` Org: ${id.orgName || '-'}`);
633
+ console.log(` Subscription: ${id.subscriptionType || '-'}`);
634
+ console.log(` Logged in: ${id.loggedIn ? 'yes' : 'no'}`);
635
+ console.log('');
636
+ break;
637
+ }
638
+
639
+ default: {
640
+ console.log(`
641
+ Claude profile commands:
642
+ claude list Show all saved credential profiles
643
+ claude switch <name> Switch to a saved profile
644
+ claude login <name> Login via browser and save as profile
645
+ claude save <name> Save current credentials as a profile
646
+ claude delete <name> Remove a saved profile
647
+ claude status Show current Claude auth status
648
+ `);
649
+ break;
650
+ }
651
+ }
652
+ }
653
+
519
654
  function cmdHelp(): void {
520
655
  console.log(`
521
656
  Available commands:
@@ -532,6 +667,14 @@ Available commands:
532
667
  snapshot <project> "label" Create a named snapshot
533
668
  auto-assign on|off Toggle automatic project assignment
534
669
  reset Wipe all accounts and project mappings
670
+
671
+ claude list Show Claude Code credential profiles
672
+ claude switch <name> Switch to a saved Claude profile
673
+ claude login <name> Login and save as Claude profile
674
+ claude save <name> Save current Claude credentials as profile
675
+ claude delete <name> Remove a Claude profile
676
+ claude status Show current Claude auth status
677
+
535
678
  help Show this help
536
679
  quit / exit Exit
537
680
 
@@ -556,6 +699,7 @@ const COMMANDS: Record<string, CommandHandler> = {
556
699
  snapshot: cmdSnapshot,
557
700
  'auto-assign': cmdAutoAssign,
558
701
  reset: cmdReset,
702
+ claude: cmdClaude,
559
703
  help: async () => cmdHelp(),
560
704
  quit: async () => process.exit(0),
561
705
  exit: async () => process.exit(0),
@@ -574,12 +718,32 @@ export async function startRepl(serverUrl?: string): Promise<void> {
574
718
 
575
719
  const mappedCount = Object.keys(config.projectAccounts).length;
576
720
 
721
+ // Load active Claude profile for banner
722
+ let claudeBannerLine = '';
723
+ try {
724
+ const activeClaudeProfile = await loadActiveProfile();
725
+ if (activeClaudeProfile) {
726
+ const profile = await loadProfile(activeClaudeProfile.profile);
727
+ if (profile) {
728
+ const parts = [profile.email];
729
+ if (profile.orgName) parts.push(profile.orgName);
730
+ if (profile.subscriptionType) parts.push(profile.subscriptionType);
731
+ claudeBannerLine = ` Claude: ${parts[0]}${parts.length > 1 ? ` (${parts.slice(1).join(', ')})` : ''}`;
732
+ }
733
+ }
734
+ } catch (profileErr: unknown) {
735
+ logger.debug({ err: profileErr }, 'Failed to load active Claude profile for banner');
736
+ }
737
+
577
738
  // Status banner
578
739
  console.log('');
579
740
  console.log(' Ultra Claude Agent — Interactive Mode');
580
741
  console.log(` Server: ${resolvedUrl}`);
581
742
  console.log(` Accounts: ${accounts.size}`);
582
743
  console.log(` Projects: ${registry.projects.length} discovered, ${mappedCount} mapped`);
744
+ if (claudeBannerLine) {
745
+ console.log(claudeBannerLine);
746
+ }
583
747
  console.log('');
584
748
  console.log(' Type "help" for available commands.');
585
749
  console.log('');