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.
- package/.env.example +33 -0
- package/LICENSE +21 -0
- package/README.md +262 -0
- package/assets/icon.jpeg +0 -0
- package/assets/screenshot.png +0 -0
- package/bin/claude-nonstop.js +1679 -0
- package/lib/config.js +163 -0
- package/lib/keychain.js +397 -0
- package/lib/platform.js +9 -0
- package/lib/reauth.js +147 -0
- package/lib/runner.js +566 -0
- package/lib/scorer.js +100 -0
- package/lib/service.js +196 -0
- package/lib/session.js +294 -0
- package/lib/tmux.js +95 -0
- package/lib/usage.js +146 -0
- package/package.json +56 -0
- package/remote/channel-manager.cjs +548 -0
- package/remote/hook-notify.cjs +504 -0
- package/remote/load-env.cjs +32 -0
- package/remote/paths.cjs +17 -0
- package/remote/start-webhook.cjs +97 -0
- package/remote/webhook.cjs +228 -0
- package/scripts/postinstall.js +40 -0
- package/slack-manifest.yaml +32 -0
|
@@ -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
|
+
}
|