npm-cli-gh-issue-preparator 1.1.0 → 1.2.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/CHANGELOG.md +7 -0
- package/bin/adapter/repositories/OauthAPIClaudeRepository.js +103 -1
- package/bin/adapter/repositories/OauthAPIClaudeRepository.js.map +1 -1
- package/package.json +1 -1
- package/src/adapter/repositories/OauthAPIClaudeRepository.test.ts +495 -0
- package/src/adapter/repositories/OauthAPIClaudeRepository.ts +141 -5
- package/src/domain/usecases/adapter-interfaces/ClaudeRepository.ts +1 -0
- package/types/adapter/repositories/OauthAPIClaudeRepository.d.ts +4 -0
- package/types/adapter/repositories/OauthAPIClaudeRepository.d.ts.map +1 -1
- package/types/domain/usecases/adapter-interfaces/ClaudeRepository.d.ts +1 -0
- package/types/domain/usecases/adapter-interfaces/ClaudeRepository.d.ts.map +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
# [1.2.0](https://github.com/HiromiShikata/npm-cli-gh-issue-preparator/compare/v1.1.0...v1.2.0) (2026-01-12)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **src:** add isClaudeAvailable method to rotate Claude credentials ([7b966bd](https://github.com/HiromiShikata/npm-cli-gh-issue-preparator/commit/7b966bd123eb9474fd9f675e7addb4b54b509abe))
|
|
7
|
+
|
|
1
8
|
# [1.1.0](https://github.com/HiromiShikata/npm-cli-gh-issue-preparator/compare/v1.0.4...v1.1.0) (2026-01-12)
|
|
2
9
|
|
|
3
10
|
|
|
@@ -47,9 +47,37 @@ const isUsageResponse = (value) => {
|
|
|
47
47
|
return false;
|
|
48
48
|
return true;
|
|
49
49
|
};
|
|
50
|
+
const findCredentials = (filePathList) => {
|
|
51
|
+
const credentials = [];
|
|
52
|
+
const baseFileName = '.credentials.json';
|
|
53
|
+
for (const filePath of filePathList) {
|
|
54
|
+
const fileName = path.basename(filePath);
|
|
55
|
+
if (fileName === baseFileName) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const suffix = fileName.slice(baseFileName.length + 1);
|
|
59
|
+
const parts = suffix.split('.');
|
|
60
|
+
if (parts.length !== 2) {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const name = parts[0];
|
|
64
|
+
const priorityStr = parts[1];
|
|
65
|
+
const priority = parseInt(priorityStr, 10);
|
|
66
|
+
if (isNaN(priority)) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
credentials.push({
|
|
70
|
+
name,
|
|
71
|
+
priority,
|
|
72
|
+
filePath,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return credentials.sort((a, b) => a.priority - b.priority);
|
|
76
|
+
};
|
|
50
77
|
class OauthAPIClaudeRepository {
|
|
51
78
|
constructor() {
|
|
52
|
-
this.
|
|
79
|
+
this.claudeDir = path.join(os.homedir(), '.claude');
|
|
80
|
+
this.credentialsPath = path.join(this.claudeDir, '.credentials.json');
|
|
53
81
|
}
|
|
54
82
|
getAccessToken() {
|
|
55
83
|
if (!fs.existsSync(this.credentialsPath)) {
|
|
@@ -128,6 +156,80 @@ class OauthAPIClaudeRepository {
|
|
|
128
156
|
}
|
|
129
157
|
return usages;
|
|
130
158
|
}
|
|
159
|
+
async getUsageWithToken(accessToken) {
|
|
160
|
+
const response = await fetch('https://api.anthropic.com/api/oauth/usage', {
|
|
161
|
+
method: 'GET',
|
|
162
|
+
headers: {
|
|
163
|
+
Accept: 'application/json, text/plain, */*',
|
|
164
|
+
'Content-Type': 'application/json',
|
|
165
|
+
'User-Agent': 'claude-code/2.0.32',
|
|
166
|
+
Authorization: `Bearer ${accessToken}`,
|
|
167
|
+
'anthropic-beta': 'oauth-2025-04-20',
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
if (!response.ok) {
|
|
171
|
+
const errorText = await response.text();
|
|
172
|
+
throw new Error(`Claude API error: ${errorText}`);
|
|
173
|
+
}
|
|
174
|
+
const responseData = await response.json();
|
|
175
|
+
if (!isUsageResponse(responseData)) {
|
|
176
|
+
throw new Error('Invalid API response format');
|
|
177
|
+
}
|
|
178
|
+
if (responseData.error) {
|
|
179
|
+
throw new Error(`API error: ${responseData.error}`);
|
|
180
|
+
}
|
|
181
|
+
return responseData;
|
|
182
|
+
}
|
|
183
|
+
isUsageUnderThreshold(usageResponse, threshold) {
|
|
184
|
+
const windows = [
|
|
185
|
+
usageResponse.five_hour,
|
|
186
|
+
usageResponse.seven_day,
|
|
187
|
+
usageResponse.seven_day_opus,
|
|
188
|
+
usageResponse.seven_day_sonnet,
|
|
189
|
+
];
|
|
190
|
+
for (const window of windows) {
|
|
191
|
+
if (window?.utilization !== undefined &&
|
|
192
|
+
window.utilization >= threshold) {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
async isClaudeAvailable(threshold) {
|
|
199
|
+
if (!fs.existsSync(this.claudeDir)) {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
const files = fs.readdirSync(this.claudeDir);
|
|
203
|
+
const filePathList = files
|
|
204
|
+
.filter((file) => file.startsWith('.credentials.json'))
|
|
205
|
+
.map((file) => path.join(this.claudeDir, file));
|
|
206
|
+
const credentials = findCredentials(filePathList);
|
|
207
|
+
if (credentials.length === 0) {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
for (const credential of credentials) {
|
|
211
|
+
const fileContent = fs.readFileSync(credential.filePath, 'utf-8');
|
|
212
|
+
const credentialData = JSON.parse(fileContent);
|
|
213
|
+
if (!isCredentialsFile(credentialData)) {
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
const accessToken = credentialData.claudeAiOauth?.accessToken;
|
|
217
|
+
if (!accessToken) {
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
const usageResponse = await this.getUsageWithToken(accessToken);
|
|
222
|
+
if (this.isUsageUnderThreshold(usageResponse, threshold)) {
|
|
223
|
+
fs.copyFileSync(credential.filePath, this.credentialsPath);
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
131
233
|
}
|
|
132
234
|
exports.OauthAPIClaudeRepository = OauthAPIClaudeRepository;
|
|
133
235
|
//# sourceMappingURL=OauthAPIClaudeRepository.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"OauthAPIClaudeRepository.js","sourceRoot":"","sources":["../../../src/adapter/repositories/OauthAPIClaudeRepository.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAEA,uCAAyB;AACzB,2CAA6B;AAC7B,uCAAyB;
|
|
1
|
+
{"version":3,"file":"OauthAPIClaudeRepository.js","sourceRoot":"","sources":["../../../src/adapter/repositories/OauthAPIClaudeRepository.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
const mockExistsSync = jest.fn();
|
|
2
2
|
const mockReadFileSync = jest.fn();
|
|
3
|
+
const mockReaddirSync = jest.fn();
|
|
4
|
+
const mockCopyFileSync = jest.fn();
|
|
3
5
|
const mockHomedir = jest.fn();
|
|
4
6
|
|
|
5
7
|
jest.mock('fs', () => ({
|
|
6
8
|
existsSync: mockExistsSync,
|
|
7
9
|
readFileSync: mockReadFileSync,
|
|
10
|
+
readdirSync: mockReaddirSync,
|
|
11
|
+
copyFileSync: mockCopyFileSync,
|
|
8
12
|
}));
|
|
9
13
|
|
|
10
14
|
jest.mock('os', () => ({
|
|
@@ -343,4 +347,495 @@ describe('OauthAPIClaudeRepository', () => {
|
|
|
343
347
|
}
|
|
344
348
|
});
|
|
345
349
|
});
|
|
350
|
+
|
|
351
|
+
describe('isClaudeAvailable', () => {
|
|
352
|
+
const claudeDir = path.join('/home/testuser', '.claude');
|
|
353
|
+
|
|
354
|
+
it('should return false when claude directory does not exist', async () => {
|
|
355
|
+
mockExistsSync.mockReturnValue(false);
|
|
356
|
+
|
|
357
|
+
repository = new OauthAPIClaudeRepository();
|
|
358
|
+
const result = await repository.isClaudeAvailable(80);
|
|
359
|
+
|
|
360
|
+
expect(result).toBe(false);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('should return false when no credential files exist', async () => {
|
|
364
|
+
mockExistsSync.mockReturnValue(true);
|
|
365
|
+
mockReaddirSync.mockReturnValue(['.credentials.json']);
|
|
366
|
+
|
|
367
|
+
repository = new OauthAPIClaudeRepository();
|
|
368
|
+
const result = await repository.isClaudeAvailable(80);
|
|
369
|
+
|
|
370
|
+
expect(result).toBe(false);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('should return true and copy credential file when usage is under threshold', async () => {
|
|
374
|
+
mockExistsSync.mockReturnValue(true);
|
|
375
|
+
mockReaddirSync.mockReturnValue([
|
|
376
|
+
'.credentials.json',
|
|
377
|
+
'.credentials.json.dev1.1',
|
|
378
|
+
]);
|
|
379
|
+
mockReadFileSync.mockReturnValue(
|
|
380
|
+
JSON.stringify({
|
|
381
|
+
claudeAiOauth: {
|
|
382
|
+
accessToken: 'test-token-dev1',
|
|
383
|
+
},
|
|
384
|
+
}),
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
mockFetch.mockResolvedValueOnce({
|
|
388
|
+
ok: true,
|
|
389
|
+
json: jest.fn().mockResolvedValue({
|
|
390
|
+
five_hour: {
|
|
391
|
+
utilization: 50.0,
|
|
392
|
+
resets_at: '2026-01-12T10:00:00Z',
|
|
393
|
+
},
|
|
394
|
+
}),
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
repository = new OauthAPIClaudeRepository();
|
|
398
|
+
const result = await repository.isClaudeAvailable(80);
|
|
399
|
+
|
|
400
|
+
expect(result).toBe(true);
|
|
401
|
+
expect(mockCopyFileSync).toHaveBeenCalledWith(
|
|
402
|
+
path.join(claudeDir, '.credentials.json.dev1.1'),
|
|
403
|
+
path.join(claudeDir, '.credentials.json'),
|
|
404
|
+
);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('should return false when all credentials are over threshold', async () => {
|
|
408
|
+
mockExistsSync.mockReturnValue(true);
|
|
409
|
+
mockReaddirSync.mockReturnValue([
|
|
410
|
+
'.credentials.json',
|
|
411
|
+
'.credentials.json.dev1.1',
|
|
412
|
+
]);
|
|
413
|
+
mockReadFileSync.mockReturnValue(
|
|
414
|
+
JSON.stringify({
|
|
415
|
+
claudeAiOauth: {
|
|
416
|
+
accessToken: 'test-token-dev1',
|
|
417
|
+
},
|
|
418
|
+
}),
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
mockFetch.mockResolvedValueOnce({
|
|
422
|
+
ok: true,
|
|
423
|
+
json: jest.fn().mockResolvedValue({
|
|
424
|
+
five_hour: {
|
|
425
|
+
utilization: 90.0,
|
|
426
|
+
resets_at: '2026-01-12T10:00:00Z',
|
|
427
|
+
},
|
|
428
|
+
}),
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
repository = new OauthAPIClaudeRepository();
|
|
432
|
+
const result = await repository.isClaudeAvailable(80);
|
|
433
|
+
|
|
434
|
+
expect(result).toBe(false);
|
|
435
|
+
expect(mockCopyFileSync).not.toHaveBeenCalled();
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it('should sort credentials by priority and try lower priority first', async () => {
|
|
439
|
+
mockExistsSync.mockReturnValue(true);
|
|
440
|
+
mockReaddirSync.mockReturnValue([
|
|
441
|
+
'.credentials.json',
|
|
442
|
+
'.credentials.json.dev2.5',
|
|
443
|
+
'.credentials.json.dev1.1',
|
|
444
|
+
]);
|
|
445
|
+
|
|
446
|
+
const credentialContents: Record<string, string> = {
|
|
447
|
+
[path.join(claudeDir, '.credentials.json.dev1.1')]: JSON.stringify({
|
|
448
|
+
claudeAiOauth: { accessToken: 'token-dev1' },
|
|
449
|
+
}),
|
|
450
|
+
[path.join(claudeDir, '.credentials.json.dev2.5')]: JSON.stringify({
|
|
451
|
+
claudeAiOauth: { accessToken: 'token-dev2' },
|
|
452
|
+
}),
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
mockReadFileSync.mockImplementation((filePath: string) => {
|
|
456
|
+
return credentialContents[filePath] || '';
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
mockFetch.mockResolvedValueOnce({
|
|
460
|
+
ok: true,
|
|
461
|
+
json: jest.fn().mockResolvedValue({
|
|
462
|
+
five_hour: { utilization: 30.0 },
|
|
463
|
+
}),
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
repository = new OauthAPIClaudeRepository();
|
|
467
|
+
const result = await repository.isClaudeAvailable(80);
|
|
468
|
+
|
|
469
|
+
expect(result).toBe(true);
|
|
470
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
471
|
+
'https://api.anthropic.com/api/oauth/usage',
|
|
472
|
+
expect.objectContaining({
|
|
473
|
+
method: 'GET',
|
|
474
|
+
headers: {
|
|
475
|
+
Accept: 'application/json, text/plain, */*',
|
|
476
|
+
'Content-Type': 'application/json',
|
|
477
|
+
'User-Agent': 'claude-code/2.0.32',
|
|
478
|
+
Authorization: 'Bearer token-dev1',
|
|
479
|
+
'anthropic-beta': 'oauth-2025-04-20',
|
|
480
|
+
},
|
|
481
|
+
}),
|
|
482
|
+
);
|
|
483
|
+
expect(mockCopyFileSync).toHaveBeenCalledWith(
|
|
484
|
+
path.join(claudeDir, '.credentials.json.dev1.1'),
|
|
485
|
+
path.join(claudeDir, '.credentials.json'),
|
|
486
|
+
);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it('should skip to next credential when API call fails', async () => {
|
|
490
|
+
mockExistsSync.mockReturnValue(true);
|
|
491
|
+
mockReaddirSync.mockReturnValue([
|
|
492
|
+
'.credentials.json',
|
|
493
|
+
'.credentials.json.dev1.1',
|
|
494
|
+
'.credentials.json.dev2.2',
|
|
495
|
+
]);
|
|
496
|
+
|
|
497
|
+
const credentialContents: Record<string, string> = {
|
|
498
|
+
[path.join(claudeDir, '.credentials.json.dev1.1')]: JSON.stringify({
|
|
499
|
+
claudeAiOauth: { accessToken: 'token-dev1' },
|
|
500
|
+
}),
|
|
501
|
+
[path.join(claudeDir, '.credentials.json.dev2.2')]: JSON.stringify({
|
|
502
|
+
claudeAiOauth: { accessToken: 'token-dev2' },
|
|
503
|
+
}),
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
mockReadFileSync.mockImplementation((filePath: string) => {
|
|
507
|
+
return credentialContents[filePath] || '';
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
mockFetch
|
|
511
|
+
.mockResolvedValueOnce({
|
|
512
|
+
ok: false,
|
|
513
|
+
text: jest.fn().mockResolvedValue('API Error'),
|
|
514
|
+
})
|
|
515
|
+
.mockResolvedValueOnce({
|
|
516
|
+
ok: true,
|
|
517
|
+
json: jest.fn().mockResolvedValue({
|
|
518
|
+
five_hour: { utilization: 30.0 },
|
|
519
|
+
}),
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
repository = new OauthAPIClaudeRepository();
|
|
523
|
+
const result = await repository.isClaudeAvailable(80);
|
|
524
|
+
|
|
525
|
+
expect(result).toBe(true);
|
|
526
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
527
|
+
expect(mockCopyFileSync).toHaveBeenCalledWith(
|
|
528
|
+
path.join(claudeDir, '.credentials.json.dev2.2'),
|
|
529
|
+
path.join(claudeDir, '.credentials.json'),
|
|
530
|
+
);
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it('should skip credential files with invalid format', async () => {
|
|
534
|
+
mockExistsSync.mockReturnValue(true);
|
|
535
|
+
mockReaddirSync.mockReturnValue([
|
|
536
|
+
'.credentials.json',
|
|
537
|
+
'.credentials.json.invalid',
|
|
538
|
+
'.credentials.json.dev1.1',
|
|
539
|
+
]);
|
|
540
|
+
|
|
541
|
+
mockReadFileSync.mockReturnValue(
|
|
542
|
+
JSON.stringify({
|
|
543
|
+
claudeAiOauth: { accessToken: 'token-dev1' },
|
|
544
|
+
}),
|
|
545
|
+
);
|
|
546
|
+
|
|
547
|
+
mockFetch.mockResolvedValueOnce({
|
|
548
|
+
ok: true,
|
|
549
|
+
json: jest.fn().mockResolvedValue({
|
|
550
|
+
five_hour: { utilization: 30.0 },
|
|
551
|
+
}),
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
repository = new OauthAPIClaudeRepository();
|
|
555
|
+
const result = await repository.isClaudeAvailable(80);
|
|
556
|
+
|
|
557
|
+
expect(result).toBe(true);
|
|
558
|
+
expect(mockCopyFileSync).toHaveBeenCalledWith(
|
|
559
|
+
path.join(claudeDir, '.credentials.json.dev1.1'),
|
|
560
|
+
path.join(claudeDir, '.credentials.json'),
|
|
561
|
+
);
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
it('should skip credential files without access token', async () => {
|
|
565
|
+
mockExistsSync.mockReturnValue(true);
|
|
566
|
+
mockReaddirSync.mockReturnValue([
|
|
567
|
+
'.credentials.json',
|
|
568
|
+
'.credentials.json.dev1.1',
|
|
569
|
+
'.credentials.json.dev2.2',
|
|
570
|
+
]);
|
|
571
|
+
|
|
572
|
+
const credentialContents: Record<string, string> = {
|
|
573
|
+
[path.join(claudeDir, '.credentials.json.dev1.1')]: JSON.stringify({
|
|
574
|
+
claudeAiOauth: {},
|
|
575
|
+
}),
|
|
576
|
+
[path.join(claudeDir, '.credentials.json.dev2.2')]: JSON.stringify({
|
|
577
|
+
claudeAiOauth: { accessToken: 'token-dev2' },
|
|
578
|
+
}),
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
mockReadFileSync.mockImplementation((filePath: string) => {
|
|
582
|
+
return credentialContents[filePath] || '';
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
mockFetch.mockResolvedValueOnce({
|
|
586
|
+
ok: true,
|
|
587
|
+
json: jest.fn().mockResolvedValue({
|
|
588
|
+
five_hour: { utilization: 30.0 },
|
|
589
|
+
}),
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
repository = new OauthAPIClaudeRepository();
|
|
593
|
+
const result = await repository.isClaudeAvailable(80);
|
|
594
|
+
|
|
595
|
+
expect(result).toBe(true);
|
|
596
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
597
|
+
expect(mockCopyFileSync).toHaveBeenCalledWith(
|
|
598
|
+
path.join(claudeDir, '.credentials.json.dev2.2'),
|
|
599
|
+
path.join(claudeDir, '.credentials.json'),
|
|
600
|
+
);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
it('should check all usage windows against threshold', async () => {
|
|
604
|
+
mockExistsSync.mockReturnValue(true);
|
|
605
|
+
mockReaddirSync.mockReturnValue([
|
|
606
|
+
'.credentials.json',
|
|
607
|
+
'.credentials.json.dev1.1',
|
|
608
|
+
]);
|
|
609
|
+
|
|
610
|
+
mockReadFileSync.mockReturnValue(
|
|
611
|
+
JSON.stringify({
|
|
612
|
+
claudeAiOauth: { accessToken: 'token-dev1' },
|
|
613
|
+
}),
|
|
614
|
+
);
|
|
615
|
+
|
|
616
|
+
mockFetch.mockResolvedValueOnce({
|
|
617
|
+
ok: true,
|
|
618
|
+
json: jest.fn().mockResolvedValue({
|
|
619
|
+
five_hour: { utilization: 30.0 },
|
|
620
|
+
seven_day: { utilization: 90.0 },
|
|
621
|
+
}),
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
repository = new OauthAPIClaudeRepository();
|
|
625
|
+
const result = await repository.isClaudeAvailable(80);
|
|
626
|
+
|
|
627
|
+
expect(result).toBe(false);
|
|
628
|
+
expect(mockCopyFileSync).not.toHaveBeenCalled();
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it('should return true when usage equals threshold minus 1', async () => {
|
|
632
|
+
mockExistsSync.mockReturnValue(true);
|
|
633
|
+
mockReaddirSync.mockReturnValue([
|
|
634
|
+
'.credentials.json',
|
|
635
|
+
'.credentials.json.dev1.1',
|
|
636
|
+
]);
|
|
637
|
+
|
|
638
|
+
mockReadFileSync.mockReturnValue(
|
|
639
|
+
JSON.stringify({
|
|
640
|
+
claudeAiOauth: { accessToken: 'token-dev1' },
|
|
641
|
+
}),
|
|
642
|
+
);
|
|
643
|
+
|
|
644
|
+
mockFetch.mockResolvedValueOnce({
|
|
645
|
+
ok: true,
|
|
646
|
+
json: jest.fn().mockResolvedValue({
|
|
647
|
+
five_hour: { utilization: 79.0 },
|
|
648
|
+
}),
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
repository = new OauthAPIClaudeRepository();
|
|
652
|
+
const result = await repository.isClaudeAvailable(80);
|
|
653
|
+
|
|
654
|
+
expect(result).toBe(true);
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
it('should return false when usage equals threshold', async () => {
|
|
658
|
+
mockExistsSync.mockReturnValue(true);
|
|
659
|
+
mockReaddirSync.mockReturnValue([
|
|
660
|
+
'.credentials.json',
|
|
661
|
+
'.credentials.json.dev1.1',
|
|
662
|
+
]);
|
|
663
|
+
|
|
664
|
+
mockReadFileSync.mockReturnValue(
|
|
665
|
+
JSON.stringify({
|
|
666
|
+
claudeAiOauth: { accessToken: 'token-dev1' },
|
|
667
|
+
}),
|
|
668
|
+
);
|
|
669
|
+
|
|
670
|
+
mockFetch.mockResolvedValueOnce({
|
|
671
|
+
ok: true,
|
|
672
|
+
json: jest.fn().mockResolvedValue({
|
|
673
|
+
five_hour: { utilization: 80.0 },
|
|
674
|
+
}),
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
repository = new OauthAPIClaudeRepository();
|
|
678
|
+
const result = await repository.isClaudeAvailable(80);
|
|
679
|
+
|
|
680
|
+
expect(result).toBe(false);
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
it('should skip credential files with non-numeric priority', async () => {
|
|
684
|
+
mockExistsSync.mockReturnValue(true);
|
|
685
|
+
mockReaddirSync.mockReturnValue([
|
|
686
|
+
'.credentials.json',
|
|
687
|
+
'.credentials.json.dev.abc',
|
|
688
|
+
'.credentials.json.dev1.1',
|
|
689
|
+
]);
|
|
690
|
+
|
|
691
|
+
mockReadFileSync.mockReturnValue(
|
|
692
|
+
JSON.stringify({
|
|
693
|
+
claudeAiOauth: { accessToken: 'token-dev1' },
|
|
694
|
+
}),
|
|
695
|
+
);
|
|
696
|
+
|
|
697
|
+
mockFetch.mockResolvedValueOnce({
|
|
698
|
+
ok: true,
|
|
699
|
+
json: jest.fn().mockResolvedValue({
|
|
700
|
+
five_hour: { utilization: 30.0 },
|
|
701
|
+
}),
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
repository = new OauthAPIClaudeRepository();
|
|
705
|
+
const result = await repository.isClaudeAvailable(80);
|
|
706
|
+
|
|
707
|
+
expect(result).toBe(true);
|
|
708
|
+
expect(mockCopyFileSync).toHaveBeenCalledWith(
|
|
709
|
+
path.join(claudeDir, '.credentials.json.dev1.1'),
|
|
710
|
+
path.join(claudeDir, '.credentials.json'),
|
|
711
|
+
);
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
it('should skip to next credential when API returns invalid response format', async () => {
|
|
715
|
+
mockExistsSync.mockReturnValue(true);
|
|
716
|
+
mockReaddirSync.mockReturnValue([
|
|
717
|
+
'.credentials.json',
|
|
718
|
+
'.credentials.json.dev1.1',
|
|
719
|
+
'.credentials.json.dev2.2',
|
|
720
|
+
]);
|
|
721
|
+
|
|
722
|
+
const credentialContents: Record<string, string> = {
|
|
723
|
+
[path.join(claudeDir, '.credentials.json.dev1.1')]: JSON.stringify({
|
|
724
|
+
claudeAiOauth: { accessToken: 'token-dev1' },
|
|
725
|
+
}),
|
|
726
|
+
[path.join(claudeDir, '.credentials.json.dev2.2')]: JSON.stringify({
|
|
727
|
+
claudeAiOauth: { accessToken: 'token-dev2' },
|
|
728
|
+
}),
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
mockReadFileSync.mockImplementation((filePath: string) => {
|
|
732
|
+
return credentialContents[filePath] || '';
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
mockFetch
|
|
736
|
+
.mockResolvedValueOnce({
|
|
737
|
+
ok: true,
|
|
738
|
+
json: jest.fn().mockResolvedValue(null),
|
|
739
|
+
})
|
|
740
|
+
.mockResolvedValueOnce({
|
|
741
|
+
ok: true,
|
|
742
|
+
json: jest.fn().mockResolvedValue({
|
|
743
|
+
five_hour: { utilization: 30.0 },
|
|
744
|
+
}),
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
repository = new OauthAPIClaudeRepository();
|
|
748
|
+
const result = await repository.isClaudeAvailable(80);
|
|
749
|
+
|
|
750
|
+
expect(result).toBe(true);
|
|
751
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
752
|
+
expect(mockCopyFileSync).toHaveBeenCalledWith(
|
|
753
|
+
path.join(claudeDir, '.credentials.json.dev2.2'),
|
|
754
|
+
path.join(claudeDir, '.credentials.json'),
|
|
755
|
+
);
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
it('should skip to next credential when API returns error in response', async () => {
|
|
759
|
+
mockExistsSync.mockReturnValue(true);
|
|
760
|
+
mockReaddirSync.mockReturnValue([
|
|
761
|
+
'.credentials.json',
|
|
762
|
+
'.credentials.json.dev1.1',
|
|
763
|
+
'.credentials.json.dev2.2',
|
|
764
|
+
]);
|
|
765
|
+
|
|
766
|
+
const credentialContents: Record<string, string> = {
|
|
767
|
+
[path.join(claudeDir, '.credentials.json.dev1.1')]: JSON.stringify({
|
|
768
|
+
claudeAiOauth: { accessToken: 'token-dev1' },
|
|
769
|
+
}),
|
|
770
|
+
[path.join(claudeDir, '.credentials.json.dev2.2')]: JSON.stringify({
|
|
771
|
+
claudeAiOauth: { accessToken: 'token-dev2' },
|
|
772
|
+
}),
|
|
773
|
+
};
|
|
774
|
+
|
|
775
|
+
mockReadFileSync.mockImplementation((filePath: string) => {
|
|
776
|
+
return credentialContents[filePath] || '';
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
mockFetch
|
|
780
|
+
.mockResolvedValueOnce({
|
|
781
|
+
ok: true,
|
|
782
|
+
json: jest.fn().mockResolvedValue({
|
|
783
|
+
error: 'Token expired',
|
|
784
|
+
}),
|
|
785
|
+
})
|
|
786
|
+
.mockResolvedValueOnce({
|
|
787
|
+
ok: true,
|
|
788
|
+
json: jest.fn().mockResolvedValue({
|
|
789
|
+
five_hour: { utilization: 30.0 },
|
|
790
|
+
}),
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
repository = new OauthAPIClaudeRepository();
|
|
794
|
+
const result = await repository.isClaudeAvailable(80);
|
|
795
|
+
|
|
796
|
+
expect(result).toBe(true);
|
|
797
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
798
|
+
expect(mockCopyFileSync).toHaveBeenCalledWith(
|
|
799
|
+
path.join(claudeDir, '.credentials.json.dev2.2'),
|
|
800
|
+
path.join(claudeDir, '.credentials.json'),
|
|
801
|
+
);
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
it('should skip credential files with invalid JSON format', async () => {
|
|
805
|
+
mockExistsSync.mockReturnValue(true);
|
|
806
|
+
mockReaddirSync.mockReturnValue([
|
|
807
|
+
'.credentials.json',
|
|
808
|
+
'.credentials.json.dev1.1',
|
|
809
|
+
'.credentials.json.dev2.2',
|
|
810
|
+
]);
|
|
811
|
+
|
|
812
|
+
const credentialContents: Record<string, string> = {
|
|
813
|
+
[path.join(claudeDir, '.credentials.json.dev1.1')]: 'null',
|
|
814
|
+
[path.join(claudeDir, '.credentials.json.dev2.2')]: JSON.stringify({
|
|
815
|
+
claudeAiOauth: { accessToken: 'token-dev2' },
|
|
816
|
+
}),
|
|
817
|
+
};
|
|
818
|
+
|
|
819
|
+
mockReadFileSync.mockImplementation((filePath: string) => {
|
|
820
|
+
return credentialContents[filePath] || '';
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
mockFetch.mockResolvedValueOnce({
|
|
824
|
+
ok: true,
|
|
825
|
+
json: jest.fn().mockResolvedValue({
|
|
826
|
+
five_hour: { utilization: 30.0 },
|
|
827
|
+
}),
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
repository = new OauthAPIClaudeRepository();
|
|
831
|
+
const result = await repository.isClaudeAvailable(80);
|
|
832
|
+
|
|
833
|
+
expect(result).toBe(true);
|
|
834
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
835
|
+
expect(mockCopyFileSync).toHaveBeenCalledWith(
|
|
836
|
+
path.join(claudeDir, '.credentials.json.dev2.2'),
|
|
837
|
+
path.join(claudeDir, '.credentials.json'),
|
|
838
|
+
);
|
|
839
|
+
});
|
|
840
|
+
});
|
|
346
841
|
});
|
|
@@ -10,6 +10,12 @@ type CredentialsFile = {
|
|
|
10
10
|
};
|
|
11
11
|
};
|
|
12
12
|
|
|
13
|
+
type CredentialInfo = {
|
|
14
|
+
name: string;
|
|
15
|
+
priority: number;
|
|
16
|
+
filePath: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
13
19
|
type UsageWindow = {
|
|
14
20
|
utilization?: number;
|
|
15
21
|
resets_at?: string;
|
|
@@ -33,15 +39,48 @@ const isUsageResponse = (value: unknown): value is UsageResponse => {
|
|
|
33
39
|
return true;
|
|
34
40
|
};
|
|
35
41
|
|
|
42
|
+
const findCredentials = (filePathList: string[]): CredentialInfo[] => {
|
|
43
|
+
const credentials: CredentialInfo[] = [];
|
|
44
|
+
const baseFileName = '.credentials.json';
|
|
45
|
+
|
|
46
|
+
for (const filePath of filePathList) {
|
|
47
|
+
const fileName = path.basename(filePath);
|
|
48
|
+
|
|
49
|
+
if (fileName === baseFileName) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const suffix = fileName.slice(baseFileName.length + 1);
|
|
54
|
+
const parts = suffix.split('.');
|
|
55
|
+
if (parts.length !== 2) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const name = parts[0];
|
|
60
|
+
const priorityStr = parts[1];
|
|
61
|
+
const priority = parseInt(priorityStr, 10);
|
|
62
|
+
|
|
63
|
+
if (isNaN(priority)) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
credentials.push({
|
|
68
|
+
name,
|
|
69
|
+
priority,
|
|
70
|
+
filePath,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return credentials.sort((a, b) => a.priority - b.priority);
|
|
75
|
+
};
|
|
76
|
+
|
|
36
77
|
export class OauthAPIClaudeRepository implements ClaudeRepository {
|
|
37
78
|
private readonly credentialsPath: string;
|
|
79
|
+
private readonly claudeDir: string;
|
|
38
80
|
|
|
39
81
|
constructor() {
|
|
40
|
-
this.
|
|
41
|
-
|
|
42
|
-
'.claude',
|
|
43
|
-
'.credentials.json',
|
|
44
|
-
);
|
|
82
|
+
this.claudeDir = path.join(os.homedir(), '.claude');
|
|
83
|
+
this.credentialsPath = path.join(this.claudeDir, '.credentials.json');
|
|
45
84
|
}
|
|
46
85
|
|
|
47
86
|
private getAccessToken(): string {
|
|
@@ -140,4 +179,101 @@ export class OauthAPIClaudeRepository implements ClaudeRepository {
|
|
|
140
179
|
|
|
141
180
|
return usages;
|
|
142
181
|
}
|
|
182
|
+
|
|
183
|
+
private async getUsageWithToken(accessToken: string): Promise<UsageResponse> {
|
|
184
|
+
const response = await fetch('https://api.anthropic.com/api/oauth/usage', {
|
|
185
|
+
method: 'GET',
|
|
186
|
+
headers: {
|
|
187
|
+
Accept: 'application/json, text/plain, */*',
|
|
188
|
+
'Content-Type': 'application/json',
|
|
189
|
+
'User-Agent': 'claude-code/2.0.32',
|
|
190
|
+
Authorization: `Bearer ${accessToken}`,
|
|
191
|
+
'anthropic-beta': 'oauth-2025-04-20',
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
if (!response.ok) {
|
|
196
|
+
const errorText = await response.text();
|
|
197
|
+
throw new Error(`Claude API error: ${errorText}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const responseData: unknown = await response.json();
|
|
201
|
+
|
|
202
|
+
if (!isUsageResponse(responseData)) {
|
|
203
|
+
throw new Error('Invalid API response format');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (responseData.error) {
|
|
207
|
+
throw new Error(`API error: ${responseData.error}`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return responseData;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private isUsageUnderThreshold(
|
|
214
|
+
usageResponse: UsageResponse,
|
|
215
|
+
threshold: number,
|
|
216
|
+
): boolean {
|
|
217
|
+
const windows = [
|
|
218
|
+
usageResponse.five_hour,
|
|
219
|
+
usageResponse.seven_day,
|
|
220
|
+
usageResponse.seven_day_opus,
|
|
221
|
+
usageResponse.seven_day_sonnet,
|
|
222
|
+
];
|
|
223
|
+
|
|
224
|
+
for (const window of windows) {
|
|
225
|
+
if (
|
|
226
|
+
window?.utilization !== undefined &&
|
|
227
|
+
window.utilization >= threshold
|
|
228
|
+
) {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async isClaudeAvailable(threshold: number): Promise<boolean> {
|
|
237
|
+
if (!fs.existsSync(this.claudeDir)) {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const files = fs.readdirSync(this.claudeDir);
|
|
242
|
+
const filePathList = files
|
|
243
|
+
.filter((file) => file.startsWith('.credentials.json'))
|
|
244
|
+
.map((file) => path.join(this.claudeDir, file));
|
|
245
|
+
|
|
246
|
+
const credentials = findCredentials(filePathList);
|
|
247
|
+
|
|
248
|
+
if (credentials.length === 0) {
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
for (const credential of credentials) {
|
|
253
|
+
const fileContent = fs.readFileSync(credential.filePath, 'utf-8');
|
|
254
|
+
const credentialData: unknown = JSON.parse(fileContent);
|
|
255
|
+
|
|
256
|
+
if (!isCredentialsFile(credentialData)) {
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const accessToken = credentialData.claudeAiOauth?.accessToken;
|
|
261
|
+
if (!accessToken) {
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
const usageResponse = await this.getUsageWithToken(accessToken);
|
|
267
|
+
|
|
268
|
+
if (this.isUsageUnderThreshold(usageResponse, threshold)) {
|
|
269
|
+
fs.copyFileSync(credential.filePath, this.credentialsPath);
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
} catch {
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
143
279
|
}
|
|
@@ -2,8 +2,12 @@ import { ClaudeRepository } from '../../domain/usecases/adapter-interfaces/Claud
|
|
|
2
2
|
import { ClaudeWindowUsage } from '../../domain/entities/ClaudeWindowUsage';
|
|
3
3
|
export declare class OauthAPIClaudeRepository implements ClaudeRepository {
|
|
4
4
|
private readonly credentialsPath;
|
|
5
|
+
private readonly claudeDir;
|
|
5
6
|
constructor();
|
|
6
7
|
private getAccessToken;
|
|
7
8
|
getUsage(): Promise<ClaudeWindowUsage[]>;
|
|
9
|
+
private getUsageWithToken;
|
|
10
|
+
private isUsageUnderThreshold;
|
|
11
|
+
isClaudeAvailable(threshold: number): Promise<boolean>;
|
|
8
12
|
}
|
|
9
13
|
//# sourceMappingURL=OauthAPIClaudeRepository.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"OauthAPIClaudeRepository.d.ts","sourceRoot":"","sources":["../../../src/adapter/repositories/OauthAPIClaudeRepository.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,2DAA2D,CAAC;AAC7F,OAAO,EAAE,iBAAiB,EAAE,MAAM,yCAAyC,CAAC;
|
|
1
|
+
{"version":3,"file":"OauthAPIClaudeRepository.d.ts","sourceRoot":"","sources":["../../../src/adapter/repositories/OauthAPIClaudeRepository.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,2DAA2D,CAAC;AAC7F,OAAO,EAAE,iBAAiB,EAAE,MAAM,yCAAyC,CAAC;AA2E5E,qBAAa,wBAAyB,YAAW,gBAAgB;IAC/D,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAS;IACzC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;;IAOnC,OAAO,CAAC,cAAc;IAuBhB,QAAQ,IAAI,OAAO,CAAC,iBAAiB,EAAE,CAAC;YA0EhC,iBAAiB;IA8B/B,OAAO,CAAC,qBAAqB;IAuBvB,iBAAiB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;CA2C7D"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ClaudeRepository.d.ts","sourceRoot":"","sources":["../../../../src/domain/usecases/adapter-interfaces/ClaudeRepository.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,kCAAkC,CAAC;AAErE,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,IAAI,OAAO,CAAC,iBAAiB,EAAE,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"ClaudeRepository.d.ts","sourceRoot":"","sources":["../../../../src/domain/usecases/adapter-interfaces/ClaudeRepository.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,kCAAkC,CAAC;AAErE,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,IAAI,OAAO,CAAC,iBAAiB,EAAE,CAAC,CAAC;IACzC,iBAAiB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACxD"}
|