claude-nonstop 0.3.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.
@@ -0,0 +1,1679 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * claude-nonstop — Multi-account switching + Slack remote access for Claude Code.
5
+ *
6
+ * Run `claude-nonstop help` for usage.
7
+ */
8
+
9
+ import { spawn, execFileSync } from 'child_process';
10
+ import { createInterface } from 'readline';
11
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, renameSync } from 'fs';
12
+ import { join, dirname } from 'path';
13
+ import { fileURLToPath } from 'url';
14
+ import { addAccount, removeAccount, getAccounts, ensureDefaultAccount, validateAccountName, setAccountPriority, clearAccountPriority, CONFIG_DIR, DEFAULT_CLAUDE_DIR } from '../lib/config.js';
15
+ import { readCredentials, isTokenExpired, deleteKeychainEntry } from '../lib/keychain.js';
16
+ import { checkAllUsage, checkUsage, fetchProfile } from '../lib/usage.js';
17
+ import { pickBestAccount, pickByPriority } from '../lib/scorer.js';
18
+ import { run } from '../lib/runner.js';
19
+ import { reauthAccount, reauthExpiredAccounts, silentRefresh } from '../lib/reauth.js';
20
+ import { isMacOS } from '../lib/platform.js';
21
+ import { installService, uninstallService, restartService, getServiceStatus, isServiceInstalled, LOG_PATH } from '../lib/service.js';
22
+
23
+ const __filename = fileURLToPath(import.meta.url);
24
+ const __dirname = dirname(__filename);
25
+ const PROJECT_ROOT = join(__dirname, '..');
26
+
27
+ const args = process.argv.slice(2);
28
+ const command = args[0];
29
+
30
+ // Auto-detect default account once at startup
31
+ ensureDefaultAccount();
32
+
33
+ switch (command) {
34
+ case 'add':
35
+ await cmdAdd(args.slice(1));
36
+ break;
37
+
38
+ case 'remove':
39
+ await cmdRemove(args.slice(1));
40
+ break;
41
+
42
+ case 'list':
43
+ await cmdList();
44
+ break;
45
+
46
+ case 'status':
47
+ await cmdStatus();
48
+ break;
49
+
50
+ case 'setup':
51
+ await cmdSetup(args.slice(1));
52
+ break;
53
+
54
+ case 'webhook':
55
+ await cmdWebhook(args.slice(1));
56
+ break;
57
+
58
+ case 'hooks':
59
+ await cmdHooks(args.slice(1));
60
+ break;
61
+
62
+ case 'uninstall':
63
+ await cmdUninstall(args.slice(1));
64
+ break;
65
+
66
+ case 'reauth':
67
+ await cmdReauth();
68
+ break;
69
+
70
+ case 'update':
71
+ await cmdUpdate();
72
+ break;
73
+
74
+ case 'resume':
75
+ await cmdResume(args.slice(1));
76
+ break;
77
+
78
+ case 'use':
79
+ await cmdUse(args.slice(1));
80
+ break;
81
+
82
+ case 'set-priority':
83
+ await cmdSetPriority(args.slice(1));
84
+ break;
85
+
86
+ case 'init':
87
+ cmdInit(args[1]);
88
+ break;
89
+
90
+ case 'help':
91
+ case '--help':
92
+ case '-h':
93
+ printHelp();
94
+ break;
95
+
96
+ case undefined:
97
+ // No command given — default to running Claude
98
+ await cmdRun([]);
99
+ break;
100
+
101
+ default:
102
+ // Unknown command — treat as args to run (e.g. `claude-nonstop -p "fix bug"`)
103
+ await cmdRun(args);
104
+ break;
105
+ }
106
+
107
+ // ─── Commands ──────────────────────────────────────────────────────────────────
108
+
109
+ async function cmdAdd(args) {
110
+ const name = args[0];
111
+ if (!name) {
112
+ console.error('Usage: claude-nonstop add <name>');
113
+ console.error('Example: claude-nonstop add work');
114
+ process.exit(1);
115
+ }
116
+
117
+ try {
118
+ const configDir = addAccount(name);
119
+ console.log(`Account "${name}" registered.`);
120
+ console.log(`Config directory: ${configDir}`);
121
+ console.log('');
122
+ console.log('Opening browser for login...');
123
+ console.log('');
124
+
125
+ // Use `claude auth login` for non-interactive browser-based OAuth.
126
+ // Strip CLAUDECODE env var so this works when called from inside a Claude Code session.
127
+ const authEnv = { ...process.env, CLAUDE_CONFIG_DIR: configDir };
128
+ delete authEnv.CLAUDECODE;
129
+
130
+ await new Promise((resolve) => {
131
+ const child = spawn('claude', ['auth', 'login'], {
132
+ env: authEnv,
133
+ stdio: 'inherit',
134
+ });
135
+
136
+ child.on('close', () => resolve());
137
+ child.on('error', (err) => {
138
+ console.error(`Failed to launch Claude Code: ${err.message}`);
139
+ console.error('Make sure "claude" is installed and in your PATH.');
140
+ resolve();
141
+ });
142
+ });
143
+
144
+ // Verify credentials were saved
145
+ const creds = readCredentials(configDir);
146
+ if (!creds.token) {
147
+ console.log('');
148
+ console.log(`Warning: No credentials found for "${name}".`);
149
+ console.log(`You can login later by running: CLAUDE_CONFIG_DIR="${configDir}" claude auth login`);
150
+ return;
151
+ }
152
+
153
+ console.log('');
154
+ console.log(`Account "${name}" authenticated. Checking for duplicates...`);
155
+
156
+ // Duplicate detection: identity is the ORGANIZATION, not the email.
157
+ // One email can own multiple orgs — e.g. an enterprise email that, per
158
+ // company policy, has both an enterprise org and a personal Max plan org —
159
+ // each a distinct subscription with its own quota. We key on
160
+ // organization.uuid; only when org info is unavailable do we fall back to
161
+ // email so older/edge tokens still get some duplicate protection.
162
+ const newProfile = await fetchProfile(creds.token);
163
+ const newIdentity = newProfile.orgId || newProfile.email;
164
+ if (newIdentity) {
165
+ const existingAccounts = getAccounts().filter(a => a.name !== name);
166
+ const existingProfiles = await Promise.all(existingAccounts.map(async (a) => {
167
+ const existingCreds = readCredentials(a.configDir);
168
+ if (!existingCreds.token) return { ...a, identity: null, profile: null };
169
+ const profile = await fetchProfile(existingCreds.token);
170
+ return { ...a, identity: profile.orgId || profile.email, profile };
171
+ }));
172
+
173
+ const duplicate = existingProfiles.find(a => a.identity && a.identity === newIdentity);
174
+ if (duplicate) {
175
+ const label = formatOrgLabel(newProfile);
176
+ console.error(`\nError: "${name}" (${label}) is the same organization as "${duplicate.name}".`);
177
+ console.error('Each account must be a different Claude organization (org).');
178
+ console.error('Tip: if you meant a different org under the same email, pick that org in the browser sign-in.');
179
+ console.error(`Removing "${name}"...`);
180
+ removeAccount(name);
181
+ process.exit(1);
182
+ }
183
+ }
184
+
185
+ console.log(`Account "${name}" added successfully.`);
186
+ const addedLabel = formatOrgLabel(newProfile);
187
+ if (addedLabel) console.log(`Identity: ${addedLabel}`);
188
+ } catch (err) {
189
+ console.error(`Error: ${err.message}`);
190
+ process.exit(1);
191
+ }
192
+ }
193
+
194
+ async function cmdRemove(args) {
195
+ const name = args[0];
196
+ if (!name) {
197
+ console.error('Usage: claude-nonstop remove <name>');
198
+ process.exit(1);
199
+ }
200
+
201
+ try {
202
+ removeAccount(name);
203
+ console.log(`Account "${name}" removed.`);
204
+ console.log('Note: Credentials in Keychain and config directory were not deleted.');
205
+ } catch (err) {
206
+ console.error(`Error: ${err.message}`);
207
+ process.exit(1);
208
+ }
209
+ }
210
+
211
+ async function cmdReauth() {
212
+ const accounts = getAccounts();
213
+
214
+ if (accounts.length === 0) {
215
+ console.log('No accounts registered.');
216
+ return;
217
+ }
218
+
219
+ // Check which accounts have expired tokens
220
+ console.log('Checking account credentials...\n');
221
+
222
+ const accountsWithTokens = accounts.map(a => {
223
+ const creds = readCredentials(a.configDir);
224
+ return { ...a, token: creds.token, expiresAt: creds.expiresAt, error: creds.error };
225
+ });
226
+
227
+ const withTokens = accountsWithTokens.filter(a => a.token);
228
+ const noTokens = accountsWithTokens.filter(a => !a.token);
229
+
230
+ // Pre-check: tokens expired per keychain expiresAt
231
+ const localExpired = withTokens.filter(a => isTokenExpired({ expiresAt: a.expiresAt }));
232
+
233
+ // Check usage API for remaining accounts that have non-expired tokens
234
+ const toCheck = withTokens.filter(a => !isTokenExpired({ expiresAt: a.expiresAt }));
235
+ let expired = [...noTokens, ...localExpired];
236
+ if (toCheck.length > 0) {
237
+ const withUsage = await checkAllUsage(toCheck);
238
+ for (const a of withUsage) {
239
+ if (a.usage?.error) {
240
+ expired.push(a);
241
+ }
242
+ }
243
+ }
244
+
245
+ if (expired.length === 0) {
246
+ console.log('All accounts are authenticated and working.');
247
+ return;
248
+ }
249
+
250
+ console.log(`Found ${expired.length} account(s) needing re-authentication:\n`);
251
+ for (const a of expired) {
252
+ const reason = a.token
253
+ ? (isTokenExpired({ expiresAt: a.expiresAt }) ? 'token expired' : `API error (${a.usage?.error || 'unknown'})`)
254
+ : (a.error || 'no credentials');
255
+ console.log(` ${a.name}: ${reason}`);
256
+ }
257
+ console.log('');
258
+
259
+ // First pass: try silent refresh for accounts that have tokens
260
+ let successCount = 0;
261
+ const stillExpired = [];
262
+ const silentCandidates = expired.filter(a => a.token);
263
+
264
+ if (silentCandidates.length > 0) {
265
+ console.log('Attempting silent token refresh...');
266
+ for (const account of silentCandidates) {
267
+ if (await silentRefresh(account)) {
268
+ console.log(` ${account.name}: refreshed`);
269
+ successCount++;
270
+ } else {
271
+ stillExpired.push(account);
272
+ }
273
+ }
274
+ // Add accounts with no token (need browser login)
275
+ stillExpired.push(...expired.filter(a => !a.token));
276
+ console.log('');
277
+ } else {
278
+ stillExpired.push(...expired);
279
+ }
280
+
281
+ // Second pass: browser-based re-auth for remaining accounts
282
+ for (let i = 0; i < stillExpired.length; i++) {
283
+ console.log(`[${i + 1}/${stillExpired.length}]`);
284
+ const success = await reauthAccount(stillExpired[i]);
285
+ if (success) successCount++;
286
+ console.log('');
287
+ }
288
+
289
+ console.log(`Re-authentication complete. ${successCount}/${expired.length} account(s) refreshed.`);
290
+ console.log('Run "claude-nonstop status" to verify.');
291
+ }
292
+
293
+ async function cmdUpdate() {
294
+ // Check if installed from a local git repo or from npm
295
+ function isClaudeNonstopRepo(dir) {
296
+ try {
297
+ if (!existsSync(join(dir, 'package.json'))) return false;
298
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
299
+ if (pkg.name !== 'claude-nonstop') return false;
300
+ execFileSync('git', ['rev-parse', '--git-dir'], { cwd: dir, stdio: 'pipe' });
301
+ return true;
302
+ } catch { return false; }
303
+ }
304
+
305
+ const home = process.env.HOME || '';
306
+ const candidates = [
307
+ join(home, 'code', 'claude-nonstop'),
308
+ join(home, 'src', 'claude-nonstop'),
309
+ join(home, 'projects', 'claude-nonstop'),
310
+ join(home, 'dev', 'claude-nonstop'),
311
+ join(home, 'repos', 'claude-nonstop'),
312
+ ];
313
+
314
+ const repoDir = candidates.find(isClaudeNonstopRepo);
315
+
316
+ if (repoDir) {
317
+ console.log(`Updating from local repo: ${repoDir}\n`);
318
+
319
+ try {
320
+ const remotes = execFileSync('git', ['remote'], { cwd: repoDir, encoding: 'utf8', stdio: 'pipe' }).trim();
321
+ if (remotes) {
322
+ console.log(`Tip: Run "cd ${repoDir} && git pull" first to get the latest changes.\n`);
323
+ }
324
+ } catch {}
325
+
326
+ console.log('Reinstalling from source...');
327
+ try {
328
+ const tgz = execFileSync('npm', ['pack'], { cwd: repoDir, encoding: 'utf8', stdio: 'pipe' }).trim();
329
+ const tgzPath = join(repoDir, tgz);
330
+ execFileSync('npm', ['install', '-g', tgzPath], { cwd: repoDir, encoding: 'utf8', stdio: 'pipe' });
331
+ console.log(` Installed ${tgz}`);
332
+ } catch (err) {
333
+ console.error(` npm install failed: ${err.message}`);
334
+ process.exit(1);
335
+ }
336
+ } else {
337
+ console.log('Updating from npm...\n');
338
+ try {
339
+ const output = execFileSync('npm', ['install', '-g', 'claude-nonstop@latest'], { encoding: 'utf8', stdio: 'pipe' });
340
+ console.log(output.trim());
341
+ } catch (err) {
342
+ console.error(` npm install failed: ${err.message}`);
343
+ process.exit(1);
344
+ }
345
+ }
346
+
347
+ // Reinstall hooks to pick up any new/changed hook types
348
+ console.log('\nReinstalling hooks...');
349
+ installHooksToAllProfiles();
350
+
351
+ // postinstall handles webhook restart, but verify
352
+ if (isMacOS() && isServiceInstalled()) {
353
+ console.log('\nWebhook service restarted by postinstall.');
354
+ }
355
+
356
+ console.log('\nUpdate complete.');
357
+ }
358
+
359
+ async function cmdList() {
360
+ const accounts = getAccounts();
361
+
362
+ if (accounts.length === 0) {
363
+ console.log('No accounts registered.');
364
+ console.log('Run "claude-nonstop add <name>" to register an account.');
365
+ return;
366
+ }
367
+
368
+ console.log('Accounts:\n');
369
+
370
+ // Read credentials and fetch profiles in parallel
371
+ const enriched = await Promise.all(accounts.map(async (account) => {
372
+ const creds = readCredentials(account.configDir);
373
+ const profile = creds.token ? await fetchProfile(creds.token) : { name: null, email: null };
374
+ return { ...account, creds, profile };
375
+ }));
376
+
377
+ for (const entry of enriched) {
378
+ const status = entry.creds.token ? 'authenticated' : 'not authenticated';
379
+ const userInfo = formatUserInfo(entry.profile);
380
+ const priLabel = entry.priority != null ? ` (priority: ${entry.priority})` : '';
381
+ console.log(` ${entry.name}${userInfo}${priLabel}`);
382
+ console.log(` Config: ${entry.configDir}`);
383
+ console.log(` Status: ${status}`);
384
+ console.log('');
385
+ }
386
+ }
387
+
388
+ async function cmdStatus() {
389
+ const accounts = getAccounts();
390
+
391
+ if (accounts.length === 0) {
392
+ console.log('No accounts registered.');
393
+ return;
394
+ }
395
+
396
+ console.log('Checking usage for all accounts...\n');
397
+
398
+ // Read credentials for all accounts
399
+ const accountsWithTokens = accounts.map(a => {
400
+ const creds = readCredentials(a.configDir);
401
+ return { ...a, token: creds.token };
402
+ });
403
+
404
+ const authenticated = accountsWithTokens.filter(a => a.token);
405
+ const unauthenticated = accountsWithTokens.filter(a => !a.token);
406
+
407
+ if (authenticated.length > 0) {
408
+ // Fetch usage and profiles in parallel
409
+ let [withUsage, profiles] = await Promise.all([
410
+ checkAllUsage(authenticated),
411
+ Promise.all(authenticated.map(a => fetchProfile(a.token))),
412
+ ]);
413
+
414
+ // Silent refresh: retry accounts with auth errors (401 expired, 403 revoked)
415
+ const rejected = withUsage.filter(a =>
416
+ a.usage?.error === 'HTTP 401' || a.usage?.error === 'HTTP 403'
417
+ );
418
+ if (rejected.length > 0) {
419
+ for (const account of rejected) {
420
+ if (await silentRefresh(account)) {
421
+ // Re-read token and retry usage check
422
+ const creds = readCredentials(account.configDir);
423
+ if (creds.token) {
424
+ account.token = creds.token;
425
+ account.usage = await checkUsage(creds.token);
426
+ // Re-fetch profile with refreshed token
427
+ const profile = await fetchProfile(creds.token);
428
+ const idx = authenticated.findIndex(a => a.name === account.name);
429
+ if (idx !== -1) profiles[idx] = profile;
430
+ }
431
+ }
432
+ }
433
+ }
434
+
435
+ // Merge profiles into usage results
436
+ const profileMap = Object.fromEntries(authenticated.map((a, i) => [a.name, profiles[i]]));
437
+
438
+ // Find best account for display
439
+ const best = pickBestAccount(withUsage);
440
+ const bestName = best?.account?.name;
441
+
442
+ for (const account of withUsage) {
443
+ const isBest = account.name === bestName;
444
+ const marker = isBest ? ' <-- best' : '';
445
+ const userInfo = formatUserInfo(profileMap[account.name] || {});
446
+ const priLabel = account.priority != null ? ` (priority: ${account.priority})` : '';
447
+
448
+ console.log(` ${account.name}${userInfo}${priLabel}${marker}`);
449
+
450
+ if (account.usage.error) {
451
+ console.log(` Usage: error (${account.usage.error})`);
452
+ } else {
453
+ const sessionBar = makeBar(account.usage.sessionPercent);
454
+ const weeklyBar = makeBar(account.usage.weeklyPercent);
455
+ console.log(` 5-hour: ${sessionBar} ${account.usage.sessionPercent}%`);
456
+ console.log(` 7-day: ${weeklyBar} ${account.usage.weeklyPercent}%`);
457
+
458
+ if (account.usage.sessionResetsAt) {
459
+ console.log(` Session resets: ${formatResetTime(account.usage.sessionResetsAt)}`);
460
+ }
461
+ if (account.usage.weeklyResetsAt) {
462
+ console.log(` Weekly resets: ${formatResetTime(account.usage.weeklyResetsAt)}`);
463
+ }
464
+ }
465
+ console.log('');
466
+ }
467
+ }
468
+
469
+ if (unauthenticated.length > 0) {
470
+ console.log(' Not authenticated:');
471
+ for (const account of unauthenticated) {
472
+ console.log(` ${account.name} (${account.configDir})`);
473
+ }
474
+ console.log('');
475
+ }
476
+ }
477
+
478
+ async function cmdRun(claudeArgs) {
479
+ // Extract --remote-access flag (consume it, don't pass to claude)
480
+ const remoteAccessIdx = claudeArgs.indexOf('--remote-access');
481
+ const remoteAccess = remoteAccessIdx !== -1;
482
+ if (remoteAccess) {
483
+ claudeArgs.splice(remoteAccessIdx, 1);
484
+ }
485
+
486
+ // Extract --account / -a flag (consume it, don't pass to claude)
487
+ const requestedAccount = extractAccountFlag(claudeArgs);
488
+
489
+ // Handle tmux bootstrapping for remote access
490
+ if (remoteAccess) {
491
+ const { isInsideTmux, generateSessionName, reexecInTmux } = await import('../lib/tmux.js');
492
+
493
+ if (!isInsideTmux()) {
494
+ const sessionName = generateSessionName();
495
+ console.error(`[claude-nonstop] Creating tmux session "${sessionName}"...`);
496
+ reexecInTmux(sessionName, process.argv);
497
+ return; // reexecInTmux calls process.exit, but just in case
498
+ }
499
+
500
+ // Inside tmux — inject --dangerously-skip-permissions if not already present
501
+ if (!claudeArgs.includes('--dangerously-skip-permissions')) {
502
+ claudeArgs.push('--dangerously-skip-permissions');
503
+ }
504
+
505
+ // Append formatting instruction for Slack readability
506
+ if (!claudeArgs.includes('--append-system-prompt')) {
507
+ claudeArgs.push(
508
+ '--append-system-prompt',
509
+ 'Your responses are relayed to a Slack channel. Structure output for readability: use short paragraphs, bullet points, and bold headers (## Header). Separate sections with blank lines. Keep summaries concise — prefer a few clear bullets over long prose.'
510
+ );
511
+ }
512
+ }
513
+
514
+ const accounts = getAccounts();
515
+
516
+ if (accounts.length === 0) {
517
+ console.error('No accounts registered. Run "claude-nonstop add <name>" first.');
518
+ process.exit(1);
519
+ }
520
+
521
+ // Read credentials for all accounts
522
+ let accountsWithCreds = accounts.map(a => {
523
+ const creds = readCredentials(a.configDir);
524
+ return { ...a, token: creds.token, expiresAt: creds.expiresAt };
525
+ });
526
+
527
+ // Pre-flight: detect expired tokens and offer re-auth
528
+ const expiredPreFlight = accountsWithCreds.filter(a =>
529
+ !a.token || (a.expiresAt && isTokenExpired({ expiresAt: a.expiresAt }))
530
+ );
531
+
532
+ if (expiredPreFlight.length > 0 && !remoteAccess) {
533
+ const refreshed = await reauthExpiredAccounts(expiredPreFlight);
534
+ if (refreshed.length > 0) {
535
+ // Re-read credentials for refreshed accounts
536
+ accountsWithCreds = accounts.map(a => {
537
+ const creds = readCredentials(a.configDir);
538
+ return { ...a, token: creds.token, expiresAt: creds.expiresAt };
539
+ });
540
+ }
541
+ }
542
+
543
+ const authenticated = accountsWithCreds.filter(a => a.token);
544
+
545
+ if (authenticated.length === 0) {
546
+ console.error('No authenticated accounts. Run "claude-nonstop add <name>" to add and authenticate an account.');
547
+ process.exit(1);
548
+ }
549
+
550
+ // Check usage and pick best account
551
+ let selectedAccount;
552
+
553
+ if (requestedAccount) {
554
+ // Explicit --account flag — use it directly, skip usage check
555
+ selectedAccount = authenticated.find(a => a.name === requestedAccount);
556
+ if (!selectedAccount) {
557
+ console.error(`Error: Account "${requestedAccount}" not found or not authenticated.`);
558
+ console.error(`Authenticated accounts: ${authenticated.map(a => a.name).join(', ')}`);
559
+ process.exit(1);
560
+ }
561
+ console.error(`[claude-nonstop] Using requested account "${selectedAccount.name}"`);
562
+ } else if (authenticated.length === 1) {
563
+ // Only one account — use it directly (skip usage check)
564
+ selectedAccount = authenticated[0];
565
+ console.error(`[claude-nonstop] Using account "${selectedAccount.name}"`);
566
+ } else {
567
+ // Multiple accounts — check usage and pick best
568
+ console.error('[claude-nonstop] Checking usage across accounts...');
569
+ const withUsage = await checkAllUsage(authenticated);
570
+
571
+ // Check if any authenticated accounts have API auth errors (expired or revoked)
572
+ const apiExpired = withUsage.filter(a =>
573
+ a.usage?.error === 'HTTP 401' || a.usage?.error === 'HTTP 403'
574
+ );
575
+ if (apiExpired.length > 0 && !remoteAccess) {
576
+ const refreshed = await reauthExpiredAccounts(apiExpired);
577
+ if (refreshed.length > 0) {
578
+ // Re-read credentials and re-check usage for refreshed accounts
579
+ const updatedAccounts = accounts.map(a => {
580
+ const creds = readCredentials(a.configDir);
581
+ return { ...a, token: creds.token };
582
+ }).filter(a => a.token);
583
+ const updatedUsage = await checkAllUsage(updatedAccounts);
584
+ // Merge: replace stale entries with refreshed ones
585
+ for (const updated of updatedUsage) {
586
+ const idx = withUsage.findIndex(a => a.name === updated.name);
587
+ if (idx !== -1) withUsage[idx] = updated;
588
+ else withUsage.push(updated);
589
+ }
590
+ }
591
+ }
592
+
593
+ // Only use priority sorting when at least one account has a priority set
594
+ const hasPriorities = withUsage.some(a => a.priority != null);
595
+ const best = pickBestAccount(withUsage, undefined, { usePriority: hasPriorities });
596
+
597
+ if (best) {
598
+ selectedAccount = best.account;
599
+ console.error(`[claude-nonstop] Selected "${selectedAccount.name}" (${best.reason})`);
600
+ } else {
601
+ // Fallback to first authenticated account
602
+ selectedAccount = authenticated[0];
603
+ console.error(`[claude-nonstop] Defaulting to "${selectedAccount.name}"`);
604
+ }
605
+ }
606
+
607
+ // Run with auto-switching
608
+ await run(claudeArgs, selectedAccount, accounts, { remoteAccess });
609
+ }
610
+
611
+ async function cmdResume(resumeArgs) {
612
+ // Extract --remote-access flag (consume it, don't pass to claude)
613
+ const remoteAccessIdx = resumeArgs.indexOf('--remote-access');
614
+ const remoteAccess = remoteAccessIdx !== -1;
615
+ if (remoteAccess) {
616
+ resumeArgs.splice(remoteAccessIdx, 1);
617
+ }
618
+
619
+ // Extract --account / -a flag (consume it, don't pass to claude)
620
+ const requestedAccount = extractAccountFlag(resumeArgs);
621
+
622
+ // Handle tmux bootstrapping for remote access
623
+ if (remoteAccess) {
624
+ const { isInsideTmux, generateSessionName, reexecInTmux } = await import('../lib/tmux.js');
625
+
626
+ if (!isInsideTmux()) {
627
+ const sessionName = generateSessionName();
628
+ console.error(`[claude-nonstop] Creating tmux session "${sessionName}"...`);
629
+ reexecInTmux(sessionName, process.argv);
630
+ return;
631
+ }
632
+ }
633
+
634
+ const accounts = getAccounts();
635
+ if (accounts.length === 0) {
636
+ console.error('No accounts registered. Run "claude-nonstop add <name>" first.');
637
+ process.exit(1);
638
+ }
639
+
640
+ // Find session across all profiles
641
+ const { findSessionAcrossProfiles, findLatestSessionAcrossProfiles, migrateSessionByHash } = await import('../lib/session.js');
642
+
643
+ const sessionIdArg = resumeArgs.find(a => !a.startsWith('-'));
644
+ let found;
645
+
646
+ if (sessionIdArg) {
647
+ console.error(`[claude-nonstop] Searching for session ${sessionIdArg}...`);
648
+ found = findSessionAcrossProfiles(accounts, sessionIdArg);
649
+ if (!found) {
650
+ console.error(`Error: Session "${sessionIdArg}" not found in any account.`);
651
+ process.exit(1);
652
+ }
653
+ } else {
654
+ console.error('[claude-nonstop] Searching for most recent session in this project...');
655
+ found = findLatestSessionAcrossProfiles(accounts, process.cwd());
656
+ if (!found) {
657
+ console.error('Error: No sessions found for this project in any account.');
658
+ process.exit(1);
659
+ }
660
+ }
661
+
662
+ const sessionId = sessionIdArg || found.sessionId;
663
+ console.error(`[claude-nonstop] Found session ${sessionId} in account "${found.account.name}"`);
664
+
665
+ // Build claude args
666
+ const claudeArgs = ['--resume', sessionId];
667
+ if (remoteAccess && !claudeArgs.includes('--dangerously-skip-permissions')) {
668
+ claudeArgs.push('--dangerously-skip-permissions');
669
+ }
670
+
671
+ // Read credentials and pick best account (same as cmdRun)
672
+ let accountsWithCreds = accounts.map(a => {
673
+ const creds = readCredentials(a.configDir);
674
+ return { ...a, token: creds.token, expiresAt: creds.expiresAt };
675
+ });
676
+
677
+ const expiredPreFlight = accountsWithCreds.filter(a =>
678
+ !a.token || (a.expiresAt && isTokenExpired({ expiresAt: a.expiresAt }))
679
+ );
680
+
681
+ if (expiredPreFlight.length > 0 && !remoteAccess) {
682
+ const refreshed = await reauthExpiredAccounts(expiredPreFlight);
683
+ if (refreshed.length > 0) {
684
+ accountsWithCreds = accounts.map(a => {
685
+ const creds = readCredentials(a.configDir);
686
+ return { ...a, token: creds.token, expiresAt: creds.expiresAt };
687
+ });
688
+ }
689
+ }
690
+
691
+ const authenticated = accountsWithCreds.filter(a => a.token);
692
+ if (authenticated.length === 0) {
693
+ console.error('No authenticated accounts. Run "claude-nonstop add <name>" to add and authenticate an account.');
694
+ process.exit(1);
695
+ }
696
+
697
+ // Pick best account
698
+ let selectedAccount;
699
+
700
+ if (requestedAccount) {
701
+ // Explicit --account flag — use it directly, skip usage check
702
+ selectedAccount = authenticated.find(a => a.name === requestedAccount);
703
+ if (!selectedAccount) {
704
+ console.error(`Error: Account "${requestedAccount}" not found or not authenticated.`);
705
+ console.error(`Authenticated accounts: ${authenticated.map(a => a.name).join(', ')}`);
706
+ process.exit(1);
707
+ }
708
+ console.error(`[claude-nonstop] Using requested account "${selectedAccount.name}"`);
709
+ } else if (authenticated.length === 1) {
710
+ selectedAccount = authenticated[0];
711
+ console.error(`[claude-nonstop] Using account "${selectedAccount.name}"`);
712
+ } else {
713
+ console.error('[claude-nonstop] Checking usage across accounts...');
714
+ const withUsage = await checkAllUsage(authenticated);
715
+
716
+ const apiExpired = withUsage.filter(a =>
717
+ a.usage?.error === 'HTTP 401' || a.usage?.error === 'HTTP 403'
718
+ );
719
+ if (apiExpired.length > 0 && !remoteAccess) {
720
+ const refreshed = await reauthExpiredAccounts(apiExpired);
721
+ if (refreshed.length > 0) {
722
+ const updatedAccounts = accounts.map(a => {
723
+ const creds = readCredentials(a.configDir);
724
+ return { ...a, token: creds.token };
725
+ }).filter(a => a.token);
726
+ const updatedUsage = await checkAllUsage(updatedAccounts);
727
+ for (const updated of updatedUsage) {
728
+ const idx = withUsage.findIndex(a => a.name === updated.name);
729
+ if (idx !== -1) withUsage[idx] = updated;
730
+ else withUsage.push(updated);
731
+ }
732
+ }
733
+ }
734
+
735
+ const hasPriorities = withUsage.some(a => a.priority != null);
736
+ const best = pickBestAccount(withUsage, undefined, { usePriority: hasPriorities });
737
+
738
+ if (best) {
739
+ selectedAccount = best.account;
740
+ console.error(`[claude-nonstop] Selected "${selectedAccount.name}" (${best.reason})`);
741
+ } else {
742
+ selectedAccount = authenticated[0];
743
+ console.error(`[claude-nonstop] Defaulting to "${selectedAccount.name}"`);
744
+ }
745
+ }
746
+
747
+ // Migrate session to selected account if it lives in a different profile
748
+ if (found.account.configDir !== selectedAccount.configDir) {
749
+ console.error(`[claude-nonstop] Migrating session from "${found.account.name}" to "${selectedAccount.name}"...`);
750
+ const result = migrateSessionByHash(found.account.configDir, selectedAccount.configDir, found.cwdHash, sessionId);
751
+ if (!result.success) {
752
+ console.error(`[claude-nonstop] Migration failed: ${result.error}`);
753
+ console.error(`[claude-nonstop] Falling back to source account "${found.account.name}"`);
754
+ selectedAccount = found.account;
755
+ }
756
+ }
757
+
758
+ await run(claudeArgs, selectedAccount, accounts, { remoteAccess });
759
+ }
760
+
761
+ // ─── Use & Priority Commands ────────────────────────────────────────────────
762
+
763
+ async function cmdUse(useArgs) {
764
+ const flag = useArgs[0];
765
+
766
+ // No args — show current active profile
767
+ if (!flag) {
768
+ const current = process.env.CLAUDE_CONFIG_DIR;
769
+ if (current) {
770
+ const accounts = getAccounts();
771
+ const match = accounts.find(a => a.configDir === current);
772
+ const label = match ? match.name : 'unknown';
773
+ console.error(`Current: ${label} (${current})`);
774
+ } else {
775
+ console.error(`Current: default (${DEFAULT_CLAUDE_DIR})`);
776
+ }
777
+ return;
778
+ }
779
+
780
+ // --unset — revert to default
781
+ if (flag === '--unset') {
782
+ // stdout: eval-friendly command; stderr: human message
783
+ console.log('unset CLAUDE_CONFIG_DIR');
784
+ console.error(`Reverted to default account (${DEFAULT_CLAUDE_DIR})`);
785
+ return;
786
+ }
787
+
788
+ // --best — pick lowest utilization (no priority)
789
+ if (flag === '--best') {
790
+ const accounts = getAccounts();
791
+ const accountsWithCreds = accounts.map(a => {
792
+ const creds = readCredentials(a.configDir);
793
+ return { ...a, token: creds.token };
794
+ });
795
+ const authenticated = accountsWithCreds.filter(a => a.token);
796
+
797
+ if (authenticated.length === 0) {
798
+ console.error('Error: No authenticated accounts.');
799
+ process.exit(1);
800
+ }
801
+
802
+ const withUsage = await checkAllUsage(authenticated);
803
+ const best = pickBestAccount(withUsage);
804
+
805
+ if (!best) {
806
+ console.error('Error: No suitable accounts found.');
807
+ process.exit(1);
808
+ }
809
+
810
+ console.log(`export CLAUDE_CONFIG_DIR='${best.account.configDir}'`);
811
+ console.error(`Switched to "${best.account.name}" (${best.reason})`);
812
+ return;
813
+ }
814
+
815
+ // --priority — pick by priority hierarchy (98% threshold)
816
+ if (flag === '--priority') {
817
+ const accounts = getAccounts();
818
+ const accountsWithCreds = accounts.map(a => {
819
+ const creds = readCredentials(a.configDir);
820
+ return { ...a, token: creds.token };
821
+ });
822
+ const authenticated = accountsWithCreds.filter(a => a.token);
823
+
824
+ if (authenticated.length === 0) {
825
+ console.error('Error: No authenticated accounts.');
826
+ process.exit(1);
827
+ }
828
+
829
+ const withUsage = await checkAllUsage(authenticated);
830
+ const best = pickByPriority(withUsage);
831
+
832
+ if (!best) {
833
+ console.error('Error: No suitable accounts found.');
834
+ process.exit(1);
835
+ }
836
+
837
+ console.log(`export CLAUDE_CONFIG_DIR='${best.account.configDir}'`);
838
+ console.error(`Switched to "${best.account.name}" (${best.reason})`);
839
+ return;
840
+ }
841
+
842
+ // Explicit account name
843
+ const name = flag;
844
+ const accounts = getAccounts();
845
+ const account = accounts.find(a => a.name === name);
846
+
847
+ if (!account) {
848
+ console.error(`Error: Account "${name}" not found.`);
849
+ console.error(`Available accounts: ${accounts.map(a => a.name).join(', ')}`);
850
+ process.exit(1);
851
+ }
852
+
853
+ const creds = readCredentials(account.configDir);
854
+ if (!creds.token) {
855
+ console.error(`Warning: Account "${name}" is not authenticated. Run "claude-nonstop reauth" first.`);
856
+ }
857
+
858
+ console.log(`export CLAUDE_CONFIG_DIR='${account.configDir}'`);
859
+ console.error(`Switched to "${account.name}" (${account.configDir})`);
860
+ }
861
+
862
+ async function cmdSetPriority(priorityArgs) {
863
+ const name = priorityArgs[0];
864
+ const priorityStr = priorityArgs[1];
865
+
866
+ if (!name) {
867
+ console.error('Usage: claude-nonstop set-priority <account> <number>');
868
+ console.error(' claude-nonstop set-priority <account> clear');
869
+ console.error('Example: claude-nonstop set-priority main 1');
870
+ process.exit(1);
871
+ }
872
+
873
+ try {
874
+ if (priorityStr === 'clear' || priorityStr === undefined) {
875
+ if (priorityStr === 'clear') {
876
+ clearAccountPriority(name);
877
+ console.log(`Priority cleared for "${name}".`);
878
+ } else {
879
+ console.error('Usage: claude-nonstop set-priority <account> <number>');
880
+ console.error(' claude-nonstop set-priority <account> clear');
881
+ process.exit(1);
882
+ }
883
+ } else {
884
+ const priority = parseInt(priorityStr, 10);
885
+ if (isNaN(priority)) {
886
+ console.error('Error: Priority must be a positive integer.');
887
+ process.exit(1);
888
+ }
889
+ setAccountPriority(name, priority);
890
+ console.log(`Priority for "${name}" set to ${priority}.`);
891
+ }
892
+ } catch (err) {
893
+ console.error(`Error: ${err.message}`);
894
+ process.exit(1);
895
+ }
896
+ }
897
+
898
+ // ─── Init (shell integration) ───────────────────────────────────────────────
899
+
900
+ function cmdInit(shell) {
901
+ if (!shell || !['bash', 'zsh'].includes(shell)) {
902
+ console.error('Usage: claude-nonstop init <bash|zsh>');
903
+ console.error('');
904
+ console.error('Add this to your shell config:');
905
+ console.error(' # ~/.bashrc');
906
+ console.error(' eval "$(claude-nonstop init bash)"');
907
+ console.error(' # ~/.zshrc');
908
+ console.error(' eval "$(claude-nonstop init zsh)"');
909
+ process.exit(1);
910
+ }
911
+
912
+ // Output a shell function that wraps `claude-nonstop use` with eval.
913
+ // stdout has export/unset commands, stderr has human-readable messages.
914
+ // The wrapper captures stdout, evals it, and lets stderr pass through naturally.
915
+ console.log(`
916
+ claude-nonstop() {
917
+ if [ "\$1" = "use" ] && [ \$# -gt 1 ]; then
918
+ local shell_code
919
+ shell_code="\$(command claude-nonstop "\$@")"
920
+ local exit_code=\$?
921
+ if [ \$exit_code -eq 0 ] && [ -n "\$shell_code" ]; then
922
+ eval "\$shell_code"
923
+ fi
924
+ return \$exit_code
925
+ else
926
+ command claude-nonstop "\$@"
927
+ fi
928
+ }
929
+ `.trim());
930
+ }
931
+
932
+ // ─── Setup & Hooks Commands ─────────────────────────────────────────────────
933
+
934
+ async function cmdSetup(setupArgs = []) {
935
+ if (setupArgs.includes('--help') || setupArgs.includes('-h') || setupArgs.includes('help')) {
936
+ console.log(`
937
+ claude-nonstop setup — Configure Slack remote access
938
+
939
+ Usage:
940
+ claude-nonstop setup Interactive setup (prompts for tokens)
941
+ claude-nonstop setup --bot-token <tok> --app-token <tok> Non-interactive
942
+ claude-nonstop setup --from-env Read tokens from environment
943
+
944
+ Options:
945
+ --bot-token <tok> Slack bot token (xoxb-...)
946
+ --app-token <tok> Slack app token (xapp-...)
947
+ --from-env Read SLACK_BOT_TOKEN, SLACK_APP_TOKEN from environment
948
+ --invite-user-id <id> Auto-invite your Slack user to session channels
949
+ --channel-id <id> Slack channel ID for single-channel mode
950
+ --allowed-users <ids> Comma-separated Slack user IDs allowed to send commands
951
+ --channel-prefix <p> Prefix for channel names (default: cn)
952
+ --default-tmux-session <name> Default tmux session for single-channel/DM mode
953
+
954
+ When --bot-token and --app-token are provided (or --from-env), setup runs
955
+ non-interactively. On macOS, setup also installs the webhook as a launchd service.
956
+ `.trim());
957
+ return;
958
+ }
959
+
960
+ console.log('claude-nonstop Slack Remote Access Setup\n');
961
+
962
+ const { flags, fromEnv } = parseSetupFlags(setupArgs);
963
+
964
+ let botToken, appToken, channelId, allowedUsers, inviteUserId, channelPrefix, defaultTmux;
965
+
966
+ if (fromEnv || (flags.botToken && flags.appToken)) {
967
+ // Non-interactive mode: read from env vars and/or CLI flags
968
+ if (fromEnv) {
969
+ botToken = flags.botToken || process.env.SLACK_BOT_TOKEN || '';
970
+ appToken = flags.appToken || process.env.SLACK_APP_TOKEN || '';
971
+ channelId = flags.channelId || process.env.SLACK_CHANNEL_ID || '';
972
+ allowedUsers = flags.allowedUsers || process.env.SLACK_ALLOWED_USERS || '';
973
+ inviteUserId = flags.inviteUserId || process.env.SLACK_INVITE_USER_ID || '';
974
+ channelPrefix = flags.channelPrefix || process.env.SLACK_CHANNEL_PREFIX || 'cn';
975
+ defaultTmux = flags.defaultTmuxSession || process.env.DEFAULT_TMUX_SESSION || '';
976
+ console.log('Reading configuration from environment variables...');
977
+ } else {
978
+ botToken = flags.botToken;
979
+ appToken = flags.appToken;
980
+ channelId = flags.channelId || '';
981
+ allowedUsers = flags.allowedUsers || '';
982
+ inviteUserId = flags.inviteUserId || '';
983
+ channelPrefix = flags.channelPrefix || 'cn';
984
+ defaultTmux = flags.defaultTmuxSession || '';
985
+ console.log('Using tokens from CLI flags...');
986
+ }
987
+ } else {
988
+ // Interactive mode (default)
989
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
990
+ const ask = (q, defaultVal = '') => new Promise((resolve) => {
991
+ const prompt = defaultVal ? `${q} [${defaultVal}]: ` : `${q}: `;
992
+ rl.question(prompt, (answer) => resolve(answer.trim() || defaultVal));
993
+ });
994
+
995
+ console.log('Enter your Slack app tokens.');
996
+ console.log('Bot Token: Slack app > OAuth & Permissions (starts with xoxb-)');
997
+ console.log('App Token: Slack app > Basic Information > App-Level Tokens (starts with xapp-)\n');
998
+
999
+ botToken = await ask('SLACK_BOT_TOKEN (xoxb-...)');
1000
+ appToken = await ask('SLACK_APP_TOKEN (xapp-...)');
1001
+ channelId = await ask('SLACK_CHANNEL_ID (optional, for single-channel mode)', '');
1002
+ allowedUsers = await ask('SLACK_ALLOWED_USERS (comma-separated user IDs, empty = all)', '');
1003
+ inviteUserId = await ask('SLACK_INVITE_USER_ID (auto-invite to session channels)', '');
1004
+ channelPrefix = await ask('SLACK_CHANNEL_PREFIX', 'cn');
1005
+ defaultTmux = await ask('DEFAULT_TMUX_SESSION (for single-channel/DM mode, optional)', '');
1006
+
1007
+ rl.close();
1008
+ }
1009
+
1010
+ // Validate required tokens
1011
+ if (!botToken || !botToken.startsWith('xoxb-')) {
1012
+ console.error('Invalid bot token. Must start with xoxb-');
1013
+ process.exit(1);
1014
+ }
1015
+
1016
+ if (!appToken || !appToken.startsWith('xapp-')) {
1017
+ console.error('Invalid app token. Must start with xapp-');
1018
+ process.exit(1);
1019
+ }
1020
+
1021
+ // Write .env
1022
+ const envContent = `# claude-nonstop Slack Configuration
1023
+ SLACK_BOT_TOKEN=${botToken}
1024
+ SLACK_APP_TOKEN=${appToken}
1025
+ SLACK_CHANNEL_ID=${channelId}
1026
+ SLACK_ALLOWED_USERS=${allowedUsers}
1027
+ SLACK_INVITE_USER_ID=${inviteUserId}
1028
+ SLACK_CHANNEL_PREFIX=${channelPrefix}
1029
+ DEFAULT_TMUX_SESSION=${defaultTmux}
1030
+ `;
1031
+
1032
+ const envDir = CONFIG_DIR;
1033
+ if (!existsSync(envDir)) mkdirSync(envDir, { recursive: true });
1034
+ const envPath = join(envDir, '.env');
1035
+ // Atomic write with restrictive permissions (contains Slack tokens)
1036
+ const envTmp = join(envDir, `.env.${process.pid}.${Date.now()}.tmp`);
1037
+ writeFileSync(envTmp, envContent, { mode: 0o600 });
1038
+ renameSync(envTmp, envPath);
1039
+ console.log(`\nWrote ${envPath}`);
1040
+
1041
+ // Install hooks
1042
+ console.log('\nInstalling Claude Code hooks into all profiles...\n');
1043
+ installHooksToAllProfiles();
1044
+
1045
+ // Auto-install launchd service on macOS
1046
+ if (isMacOS()) {
1047
+ console.log('\nInstalling webhook as launchd service...');
1048
+ try {
1049
+ installService();
1050
+ console.log(' Webhook service installed and started.');
1051
+ console.log(` Logs: ${LOG_PATH}`);
1052
+ } catch (err) {
1053
+ console.warn(` Warning: Could not install service: ${err.message}`);
1054
+ console.warn(' You can install it manually with: claude-nonstop webhook install');
1055
+ }
1056
+ }
1057
+
1058
+ console.log('\nSetup complete! Next steps:');
1059
+ if (isMacOS()) {
1060
+ console.log(' 1. Check webhook status: claude-nonstop webhook status');
1061
+ console.log(' 2. Run with remote: claude-nonstop --remote-access');
1062
+ } else {
1063
+ console.log(' 1. Start the webhook: claude-nonstop webhook');
1064
+ console.log(' (or set up a systemd service for auto-restart)');
1065
+ console.log(' 2. Run with remote: claude-nonstop --remote-access');
1066
+ }
1067
+ }
1068
+
1069
+ async function cmdWebhook(subArgs = []) {
1070
+ const subcommand = subArgs[0];
1071
+
1072
+ switch (subcommand) {
1073
+ case 'install':
1074
+ cmdWebhookInstall();
1075
+ break;
1076
+
1077
+ case 'uninstall':
1078
+ cmdWebhookUninstall();
1079
+ break;
1080
+
1081
+ case 'restart':
1082
+ cmdWebhookRestart();
1083
+ break;
1084
+
1085
+ case 'status':
1086
+ cmdWebhookStatus();
1087
+ break;
1088
+
1089
+ case 'logs':
1090
+ cmdWebhookLogs();
1091
+ break;
1092
+
1093
+ case undefined:
1094
+ // No subcommand — show usage
1095
+ console.log('Usage:');
1096
+ console.log(' claude-nonstop webhook Run webhook in foreground');
1097
+ console.log(' claude-nonstop webhook install Install as launchd service (macOS)');
1098
+ console.log(' claude-nonstop webhook uninstall Remove launchd service');
1099
+ console.log(' claude-nonstop webhook restart Restart the service');
1100
+ console.log(' claude-nonstop webhook status Show service status');
1101
+ console.log(' claude-nonstop webhook logs Tail the webhook log');
1102
+ console.log('');
1103
+ console.log('To run in foreground (for debugging): claude-nonstop webhook start');
1104
+ break;
1105
+
1106
+ case 'start':
1107
+ // Explicit foreground mode
1108
+ cmdWebhookForeground();
1109
+ break;
1110
+
1111
+ default:
1112
+ console.error(`Unknown webhook subcommand: ${subcommand}`);
1113
+ console.error('Run "claude-nonstop help" for usage information.');
1114
+ process.exit(1);
1115
+ }
1116
+ }
1117
+
1118
+ function cmdWebhookForeground() {
1119
+ const webhookPath = join(PROJECT_ROOT, 'remote', 'start-webhook.cjs');
1120
+ const child = spawn('node', [webhookPath], { stdio: 'inherit' });
1121
+
1122
+ child.on('error', (err) => {
1123
+ console.error(`Failed to start webhook: ${err.message}`);
1124
+ process.exit(1);
1125
+ });
1126
+
1127
+ child.on('close', (code) => {
1128
+ process.exit(code || 0);
1129
+ });
1130
+
1131
+ // Forward signals
1132
+ process.on('SIGINT', () => child.kill('SIGINT'));
1133
+ process.on('SIGTERM', () => child.kill('SIGTERM'));
1134
+ }
1135
+
1136
+ function cmdWebhookInstall() {
1137
+ if (!isMacOS()) {
1138
+ console.error('Service management is only supported on macOS (launchd).');
1139
+ console.error('On Linux, use systemd or run "claude-nonstop webhook" in a screen/tmux session.');
1140
+ process.exit(1);
1141
+ }
1142
+
1143
+ try {
1144
+ installService();
1145
+ console.log('Webhook service installed and started.');
1146
+ console.log(` Service: claude-nonstop-slack`);
1147
+ console.log(` Logs: ${LOG_PATH}`);
1148
+ console.log('');
1149
+ console.log('The webhook will start automatically on login and restart on failure.');
1150
+ console.log('Use "claude-nonstop webhook status" to check status.');
1151
+ } catch (err) {
1152
+ console.error(`Failed to install service: ${err.message}`);
1153
+ process.exit(1);
1154
+ }
1155
+ }
1156
+
1157
+ function cmdWebhookUninstall() {
1158
+ if (!isMacOS()) {
1159
+ console.error('Service management is only supported on macOS (launchd).');
1160
+ process.exit(1);
1161
+ }
1162
+
1163
+ try {
1164
+ uninstallService();
1165
+ console.log('Webhook service stopped and removed.');
1166
+ } catch (err) {
1167
+ console.error(`Failed to uninstall service: ${err.message}`);
1168
+ process.exit(1);
1169
+ }
1170
+ }
1171
+
1172
+ function cmdWebhookRestart() {
1173
+ if (!isMacOS()) {
1174
+ console.error('Service management is only supported on macOS (launchd).');
1175
+ process.exit(1);
1176
+ }
1177
+
1178
+ if (!isServiceInstalled()) {
1179
+ console.error('Webhook service is not installed. Run "claude-nonstop webhook install" first.');
1180
+ process.exit(1);
1181
+ }
1182
+
1183
+ try {
1184
+ restartService();
1185
+ console.log('Webhook service restarted.');
1186
+ } catch (err) {
1187
+ console.error(`Failed to restart service: ${err.message}`);
1188
+ process.exit(1);
1189
+ }
1190
+ }
1191
+
1192
+ function cmdWebhookStatus() {
1193
+ if (!isMacOS()) {
1194
+ console.log('Service management is only supported on macOS (launchd).');
1195
+ console.log('Check webhook manually: ps aux | grep start-webhook');
1196
+ return;
1197
+ }
1198
+
1199
+ const status = getServiceStatus();
1200
+
1201
+ if (!status.installed) {
1202
+ console.log('Webhook service: not installed');
1203
+ console.log('Run "claude-nonstop webhook install" to install.');
1204
+ return;
1205
+ }
1206
+
1207
+ console.log(`Webhook service: installed`);
1208
+ console.log(` Status: ${status.running ? 'running' : 'stopped'}`);
1209
+ if (status.pid) {
1210
+ console.log(` PID: ${status.pid}`);
1211
+ }
1212
+ console.log(` Logs: ${LOG_PATH}`);
1213
+ }
1214
+
1215
+ function cmdWebhookLogs() {
1216
+ if (!existsSync(LOG_PATH)) {
1217
+ console.error(`No log file found at ${LOG_PATH}`);
1218
+ console.error('The webhook service may not have been started yet.');
1219
+ process.exit(1);
1220
+ }
1221
+
1222
+ const child = spawn('tail', ['-f', LOG_PATH], { stdio: 'inherit' });
1223
+
1224
+ child.on('error', (err) => {
1225
+ console.error(`Failed to tail logs: ${err.message}`);
1226
+ process.exit(1);
1227
+ });
1228
+
1229
+ child.on('close', (code) => {
1230
+ process.exit(code || 0);
1231
+ });
1232
+
1233
+ process.on('SIGINT', () => child.kill('SIGINT'));
1234
+ process.on('SIGTERM', () => child.kill('SIGTERM'));
1235
+ }
1236
+
1237
+ async function cmdHooks(args) {
1238
+ const subcommand = args[0];
1239
+
1240
+ if (subcommand === 'install') {
1241
+ installHooksToAllProfiles();
1242
+ } else if (subcommand === 'status') {
1243
+ showHooksStatus();
1244
+ } else {
1245
+ console.log('Usage:');
1246
+ console.log(' claude-nonstop hooks install Install hooks into all profile settings');
1247
+ console.log(' claude-nonstop hooks status Show hook status for all profiles');
1248
+ }
1249
+ }
1250
+
1251
+ function getHookCommand(hookType) {
1252
+ const hookScript = join(PROJECT_ROOT, 'remote', 'hook-notify.cjs');
1253
+ const typeArg = {
1254
+ 'Stop': 'completed',
1255
+ 'SessionStart': 'session-start',
1256
+ 'PostToolUse': 'tool-use',
1257
+ 'PreToolUse': 'waiting-for-input',
1258
+ }[hookType];
1259
+ return `node "${hookScript}" ${typeArg}`;
1260
+ }
1261
+
1262
+ function installHooksToAllProfiles() {
1263
+ const accounts = getAccounts();
1264
+ const hookScript = join(PROJECT_ROOT, 'remote', 'hook-notify.cjs');
1265
+
1266
+ if (!existsSync(hookScript)) {
1267
+ console.error(`Hook script not found: ${hookScript}`);
1268
+ process.exit(1);
1269
+ }
1270
+
1271
+ const hookTypes = ['Stop', 'SessionStart', 'PostToolUse', 'PreToolUse'];
1272
+
1273
+ for (const account of accounts) {
1274
+ const settingsPath = join(account.configDir, 'settings.json');
1275
+ let settings = {};
1276
+
1277
+ if (existsSync(settingsPath)) {
1278
+ try {
1279
+ let raw = readFileSync(settingsPath, 'utf8');
1280
+ // Strip ANSI escape codes if present (corrupted by terminal color output)
1281
+ raw = raw.replace(/\x1b\[[0-9;]*m/g, '');
1282
+ settings = JSON.parse(raw);
1283
+ } catch {
1284
+ console.warn(` Warning: Could not parse ${settingsPath}, preserving skipDangerousModePermissionPrompt`);
1285
+ settings = { skipDangerousModePermissionPrompt: true };
1286
+ }
1287
+ }
1288
+
1289
+ if (!settings.hooks) settings.hooks = {};
1290
+
1291
+ for (const hookType of hookTypes) {
1292
+ const command = getHookCommand(hookType);
1293
+ const hookEntry = {
1294
+ type: 'command',
1295
+ command,
1296
+ };
1297
+ // SessionStart needs a timeout since it makes API calls
1298
+ if (hookType === 'SessionStart') {
1299
+ hookEntry.timeout = 10;
1300
+ }
1301
+ // PostToolUse runs async so it doesn't block Claude's agentic loop
1302
+ if (hookType === 'PostToolUse') {
1303
+ hookEntry.timeout = 15;
1304
+ }
1305
+ // PreToolUse for waiting-for-input needs a timeout for Slack API calls
1306
+ if (hookType === 'PreToolUse') {
1307
+ hookEntry.timeout = 15;
1308
+ }
1309
+
1310
+ const matcher = { matcher: '', hooks: [hookEntry] };
1311
+ // PreToolUse only fires for tools that pause Claude for user input
1312
+ if (hookType === 'PreToolUse') {
1313
+ matcher.matcher = 'ExitPlanMode|AskUserQuestion';
1314
+ }
1315
+ // PostToolUse and PreToolUse must not block Claude Code
1316
+ if (hookType === 'PostToolUse' || hookType === 'PreToolUse') {
1317
+ matcher.async = true;
1318
+ }
1319
+
1320
+ // Remove old Claude-Code-Remote hooks and any previous version of our hook
1321
+ if (settings.hooks[hookType]) {
1322
+ settings.hooks[hookType] = settings.hooks[hookType].filter(m => {
1323
+ if (!m.hooks) return true;
1324
+ // Remove matchers whose only hook is an old claude-hook-notify or hook-notify
1325
+ const isOldHook = m.hooks.every(h =>
1326
+ h.command?.includes('claude-hook-notify.js') ||
1327
+ h.command?.includes('hook-notify.cjs')
1328
+ );
1329
+ return !isOldHook;
1330
+ });
1331
+ }
1332
+
1333
+ // Add our hook
1334
+ if (!settings.hooks[hookType]) {
1335
+ settings.hooks[hookType] = [];
1336
+ }
1337
+ settings.hooks[hookType].push(matcher);
1338
+ }
1339
+
1340
+ // Ensure the settings directory exists
1341
+ const settingsDir = dirname(settingsPath);
1342
+ if (!existsSync(settingsDir)) {
1343
+ mkdirSync(settingsDir, { recursive: true });
1344
+ }
1345
+
1346
+ const tmpSettings = join(settingsDir, `.settings.${process.pid}.${Date.now()}.tmp`);
1347
+ writeFileSync(tmpSettings, JSON.stringify(settings, null, 2) + '\n', { mode: 0o600 });
1348
+ renameSync(tmpSettings, settingsPath);
1349
+ console.log(` Installed hooks: ${account.name} (${settingsPath})`);
1350
+ }
1351
+
1352
+ console.log(`\nHooks installed for ${accounts.length} profile(s).`);
1353
+ }
1354
+
1355
+ function showHooksStatus() {
1356
+ const accounts = getAccounts();
1357
+ const hookTypes = ['Stop', 'SessionStart', 'PostToolUse', 'PreToolUse'];
1358
+
1359
+ for (const account of accounts) {
1360
+ console.log(`\n ${account.name} (${account.configDir})`);
1361
+ const settingsPath = join(account.configDir, 'settings.json');
1362
+
1363
+ if (!existsSync(settingsPath)) {
1364
+ console.log(' No settings.json found');
1365
+ continue;
1366
+ }
1367
+
1368
+ try {
1369
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
1370
+
1371
+ for (const hookType of hookTypes) {
1372
+ const hooks = settings.hooks?.[hookType];
1373
+ if (!hooks) {
1374
+ console.log(` ${hookType}: not configured`);
1375
+ continue;
1376
+ }
1377
+
1378
+ const hasOurHook = hooks.some(m =>
1379
+ m.hooks?.some(h => h.command?.includes('hook-notify.cjs'))
1380
+ );
1381
+ console.log(` ${hookType}: ${hasOurHook ? 'installed' : 'missing (other hooks present)'}`);
1382
+ }
1383
+ } catch {
1384
+ console.log(' Error reading settings.json');
1385
+ }
1386
+ }
1387
+ console.log('');
1388
+ }
1389
+
1390
+ // ─── Uninstall ──────────────────────────────────────────────────────────────────
1391
+
1392
+ async function cmdUninstall(uninstallArgs = []) {
1393
+ const force = uninstallArgs.includes('--force');
1394
+
1395
+ if (!force) {
1396
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1397
+ const answer = await new Promise((resolve) => {
1398
+ rl.question('This will remove claude-nonstop completely. Continue? [y/N] ', resolve);
1399
+ });
1400
+ rl.close();
1401
+
1402
+ if (answer.toLowerCase() !== 'y') {
1403
+ console.log('Aborted.');
1404
+ return;
1405
+ }
1406
+ }
1407
+
1408
+ // 1. Stop and remove launchd service
1409
+ if (isMacOS() && isServiceInstalled()) {
1410
+ console.log('Stopping webhook service...');
1411
+ try {
1412
+ uninstallService();
1413
+ console.log(' Webhook service removed.');
1414
+ } catch (err) {
1415
+ console.warn(` Warning: ${err.message}`);
1416
+ }
1417
+ }
1418
+
1419
+ // 2. Remove our hooks from all profiles' settings.json
1420
+ console.log('Removing hooks from settings...');
1421
+ removeHooksFromAllProfiles();
1422
+
1423
+ // 3. Remove keychain credentials for non-default accounts
1424
+ const accounts = getAccounts();
1425
+ const keychainAccounts = accounts.filter(a => a.configDir !== DEFAULT_CLAUDE_DIR);
1426
+ if (keychainAccounts.length > 0) {
1427
+ console.log('Removing keychain credentials...');
1428
+ for (const account of keychainAccounts) {
1429
+ const result = deleteKeychainEntry(account.configDir);
1430
+ if (result.deleted) {
1431
+ console.log(` ${account.name}: removed`);
1432
+ } else if (result.error) {
1433
+ console.warn(` ${account.name}: warning: ${result.error}`);
1434
+ } else {
1435
+ console.log(` ${account.name}: not found (already clean)`);
1436
+ }
1437
+ }
1438
+ }
1439
+
1440
+ // 4. Remove ~/.claude-nonstop/ directory
1441
+ if (existsSync(CONFIG_DIR)) {
1442
+ console.log(`Removing ${CONFIG_DIR}...`);
1443
+ rmSync(CONFIG_DIR, { recursive: true, force: true });
1444
+ console.log(' Config directory removed.');
1445
+ }
1446
+
1447
+ // 5. npm unlink
1448
+ console.log('Unlinking CLI...');
1449
+ try {
1450
+ const child = spawn('npm', ['unlink', '--global', 'claude-nonstop'], { stdio: 'pipe' });
1451
+ const exitCode = await new Promise((resolve) => child.on('close', resolve));
1452
+ if (exitCode === 0) {
1453
+ console.log(' CLI unlinked.');
1454
+ } else {
1455
+ console.warn(' Warning: npm unlink exited with code', exitCode);
1456
+ console.warn(' You may need to run "npm unlink -g claude-nonstop" manually.');
1457
+ }
1458
+ } catch {
1459
+ console.warn(' Warning: npm unlink failed. You may need to run "npm unlink -g claude-nonstop" manually.');
1460
+ }
1461
+
1462
+ console.log('\nclaude-nonstop has been uninstalled.');
1463
+ }
1464
+
1465
+ function removeHooksFromAllProfiles() {
1466
+ // Remove hooks from all known profile settings AND the default ~/.claude
1467
+ const accounts = getAccounts();
1468
+
1469
+ // Collect all settings.json paths (profiles + default)
1470
+ const settingsPaths = accounts.map(a => join(a.configDir, 'settings.json'));
1471
+
1472
+ // Also check default ~/.claude/settings.json if not already in accounts
1473
+ const defaultSettings = join(DEFAULT_CLAUDE_DIR, 'settings.json');
1474
+ if (!settingsPaths.includes(defaultSettings)) {
1475
+ settingsPaths.push(defaultSettings);
1476
+ }
1477
+
1478
+ for (const settingsPath of settingsPaths) {
1479
+ if (!existsSync(settingsPath)) continue;
1480
+
1481
+ try {
1482
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
1483
+ if (!settings.hooks) continue;
1484
+
1485
+ let modified = false;
1486
+ for (const hookType of ['Stop', 'SessionStart', 'PostToolUse', 'PreToolUse']) {
1487
+ if (!settings.hooks[hookType]) continue;
1488
+
1489
+ const filtered = settings.hooks[hookType].filter(m => {
1490
+ if (!m.hooks) return true;
1491
+ const isOurHook = m.hooks.every(h =>
1492
+ h.command?.includes('hook-notify.cjs')
1493
+ );
1494
+ return !isOurHook;
1495
+ });
1496
+
1497
+ if (filtered.length !== settings.hooks[hookType].length) {
1498
+ settings.hooks[hookType] = filtered;
1499
+ modified = true;
1500
+ }
1501
+
1502
+ // Remove empty hook arrays
1503
+ if (settings.hooks[hookType].length === 0) {
1504
+ delete settings.hooks[hookType];
1505
+ }
1506
+ }
1507
+
1508
+ // Remove empty hooks object
1509
+ if (Object.keys(settings.hooks).length === 0) {
1510
+ delete settings.hooks;
1511
+ }
1512
+
1513
+ if (modified) {
1514
+ const settingsDir = dirname(settingsPath);
1515
+ const tmpSettings = join(settingsDir, `.settings.${process.pid}.${Date.now()}.tmp`);
1516
+ writeFileSync(tmpSettings, JSON.stringify(settings, null, 2) + '\n', { mode: 0o600 });
1517
+ renameSync(tmpSettings, settingsPath);
1518
+ console.log(` Removed hooks: ${settingsPath}`);
1519
+ }
1520
+ } catch {
1521
+ // Skip files we can't parse
1522
+ }
1523
+ }
1524
+ }
1525
+
1526
+ // ─── Helpers ───────────────────────────────────────────────────────────────────
1527
+
1528
+ function parseSetupFlags(args) {
1529
+ const flags = {};
1530
+ let fromEnv = false;
1531
+
1532
+ const flagMap = {
1533
+ '--bot-token': 'botToken',
1534
+ '--app-token': 'appToken',
1535
+ '--channel-id': 'channelId',
1536
+ '--allowed-users': 'allowedUsers',
1537
+ '--invite-user-id': 'inviteUserId',
1538
+ '--channel-prefix': 'channelPrefix',
1539
+ '--default-tmux-session': 'defaultTmuxSession',
1540
+ };
1541
+
1542
+ for (let i = 0; i < args.length; i++) {
1543
+ const arg = args[i];
1544
+
1545
+ if (arg === '--from-env') {
1546
+ fromEnv = true;
1547
+ continue;
1548
+ }
1549
+
1550
+ // Handle --flag=value
1551
+ const eqIdx = arg.indexOf('=');
1552
+ if (eqIdx !== -1) {
1553
+ const key = arg.substring(0, eqIdx);
1554
+ const value = arg.substring(eqIdx + 1);
1555
+ if (flagMap[key]) {
1556
+ flags[flagMap[key]] = value;
1557
+ continue;
1558
+ }
1559
+ }
1560
+
1561
+ // Handle --flag value
1562
+ if (flagMap[arg] && i + 1 < args.length) {
1563
+ flags[flagMap[arg]] = args[i + 1];
1564
+ i++;
1565
+ continue;
1566
+ }
1567
+ }
1568
+
1569
+ return { flags, fromEnv };
1570
+ }
1571
+
1572
+ /**
1573
+ * Extract --account <name> or -a <name> from args array.
1574
+ * Splices the flag and value out of the array in-place.
1575
+ * Returns the account name string, or null if not specified.
1576
+ */
1577
+ function extractAccountFlag(args) {
1578
+ for (let i = 0; i < args.length; i++) {
1579
+ if (args[i] === '--account' || args[i] === '-a') {
1580
+ if (i + 1 >= args.length || args[i + 1].startsWith('-')) {
1581
+ console.error(`Error: ${args[i]} requires an account name.`);
1582
+ process.exit(1);
1583
+ }
1584
+ const name = args[i + 1];
1585
+ args.splice(i, 2);
1586
+ return name;
1587
+ }
1588
+ }
1589
+ return null;
1590
+ }
1591
+
1592
+ function printHelp() {
1593
+ console.log(`
1594
+ claude-nonstop — Multi-account switching + Slack remote access for Claude Code
1595
+
1596
+ Usage:
1597
+ claude-nonstop Run Claude (best account, auto-switching)
1598
+ claude-nonstop -p "prompt" One-shot prompt
1599
+ claude-nonstop status Show usage across all accounts
1600
+ claude-nonstop --remote-access Run with tmux + Slack channels
1601
+
1602
+ Commands:
1603
+ status Show usage with progress bars and reset times
1604
+ add <name> Add a new Claude account
1605
+ remove <name> Remove an account
1606
+ list List accounts with auth status
1607
+ reauth Re-authenticate expired accounts
1608
+ resume [id] Resume most recent session, or a specific one by ID
1609
+ init <bash|zsh> Shell integration (add to ~/.bashrc or ~/.zshrc):
1610
+ eval "$(claude-nonstop init bash)"
1611
+ use [name|flag] Switch active account for current shell (Agent SDK, etc.)
1612
+ use <name> Explicit account
1613
+ use --best Lowest utilization (ignores priority)
1614
+ use --priority Highest priority under 98% usage
1615
+ use --unset Revert to default ~/.claude
1616
+ use Show current active account
1617
+ set-priority <name> <n> Set account priority (1 = highest). Use "clear" to remove.
1618
+ setup Configure Slack remote access
1619
+ webhook Webhook service management
1620
+ hooks Hook management
1621
+ update Update to latest version
1622
+ uninstall Remove claude-nonstop completely
1623
+
1624
+ Options:
1625
+ -a, --account <name> Use a specific account
1626
+ --remote-access Run in tmux with Slack channels
1627
+
1628
+ All other arguments are passed through to \`claude\`.
1629
+ Run \`setup --help\`, \`webhook\`, or \`hooks\` for subcommand details.
1630
+ `.trim());
1631
+ }
1632
+
1633
+ function formatUserInfo({ name, email, orgName, orgType } = {}) {
1634
+ const who = name && email ? `${name} — ${email}` : (name || email || '');
1635
+ const org = formatOrgSuffix({ orgName, orgType });
1636
+ const inner = [who, org].filter(Boolean).join(' · ');
1637
+ return inner ? ` (${inner})` : '';
1638
+ }
1639
+
1640
+ /** Human-readable org suffix, e.g. "Speak [enterprise]". */
1641
+ function formatOrgSuffix({ orgName, orgType } = {}) {
1642
+ if (!orgName) return '';
1643
+ const t = orgType ? orgType.replace(/^claude_/, '') : '';
1644
+ return t ? `${orgName} [${t}]` : orgName;
1645
+ }
1646
+
1647
+ /** Full identity label for add/dedup messages, e.g. "sj@x.com · Speak [enterprise]". */
1648
+ function formatOrgLabel({ email, orgName, orgType } = {}) {
1649
+ const org = formatOrgSuffix({ orgName, orgType });
1650
+ return [email, org].filter(Boolean).join(' · ');
1651
+ }
1652
+
1653
+ function makeBar(percent, width = 20) {
1654
+ const filled = Math.round((percent / 100) * width);
1655
+ const empty = width - filled;
1656
+ const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(empty);
1657
+
1658
+ if (percent >= 95) return `\x1b[31m${bar}\x1b[0m`; // Red
1659
+ if (percent >= 70) return `\x1b[33m${bar}\x1b[0m`; // Yellow
1660
+ return `\x1b[32m${bar}\x1b[0m`; // Green
1661
+ }
1662
+
1663
+ function formatResetTime(isoString) {
1664
+ try {
1665
+ const date = new Date(isoString);
1666
+ const now = new Date();
1667
+ const diffMs = date.getTime() - now.getTime();
1668
+
1669
+ if (diffMs <= 0) return 'now';
1670
+
1671
+ const hours = Math.floor(diffMs / (1000 * 60 * 60));
1672
+ const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
1673
+
1674
+ if (hours > 0) return `in ${hours}h ${minutes}m`;
1675
+ return `in ${minutes}m`;
1676
+ } catch {
1677
+ return isoString;
1678
+ }
1679
+ }