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/lib/config.js ADDED
@@ -0,0 +1,163 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'fs';
2
+ import { homedir } from 'os';
3
+ import { join, normalize } from 'path';
4
+
5
+ const CONFIG_DIR = join(homedir(), '.claude-nonstop');
6
+ const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
7
+ const PROFILES_DIR = join(CONFIG_DIR, 'profiles');
8
+ const DEFAULT_CLAUDE_DIR = normalize(join(homedir(), '.claude'));
9
+
10
+ /**
11
+ * Ensure the config directory and profiles directory exist.
12
+ */
13
+ export function ensureConfigDir() {
14
+ if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true });
15
+ if (!existsSync(PROFILES_DIR)) mkdirSync(PROFILES_DIR, { recursive: true });
16
+ }
17
+
18
+ /**
19
+ * Load config from disk. Returns { accounts: [{name, configDir}] }.
20
+ * Pure read — does not write to disk.
21
+ */
22
+ export function loadConfig() {
23
+ ensureConfigDir();
24
+
25
+ let config = { accounts: [] };
26
+
27
+ if (existsSync(CONFIG_FILE)) {
28
+ try {
29
+ config = JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'));
30
+ } catch {
31
+ config = { accounts: [] };
32
+ }
33
+ }
34
+
35
+ return config;
36
+ }
37
+
38
+ /**
39
+ * Ensure the default ~/.claude account is registered if it exists on disk.
40
+ * Call once at CLI startup, not on every read.
41
+ */
42
+ export function ensureDefaultAccount() {
43
+ const config = loadConfig();
44
+ const hasDefault = config.accounts.some(a => a.configDir === DEFAULT_CLAUDE_DIR);
45
+ if (!hasDefault && existsSync(DEFAULT_CLAUDE_DIR)) {
46
+ config.accounts.unshift({ name: 'default', configDir: DEFAULT_CLAUDE_DIR });
47
+ saveConfig(config);
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Save config to disk using atomic write (write-to-temp + rename).
53
+ */
54
+ export function saveConfig(config) {
55
+ ensureConfigDir();
56
+ const tmpFile = `${CONFIG_FILE}.${process.pid}.${Date.now()}.tmp`;
57
+ writeFileSync(tmpFile, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
58
+ renameSync(tmpFile, CONFIG_FILE);
59
+ }
60
+
61
+ const VALID_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
62
+ const MAX_NAME_LENGTH = 64;
63
+
64
+ /**
65
+ * Validate an account name for safety.
66
+ * Allows letters, numbers, hyphens, and underscores only.
67
+ */
68
+ export function validateAccountName(name) {
69
+ if (!name || typeof name !== 'string') {
70
+ throw new Error('Account name is required');
71
+ }
72
+ if (name.length > MAX_NAME_LENGTH) {
73
+ throw new Error(`Account name must be ${MAX_NAME_LENGTH} characters or fewer`);
74
+ }
75
+ if (!VALID_NAME_PATTERN.test(name)) {
76
+ throw new Error('Account name may only contain letters, numbers, hyphens, and underscores');
77
+ }
78
+ if (name.includes('..') || name.includes('/') || name.includes('\\')) {
79
+ throw new Error('Account name contains invalid characters');
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Add a new account. Returns the configDir for the new profile.
85
+ */
86
+ export function addAccount(name) {
87
+ validateAccountName(name);
88
+ const config = loadConfig();
89
+
90
+ if (config.accounts.some(a => a.name === name)) {
91
+ throw new Error(`Account "${name}" already exists`);
92
+ }
93
+
94
+ const configDir = join(PROFILES_DIR, name);
95
+ if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true });
96
+
97
+ config.accounts.push({ name, configDir });
98
+ saveConfig(config);
99
+
100
+ return configDir;
101
+ }
102
+
103
+ /**
104
+ * Remove an account by name.
105
+ */
106
+ export function removeAccount(name) {
107
+ const config = loadConfig();
108
+ const idx = config.accounts.findIndex(a => a.name === name);
109
+
110
+ if (idx === -1) throw new Error(`Account "${name}" not found`);
111
+ if (config.accounts[idx].configDir === DEFAULT_CLAUDE_DIR) {
112
+ throw new Error('Cannot remove the default account');
113
+ }
114
+
115
+ config.accounts.splice(idx, 1);
116
+ saveConfig(config);
117
+ }
118
+
119
+ /**
120
+ * Set priority for an account. Lower number = higher priority.
121
+ *
122
+ * @param {string} name - Account name
123
+ * @param {number} priority - Positive integer (1 = highest priority)
124
+ */
125
+ export function setAccountPriority(name, priority) {
126
+ validateAccountName(name);
127
+ if (!Number.isInteger(priority) || priority < 1) {
128
+ throw new Error('Priority must be a positive integer (1 = highest)');
129
+ }
130
+
131
+ const config = loadConfig();
132
+ const account = config.accounts.find(a => a.name === name);
133
+
134
+ if (!account) throw new Error(`Account "${name}" not found`);
135
+
136
+ account.priority = priority;
137
+ saveConfig(config);
138
+ }
139
+
140
+ /**
141
+ * Remove priority from an account (reverts to unranked).
142
+ *
143
+ * @param {string} name - Account name
144
+ */
145
+ export function clearAccountPriority(name) {
146
+ validateAccountName(name);
147
+ const config = loadConfig();
148
+ const account = config.accounts.find(a => a.name === name);
149
+
150
+ if (!account) throw new Error(`Account "${name}" not found`);
151
+
152
+ delete account.priority;
153
+ saveConfig(config);
154
+ }
155
+
156
+ /**
157
+ * Get all registered accounts.
158
+ */
159
+ export function getAccounts() {
160
+ return loadConfig().accounts;
161
+ }
162
+
163
+ export { CONFIG_DIR, PROFILES_DIR, DEFAULT_CLAUDE_DIR };
@@ -0,0 +1,397 @@
1
+ /**
2
+ * Credential reading and token refresh for OS-specific secure storage.
3
+ *
4
+ * macOS: Keychain via `security find-generic-password` / `add-generic-password`
5
+ * Linux: Secret Service via `secret-tool` or ~/.credentials.json fallback
6
+ *
7
+ * Service name format:
8
+ * - Default (~/.claude): "Claude Code-credentials"
9
+ * - Custom dirs: "Claude Code-credentials-{sha256_8(expandedPath)}"
10
+ *
11
+ * Token refresh uses the same OAuth endpoint and client ID as Claude Code CLI,
12
+ * so both tools share the same credentials in the keychain seamlessly.
13
+ */
14
+
15
+ import { execFileSync } from 'child_process';
16
+ import { createHash } from 'crypto';
17
+ import { existsSync, readFileSync, writeFileSync, renameSync } from 'fs';
18
+ import { homedir, userInfo } from 'os';
19
+ import { join, normalize } from 'path';
20
+ import { isMacOS, isLinux } from './platform.js';
21
+ import { DEFAULT_CLAUDE_DIR } from './config.js';
22
+
23
+ const OAUTH_TOKEN_URL = 'https://console.anthropic.com/v1/oauth/token';
24
+ const OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
25
+ const REFRESH_TIMEOUT_MS = 10_000;
26
+
27
+ /**
28
+ * Get the macOS Keychain account name.
29
+ *
30
+ * Claude Code CLI uses the system username (e.g. "rc") as the `-a` account
31
+ * field. We must match this exactly, otherwise `add-generic-password -U`
32
+ * creates a second entry instead of updating the existing one, and
33
+ * `find-generic-password` without `-a` returns whichever entry it finds
34
+ * first — leading to stale-token bugs after silent refresh.
35
+ */
36
+ function getKeychainAccount() {
37
+ return userInfo().username;
38
+ }
39
+
40
+ /**
41
+ * Compute the 8-char SHA256 hash suffix for a config directory.
42
+ * Matches Claude Code CLI's hash computation.
43
+ */
44
+ export function calculateConfigDirHash(configDir) {
45
+ const expanded = expandPath(configDir);
46
+ return createHash('sha256').update(expanded).digest('hex').slice(0, 8);
47
+ }
48
+
49
+ /**
50
+ * Expand ~ to homedir and normalize the path.
51
+ */
52
+ export function expandPath(p) {
53
+ const expanded = p.startsWith('~') ? p.replace(/^~/, homedir()) : p;
54
+ return normalize(expanded);
55
+ }
56
+
57
+ /**
58
+ * Get the Keychain service name for a config directory.
59
+ *
60
+ * Default (~/.claude) uses "Claude Code-credentials" (no hash) —
61
+ * this matches the standard Claude Code CLI behavior.
62
+ * Custom dirs use "Claude Code-credentials-{hash}" for isolation.
63
+ */
64
+ export function getServiceName(configDir) {
65
+ const expanded = expandPath(configDir);
66
+
67
+ if (expanded === DEFAULT_CLAUDE_DIR) {
68
+ return 'Claude Code-credentials';
69
+ }
70
+
71
+ const hash = calculateConfigDirHash(configDir);
72
+ return `Claude Code-credentials-${hash}`;
73
+ }
74
+
75
+ /**
76
+ * Read OAuth credentials from the platform credential store.
77
+ *
78
+ * @param {string} configDir - The CLAUDE_CONFIG_DIR path
79
+ * @returns {{ token: string|null, email: string|null, name: string|null, error: string|null }}
80
+ */
81
+ export function readCredentials(configDir) {
82
+ if (isMacOS()) {
83
+ return readFromMacKeychain(configDir);
84
+ }
85
+ if (isLinux()) {
86
+ return readFromLinux(configDir);
87
+ }
88
+ return { token: null, email: null, name: null, expiresAt: null, error: 'unsupported_platform' };
89
+ }
90
+
91
+ /**
92
+ * macOS: Read from Keychain using `security` command.
93
+ */
94
+ function readFromMacKeychain(configDir) {
95
+ const serviceName = getServiceName(configDir);
96
+
97
+ try {
98
+ const raw = execFileSync('security', [
99
+ 'find-generic-password',
100
+ '-s', serviceName,
101
+ '-a', getKeychainAccount(),
102
+ '-w'
103
+ ], { encoding: 'utf-8', timeout: 5000 }).trim();
104
+
105
+ return parseCredentialJson(raw);
106
+ } catch (error) {
107
+ // Exit code 44 = errSecItemNotFound (item not in keychain)
108
+ if (error?.status === 44) {
109
+ return { token: null, email: null, name: null, expiresAt: null, error: 'not_found' };
110
+ }
111
+ return { token: null, email: null, name: null, expiresAt: null, error: error.message };
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Linux: Try secret-tool first, fall back to .credentials.json file.
117
+ */
118
+ function readFromLinux(configDir) {
119
+ const expanded = expandPath(configDir);
120
+ const serviceName = getServiceName(configDir);
121
+
122
+ // Try secret-tool (GNOME Keyring / KDE Wallet)
123
+ try {
124
+ const raw = execFileSync('secret-tool', [
125
+ 'lookup',
126
+ 'service', serviceName
127
+ ], { encoding: 'utf-8', timeout: 5000 }).trim();
128
+
129
+ if (raw) return parseCredentialJson(raw);
130
+ } catch {
131
+ // Fall through to file-based
132
+ }
133
+
134
+ // Fall back to .credentials.json
135
+ const credFile = join(expanded, '.credentials.json');
136
+ if (existsSync(credFile)) {
137
+ try {
138
+ const raw = readFileSync(credFile, 'utf-8');
139
+ return parseCredentialJson(raw);
140
+ } catch {
141
+ return { token: null, email: null, name: null, expiresAt: null, error: 'parse_failed' };
142
+ }
143
+ }
144
+
145
+ return { token: null, email: null, name: null, expiresAt: null, error: 'not_found' };
146
+ }
147
+
148
+ /**
149
+ * Parse the credential JSON blob and extract token + email.
150
+ *
151
+ * Claude Code stores credentials as:
152
+ * {
153
+ * "claudeAiOauth": {
154
+ * "accessToken": "sk-ant-oat01-...",
155
+ * "refreshToken": "sk-ant-ort01-...",
156
+ * "email": "user@example.com",
157
+ * "expiresAt": 1234567890000
158
+ * }
159
+ * }
160
+ */
161
+ export function parseCredentialJson(raw) {
162
+ try {
163
+ const data = JSON.parse(raw);
164
+ const oauth = data?.claudeAiOauth;
165
+
166
+ if (!oauth) {
167
+ return { token: null, email: null, name: null, expiresAt: null, error: 'no_oauth_data' };
168
+ }
169
+
170
+ const token = oauth.accessToken || null;
171
+ const email = oauth.email || oauth.emailAddress || data?.email || null;
172
+ const name = oauth.name || oauth.fullName || oauth.displayName || data?.name || null;
173
+ const expiresAt = oauth.expiresAt || null;
174
+
175
+ if (token && !token.startsWith('sk-ant-')) {
176
+ return { token: null, email, name, expiresAt, error: 'invalid_token_format' };
177
+ }
178
+
179
+ return { token, email, name, expiresAt, error: null };
180
+ } catch {
181
+ return { token: null, email: null, name: null, expiresAt: null, error: 'parse_failed' };
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Read the raw credential JSON blob from the credential store.
187
+ * Returns the full JSON string, not parsed — needed for read-modify-write.
188
+ *
189
+ * @param {string} configDir - The CLAUDE_CONFIG_DIR path
190
+ * @returns {string|null} Raw JSON string or null if not found
191
+ */
192
+ function readRawCredentialBlob(configDir) {
193
+ const serviceName = getServiceName(configDir);
194
+
195
+ if (isMacOS()) {
196
+ try {
197
+ return execFileSync('security', [
198
+ 'find-generic-password', '-s', serviceName, '-a', getKeychainAccount(), '-w'
199
+ ], { encoding: 'utf-8', timeout: 5000 }).trim();
200
+ } catch {
201
+ return null;
202
+ }
203
+ }
204
+
205
+ if (isLinux()) {
206
+ try {
207
+ const raw = execFileSync('secret-tool', [
208
+ 'lookup', 'service', serviceName
209
+ ], { encoding: 'utf-8', timeout: 5000 }).trim();
210
+ if (raw) return raw;
211
+ } catch { /* fall through */ }
212
+
213
+ const expanded = expandPath(configDir);
214
+ const credFile = join(expanded, '.credentials.json');
215
+ if (existsSync(credFile)) {
216
+ try { return readFileSync(credFile, 'utf-8'); } catch { /* fall through */ }
217
+ }
218
+ }
219
+
220
+ return null;
221
+ }
222
+
223
+ /**
224
+ * Write a credential JSON blob back to the credential store.
225
+ * Both Claude Code and claude-nonstop share the same keychain entries.
226
+ *
227
+ * @param {string} configDir - The CLAUDE_CONFIG_DIR path
228
+ * @param {string} jsonString - The full credential JSON to write
229
+ * @returns {{ written: boolean, error: string|null }}
230
+ */
231
+ function writeCredentialBlob(configDir, jsonString) {
232
+ const serviceName = getServiceName(configDir);
233
+
234
+ if (isMacOS()) {
235
+ try {
236
+ // -U flag updates existing entry (or creates if missing)
237
+ execFileSync('security', [
238
+ 'add-generic-password',
239
+ '-s', serviceName,
240
+ '-a', getKeychainAccount(),
241
+ '-w', jsonString,
242
+ '-U'
243
+ ], { stdio: 'pipe', timeout: 5000 });
244
+ return { written: true, error: null };
245
+ } catch (error) {
246
+ return { written: false, error: error.message };
247
+ }
248
+ }
249
+
250
+ if (isLinux()) {
251
+ // Write to .credentials.json (atomic write)
252
+ const expanded = expandPath(configDir);
253
+ const credFile = join(expanded, '.credentials.json');
254
+ try {
255
+ const tmpFile = `${credFile}.${process.pid}.tmp`;
256
+ writeFileSync(tmpFile, jsonString, { mode: 0o600 });
257
+ renameSync(tmpFile, credFile);
258
+ return { written: true, error: null };
259
+ } catch (error) {
260
+ return { written: false, error: error.message };
261
+ }
262
+ }
263
+
264
+ return { written: false, error: 'unsupported_platform' };
265
+ }
266
+
267
+ /**
268
+ * Refresh an expired access token using the OAuth refresh token.
269
+ *
270
+ * Uses the same endpoint and client ID as Claude Code CLI, so the refreshed
271
+ * tokens are written back to the shared keychain entry. Both Claude Code and
272
+ * claude-nonstop will see the fresh tokens.
273
+ *
274
+ * Refresh tokens are single-use — the new refresh token MUST be saved back.
275
+ *
276
+ * @param {string} configDir - The CLAUDE_CONFIG_DIR path
277
+ * @returns {Promise<{ token: string|null, email: string|null, name: string|null, expiresAt: number|null, error: string|null }>}
278
+ */
279
+ export async function refreshAccessToken(configDir) {
280
+ const raw = readRawCredentialBlob(configDir);
281
+ if (!raw) {
282
+ return { token: null, email: null, name: null, expiresAt: null, error: 'no_credentials' };
283
+ }
284
+
285
+ let data;
286
+ try {
287
+ data = JSON.parse(raw);
288
+ } catch {
289
+ return { token: null, email: null, name: null, expiresAt: null, error: 'parse_failed' };
290
+ }
291
+
292
+ const refreshToken = data?.claudeAiOauth?.refreshToken;
293
+ if (!refreshToken) {
294
+ return { token: null, email: null, name: null, expiresAt: null, error: 'no_refresh_token' };
295
+ }
296
+
297
+ // Call the OAuth token endpoint
298
+ try {
299
+ const controller = new AbortController();
300
+ const timeoutId = setTimeout(() => controller.abort(), REFRESH_TIMEOUT_MS);
301
+
302
+ const res = await fetch(OAUTH_TOKEN_URL, {
303
+ method: 'POST',
304
+ headers: { 'Content-Type': 'application/json' },
305
+ body: JSON.stringify({
306
+ grant_type: 'refresh_token',
307
+ refresh_token: refreshToken,
308
+ client_id: OAUTH_CLIENT_ID,
309
+ }),
310
+ signal: controller.signal,
311
+ });
312
+
313
+ clearTimeout(timeoutId);
314
+
315
+ if (!res.ok) {
316
+ const body = await res.json().catch(() => ({}));
317
+ return { token: null, email: null, name: null, expiresAt: null, error: body.error || `HTTP ${res.status}` };
318
+ }
319
+
320
+ const tokens = await res.json();
321
+ if (!tokens.access_token) {
322
+ return { token: null, email: null, name: null, expiresAt: null, error: 'no_access_token_in_response' };
323
+ }
324
+
325
+ // Update the credential blob with new tokens
326
+ data.claudeAiOauth.accessToken = tokens.access_token;
327
+ if (tokens.refresh_token) {
328
+ data.claudeAiOauth.refreshToken = tokens.refresh_token;
329
+ }
330
+ data.claudeAiOauth.expiresAt = Date.now() + (tokens.expires_in * 1000);
331
+
332
+ // Write back to keychain immediately (refresh tokens are single-use)
333
+ const writeResult = writeCredentialBlob(configDir, JSON.stringify(data));
334
+ if (!writeResult.written) {
335
+ return { token: null, email: null, name: null, expiresAt: null, error: `keychain_write_failed: ${writeResult.error}` };
336
+ }
337
+
338
+ return parseCredentialJson(JSON.stringify(data));
339
+ } catch (error) {
340
+ return { token: null, email: null, name: null, expiresAt: null, error: error.name === 'AbortError' ? 'timeout' : error.message };
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Delete a keychain entry for a config directory.
346
+ *
347
+ * @param {string} configDir - The CLAUDE_CONFIG_DIR path
348
+ * @returns {{ deleted: boolean, error: string|null }}
349
+ */
350
+ export function deleteKeychainEntry(configDir) {
351
+ const serviceName = getServiceName(configDir);
352
+
353
+ if (isMacOS()) {
354
+ try {
355
+ execFileSync('security', [
356
+ 'delete-generic-password',
357
+ '-s', serviceName,
358
+ '-a', getKeychainAccount()
359
+ ], { stdio: 'pipe', timeout: 5000 });
360
+ return { deleted: true, error: null };
361
+ } catch (error) {
362
+ // Exit code 44 = errSecItemNotFound
363
+ if (error?.status === 44) {
364
+ return { deleted: false, error: null };
365
+ }
366
+ return { deleted: false, error: error.message };
367
+ }
368
+ }
369
+
370
+ if (isLinux()) {
371
+ // File-based .credentials.json lives inside the profile dir,
372
+ // which gets removed by rmSync. Only need to clear secret-tool.
373
+ try {
374
+ execFileSync('secret-tool', [
375
+ 'clear',
376
+ 'service', serviceName
377
+ ], { stdio: 'pipe', timeout: 5000 });
378
+ return { deleted: true, error: null };
379
+ } catch {
380
+ // secret-tool may not be installed or entry may not exist
381
+ return { deleted: false, error: null };
382
+ }
383
+ }
384
+
385
+ return { deleted: false, error: 'unsupported_platform' };
386
+ }
387
+
388
+ /**
389
+ * Check if credentials have an expired access token.
390
+ *
391
+ * @param {{ expiresAt: number|null }} creds - Credentials from readCredentials()
392
+ * @returns {boolean}
393
+ */
394
+ export function isTokenExpired(creds) {
395
+ if (!creds.expiresAt) return false;
396
+ return creds.expiresAt < Date.now();
397
+ }
@@ -0,0 +1,9 @@
1
+ import { platform } from 'os';
2
+
3
+ export function isMacOS() {
4
+ return platform() === 'darwin';
5
+ }
6
+
7
+ export function isLinux() {
8
+ return platform() === 'linux';
9
+ }