tide-commander 1.152.0 → 1.154.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 +20 -0
- package/dist/assets/{BossLogsModal-DZWUfQXs.js → BossLogsModal-Bo0pUCWU.js} +1 -1
- package/dist/assets/{BossSpawnModal-7IwzcqE7.js → BossSpawnModal-BOOHaqaD.js} +1 -1
- package/dist/assets/{ControlsModal-D8L1xD-H.js → ControlsModal-DItGV-CW.js} +1 -1
- package/dist/assets/{DockerLogsModal-_zLe5UFl.js → DockerLogsModal-DjAZ3GL7.js} +1 -1
- package/dist/assets/{EmbeddedEditor-gbiFXvt_.js → EmbeddedEditor-Cihkr8Xb.js} +1 -1
- package/dist/assets/{GmailOAuthSetup-CcXTV3NP.js → GmailOAuthSetup-G1XqiM44.js} +1 -1
- package/dist/assets/{GoogleOAuthSetup-wbETidgv.js → GoogleOAuthSetup-Ce6gojdm.js} +1 -1
- package/dist/assets/{IframeModal-a9E5UDBk.js → IframeModal-CSSZJzPv.js} +1 -1
- package/dist/assets/{IntegrationsPanel-BbGyluL0.js → IntegrationsPanel-EspPH09n.js} +2 -2
- package/dist/assets/LogViewerModal-Bn-HDysM.js +1 -0
- package/dist/assets/{MonitoringModal-DymqjenY.js → MonitoringModal-DuSLTKVR.js} +1 -1
- package/dist/assets/{PM2LogsModal-CEaCk-dt.js → PM2LogsModal-Bo2OtaQd.js} +1 -1
- package/dist/assets/{RestoreArchivedAreaModal-q_FFv7L6.js → RestoreArchivedAreaModal-BOx-V6tl.js} +1 -1
- package/dist/assets/{Scene2DCanvas-DOvVtiYO.js → Scene2DCanvas-BIUbi14G.js} +1 -1
- package/dist/assets/{SceneManager-D0NdrG8K.js → SceneManager-CIWrGc-K.js} +4 -4
- package/dist/assets/{SkillsPanel-DpqD60Hy.js → SkillsPanel-_KHtTDhC.js} +1 -1
- package/dist/assets/{SlackMultiInstanceSetup-BdoALd_g.js → SlackMultiInstanceSetup-DbQ3rUtd.js} +1 -1
- package/dist/assets/{SpawnModal-DFFFH2kc.js → SpawnModal-CYEoOMPW.js} +1 -1
- package/dist/assets/{StatisticsModal-Dv_rYZDt.js → StatisticsModal-BJ-coeis.js} +1 -1
- package/dist/assets/{SubordinateAssignmentModal-CRCCI686.js → SubordinateAssignmentModal-5Z3jvbM9.js} +1 -1
- package/dist/assets/SymbolsNerdFontMono-Regular-BM2yZ5nr.woff2 +0 -0
- package/dist/assets/{TriggerManagerPanel-DBN8IRa-.js → TriggerManagerPanel-DvC0xP9r.js} +1 -1
- package/dist/assets/{WorkflowEditorPanel-uQEvNTs2.js → WorkflowEditorPanel-6ym1_csr.js} +1 -1
- package/dist/assets/{area-logos-CuVxToGM.js → area-logos-mWPCtCnf.js} +1 -1
- package/dist/assets/{index-Dwll5Eyj.js → index-8VVbTmtb.js} +1 -1
- package/dist/assets/{index-BNzYPyBm.css → index-BPjQZacc.css} +1 -1
- package/dist/assets/index-C34l72MW.js +1 -0
- package/dist/assets/{index-DKnfLe7S.js → index-CS8OutmQ.js} +1 -1
- package/dist/assets/{index-ipuWrg1S.js → index-CT2vtbed.js} +3 -3
- package/dist/assets/{index-Blc4MwKs.js → index-CY_NzVPm.js} +1 -1
- package/dist/assets/index-CiKbhMfN.js +1 -0
- package/dist/assets/{index-HvyFhszc.js → index-CnESn83U.js} +2 -2
- package/dist/assets/{index-8Ghh3AQ-.js → index-DZiNmiyC.js} +1 -1
- package/dist/assets/{index-D4iie3Vd.js → index-DxyZ-rYT.js} +1 -1
- package/dist/assets/main-BHBs2E1L.css +1 -0
- package/dist/assets/main-D5j8Ul0m.js +289 -0
- package/dist/assets/{web-D0JSbYeo.js → web-BvAQVbIF.js} +1 -1
- package/dist/assets/{web-1pfPwxBS.js → web-Cd0-XOkz.js} +1 -1
- package/dist/assets/{web-DjgiLxvi.js → web-DMxl9AVF.js} +1 -1
- package/dist/build-info.json +1 -0
- package/dist/index.html +2 -2
- package/dist/locales/de/config.json +1 -0
- package/dist/locales/en/config.json +6 -1
- package/dist/locales/en/terminal.json +36 -0
- package/dist/locales/es/config.json +4 -1
- package/dist/locales/es/terminal.json +8 -0
- package/dist/locales/fr/config.json +1 -0
- package/dist/locales/hi/config.json +1 -0
- package/dist/locales/it/config.json +1 -0
- package/dist/locales/ja/config.json +1 -0
- package/dist/locales/pt/config.json +1 -0
- package/dist/locales/ru/config.json +1 -0
- package/dist/locales/zh-CN/config.json +1 -0
- package/dist/src/packages/server/codex/app-server/app-server-event-adapter.js +222 -0
- package/dist/src/packages/server/codex/app-server/app-server-process.js +304 -0
- package/dist/src/packages/server/codex/app-server/app-server-recovery-store.js +76 -0
- package/dist/src/packages/server/codex/app-server/codex-app-server-runner.js +453 -0
- package/dist/src/packages/server/routes/agents.js +29 -1
- package/dist/src/packages/server/routes/system.js +216 -1
- package/dist/src/packages/server/runtime/codex-runner-router.js +99 -0
- package/dist/src/packages/server/runtime/codex-runtime-provider.js +5 -3
- package/dist/src/packages/server/services/claude-credentials-service.js +536 -0
- package/dist/src/packages/server/services/claude-usage-service.js +35 -28
- package/dist/src/packages/server/services/provider-credentials-service.js +338 -0
- package/dist/src/packages/server/services/system-prompt-service.js +46 -0
- package/package.json +1 -1
- package/dist/assets/LogViewerModal-cnMAbNvY.js +0 -1
- package/dist/assets/index-BHM8V4i-.js +0 -1
- package/dist/assets/index-OkenGYC6.js +0 -1
- package/dist/assets/main-DpNJ8pqp.css +0 -1
- package/dist/assets/main-aIpEYDDR.js +0 -289
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Credentials Service
|
|
3
|
+
*
|
|
4
|
+
* Manages multiple Claude Code OAuth credential profiles under ~/.claude:
|
|
5
|
+
* - Active (used by CLI + Tide): ~/.claude/.credentials.json
|
|
6
|
+
* - Named profiles: ~/.claude/.credentials.<name>.json
|
|
7
|
+
*
|
|
8
|
+
* Operators keep accounts such as david / jc / felipe as named profiles and
|
|
9
|
+
* promote one to active when the current account hits rate limits. Tokens are
|
|
10
|
+
* never returned to clients — only non-secret metadata + a stable fingerprint.
|
|
11
|
+
*/
|
|
12
|
+
import * as fs from 'fs';
|
|
13
|
+
import * as os from 'os';
|
|
14
|
+
import * as path from 'path';
|
|
15
|
+
import * as crypto from 'crypto';
|
|
16
|
+
import { createLogger } from '../utils/logger.js';
|
|
17
|
+
import { fetchClaudeRateLimitsForToken, resetClaudeRateLimitCache, } from './claude-usage-service.js';
|
|
18
|
+
const log = createLogger('ClaudeCredentials');
|
|
19
|
+
const CLAUDE_DIR = path.join(os.homedir(), '.claude');
|
|
20
|
+
const ACTIVE_FILE = '.credentials.json';
|
|
21
|
+
const NAMED_PREFIX = '.credentials.';
|
|
22
|
+
const NAMED_SUFFIX = '.json';
|
|
23
|
+
/** Overrideable for tests. */
|
|
24
|
+
let claudeDirOverride = null;
|
|
25
|
+
export function setClaudeCredentialsDirForTests(dir) {
|
|
26
|
+
claudeDirOverride = dir;
|
|
27
|
+
}
|
|
28
|
+
function getClaudeDir() {
|
|
29
|
+
return claudeDirOverride ?? CLAUDE_DIR;
|
|
30
|
+
}
|
|
31
|
+
function activePath() {
|
|
32
|
+
return path.join(getClaudeDir(), ACTIVE_FILE);
|
|
33
|
+
}
|
|
34
|
+
function namedPath(name) {
|
|
35
|
+
return path.join(getClaudeDir(), `${NAMED_PREFIX}${name}${NAMED_SUFFIX}`);
|
|
36
|
+
}
|
|
37
|
+
const NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/;
|
|
38
|
+
export function isValidProfileName(name) {
|
|
39
|
+
if (typeof name !== 'string' || !NAME_RE.test(name))
|
|
40
|
+
return false;
|
|
41
|
+
// Reject names that would collide with the active filename shape or path tricks.
|
|
42
|
+
if (name === 'json' || name.includes('/') || name.includes('\\') || name.includes('..'))
|
|
43
|
+
return false;
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
function fingerprintToken(accessToken) {
|
|
47
|
+
return crypto.createHash('sha256').update(accessToken).digest('hex').slice(0, 12);
|
|
48
|
+
}
|
|
49
|
+
function parseCredentialsFile(filePath) {
|
|
50
|
+
if (!fs.existsSync(filePath))
|
|
51
|
+
return null;
|
|
52
|
+
try {
|
|
53
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
54
|
+
const data = JSON.parse(raw);
|
|
55
|
+
const oauth = data?.claudeAiOauth;
|
|
56
|
+
const accessToken = typeof oauth?.accessToken === 'string' ? oauth.accessToken : '';
|
|
57
|
+
const valid = accessToken.length > 0;
|
|
58
|
+
const scopes = Array.isArray(oauth?.scopes)
|
|
59
|
+
? oauth.scopes.filter((s) => typeof s === 'string')
|
|
60
|
+
: [];
|
|
61
|
+
return {
|
|
62
|
+
raw,
|
|
63
|
+
fingerprint: valid ? fingerprintToken(accessToken) : null,
|
|
64
|
+
subscriptionType: typeof oauth?.subscriptionType === 'string' ? oauth.subscriptionType : null,
|
|
65
|
+
rateLimitTier: typeof oauth?.rateLimitTier === 'string' ? oauth.rateLimitTier : null,
|
|
66
|
+
expiresAt: typeof oauth?.expiresAt === 'number' ? oauth.expiresAt : null,
|
|
67
|
+
refreshTokenExpiresAt: typeof oauth?.refreshTokenExpiresAt === 'number' ? oauth.refreshTokenExpiresAt : null,
|
|
68
|
+
scopes,
|
|
69
|
+
hasMcpOAuth: Boolean(data?.mcpOAuth && typeof data.mcpOAuth === 'object'),
|
|
70
|
+
valid,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
log.warn(`Failed to parse credentials at ${filePath}: ${err}`);
|
|
75
|
+
return {
|
|
76
|
+
raw: '',
|
|
77
|
+
fingerprint: null,
|
|
78
|
+
subscriptionType: null,
|
|
79
|
+
rateLimitTier: null,
|
|
80
|
+
expiresAt: null,
|
|
81
|
+
refreshTokenExpiresAt: null,
|
|
82
|
+
scopes: [],
|
|
83
|
+
hasMcpOAuth: false,
|
|
84
|
+
valid: false,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function fileMtime(filePath) {
|
|
89
|
+
try {
|
|
90
|
+
return fs.statSync(filePath).mtimeMs;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function toMeta(id, name, filePath, parsed, isActive, matchesNamed) {
|
|
97
|
+
return {
|
|
98
|
+
id,
|
|
99
|
+
name,
|
|
100
|
+
isActive,
|
|
101
|
+
path: filePath,
|
|
102
|
+
valid: parsed?.valid ?? false,
|
|
103
|
+
fingerprint: parsed?.fingerprint ?? null,
|
|
104
|
+
subscriptionType: parsed?.subscriptionType ?? null,
|
|
105
|
+
rateLimitTier: parsed?.rateLimitTier ?? null,
|
|
106
|
+
expiresAt: parsed?.expiresAt ?? null,
|
|
107
|
+
refreshTokenExpiresAt: parsed?.refreshTokenExpiresAt ?? null,
|
|
108
|
+
scopes: parsed?.scopes ?? [],
|
|
109
|
+
hasMcpOAuth: parsed?.hasMcpOAuth ?? false,
|
|
110
|
+
mtimeMs: fileMtime(filePath),
|
|
111
|
+
matchesNamed,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
function listNamedProfileNames() {
|
|
115
|
+
const dir = getClaudeDir();
|
|
116
|
+
if (!fs.existsSync(dir))
|
|
117
|
+
return [];
|
|
118
|
+
const names = [];
|
|
119
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
120
|
+
if (!entry.startsWith(NAMED_PREFIX) || !entry.endsWith(NAMED_SUFFIX))
|
|
121
|
+
continue;
|
|
122
|
+
if (entry === ACTIVE_FILE)
|
|
123
|
+
continue;
|
|
124
|
+
const name = entry.slice(NAMED_PREFIX.length, entry.length - NAMED_SUFFIX.length);
|
|
125
|
+
if (!name || !isValidProfileName(name))
|
|
126
|
+
continue;
|
|
127
|
+
names.push(name);
|
|
128
|
+
}
|
|
129
|
+
names.sort((a, b) => a.localeCompare(b));
|
|
130
|
+
return names;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* List the active credentials file plus every named profile.
|
|
134
|
+
* Never includes access/refresh tokens.
|
|
135
|
+
*/
|
|
136
|
+
export function listClaudeCredentialProfiles() {
|
|
137
|
+
const dir = getClaudeDir();
|
|
138
|
+
const activeFile = activePath();
|
|
139
|
+
const activeParsed = parseCredentialsFile(activeFile);
|
|
140
|
+
const namedNames = listNamedProfileNames();
|
|
141
|
+
const namedByFp = new Map();
|
|
142
|
+
for (const name of namedNames) {
|
|
143
|
+
const parsed = parseCredentialsFile(namedPath(name));
|
|
144
|
+
if (parsed?.fingerprint)
|
|
145
|
+
namedByFp.set(parsed.fingerprint, name);
|
|
146
|
+
}
|
|
147
|
+
const activeMatchesNamed = activeParsed?.fingerprint ? (namedByFp.get(activeParsed.fingerprint) ?? null) : null;
|
|
148
|
+
const active = fs.existsSync(activeFile)
|
|
149
|
+
? toMeta('active', 'active', activeFile, activeParsed, true, activeMatchesNamed)
|
|
150
|
+
: null;
|
|
151
|
+
const profiles = namedNames.map((name) => {
|
|
152
|
+
const p = namedPath(name);
|
|
153
|
+
const parsed = parseCredentialsFile(p);
|
|
154
|
+
const isActive = Boolean(activeParsed?.fingerprint) &&
|
|
155
|
+
parsed?.fingerprint === activeParsed?.fingerprint;
|
|
156
|
+
return toMeta(name, name, p, parsed, isActive, null);
|
|
157
|
+
});
|
|
158
|
+
return { claudeDir: dir, active, profiles };
|
|
159
|
+
}
|
|
160
|
+
// The Claude Code CLI's public OAuth client (PKCE, no secret) — same values
|
|
161
|
+
// the CLI itself uses to refresh ~/.claude/.credentials.json.
|
|
162
|
+
const OAUTH_TOKEN_URL = 'https://console.anthropic.com/v1/oauth/token';
|
|
163
|
+
const OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
|
|
164
|
+
const OAUTH_REFRESH_TIMEOUT_MS = 10_000;
|
|
165
|
+
const GRANT_USAGE_TTL_OK_MS = 2 * 60 * 1000;
|
|
166
|
+
const GRANT_USAGE_TTL_ERR_MS = 45 * 1000;
|
|
167
|
+
// After a failed refresh, leave the grant alone for a while — retrying a
|
|
168
|
+
// rotated/invalid refresh token can trip reuse detection and revoke the grant.
|
|
169
|
+
const GRANT_REFRESH_FAIL_COOLDOWN_MS = 30 * 60 * 1000;
|
|
170
|
+
const grantUsageCache = new Map();
|
|
171
|
+
const grantUsageInFlight = new Map();
|
|
172
|
+
export function resetClaudeProfileUsageCacheForTests() {
|
|
173
|
+
grantUsageCache.clear();
|
|
174
|
+
grantUsageInFlight.clear();
|
|
175
|
+
}
|
|
176
|
+
/** Read the OAuth tokens from a credentials file (they never leave the server). */
|
|
177
|
+
function readProfileTokens(filePath) {
|
|
178
|
+
try {
|
|
179
|
+
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
180
|
+
const oauth = data?.claudeAiOauth;
|
|
181
|
+
return {
|
|
182
|
+
accessToken: typeof oauth?.accessToken === 'string' && oauth.accessToken ? oauth.accessToken : null,
|
|
183
|
+
expiresAt: typeof oauth?.expiresAt === 'number' ? oauth.expiresAt : null,
|
|
184
|
+
refreshToken: typeof oauth?.refreshToken === 'string' && oauth.refreshToken ? oauth.refreshToken : null,
|
|
185
|
+
refreshTokenExpiresAt: typeof oauth?.refreshTokenExpiresAt === 'number' ? oauth.refreshTokenExpiresAt : null,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return { accessToken: null, expiresAt: null, refreshToken: null, refreshTokenExpiresAt: null };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/** Refresh an OAuth grant the way the CLI does. Returns tokens or an error string. */
|
|
193
|
+
async function refreshOAuthGrant(refreshToken) {
|
|
194
|
+
try {
|
|
195
|
+
const response = await fetch(OAUTH_TOKEN_URL, {
|
|
196
|
+
method: 'POST',
|
|
197
|
+
headers: { 'Content-Type': 'application/json' },
|
|
198
|
+
body: JSON.stringify({
|
|
199
|
+
grant_type: 'refresh_token',
|
|
200
|
+
refresh_token: refreshToken,
|
|
201
|
+
client_id: OAUTH_CLIENT_ID,
|
|
202
|
+
}),
|
|
203
|
+
signal: AbortSignal.timeout(OAUTH_REFRESH_TIMEOUT_MS),
|
|
204
|
+
});
|
|
205
|
+
if (!response.ok) {
|
|
206
|
+
return { error: `Token refresh failed (${response.status}) — sign in to this account again` };
|
|
207
|
+
}
|
|
208
|
+
const body = await response.json();
|
|
209
|
+
const accessToken = typeof body.access_token === 'string' ? body.access_token : '';
|
|
210
|
+
if (!accessToken)
|
|
211
|
+
return { error: 'Token refresh returned no access token' };
|
|
212
|
+
const expiresIn = typeof body.expires_in === 'number' ? body.expires_in : 3600;
|
|
213
|
+
return {
|
|
214
|
+
accessToken,
|
|
215
|
+
refreshToken: typeof body.refresh_token === 'string' && body.refresh_token ? body.refresh_token : null,
|
|
216
|
+
expiresAt: Date.now() + expiresIn * 1000,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
catch (err) {
|
|
220
|
+
const reason = err?.name === 'TimeoutError' ? 'request timed out' : (err?.message ?? 'request failed');
|
|
221
|
+
return { error: `Could not reach the OAuth token endpoint (${reason})` };
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/** Write refreshed tokens into a credentials file, preserving everything else. */
|
|
225
|
+
function writeRefreshedTokens(filePath, refreshed) {
|
|
226
|
+
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
227
|
+
const oauth = (data.claudeAiOauth ?? {});
|
|
228
|
+
oauth.accessToken = refreshed.accessToken;
|
|
229
|
+
oauth.expiresAt = refreshed.expiresAt;
|
|
230
|
+
if (refreshed.refreshToken)
|
|
231
|
+
oauth.refreshToken = refreshed.refreshToken;
|
|
232
|
+
data.claudeAiOauth = oauth;
|
|
233
|
+
writeCredentialsAtomic(filePath, JSON.stringify(data));
|
|
234
|
+
}
|
|
235
|
+
function grantCacheKey(token) {
|
|
236
|
+
return crypto.createHash('sha256').update(token).digest('hex').slice(0, 12);
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Fetch (refreshing first when needed and allowed) the gauges for one grant.
|
|
240
|
+
* Cached per grant so repeated listings don't hammer Anthropic.
|
|
241
|
+
*/
|
|
242
|
+
function fetchUsageForGrant(cacheKey, group) {
|
|
243
|
+
const cached = grantUsageCache.get(cacheKey);
|
|
244
|
+
if (cached && Date.now() < cached.validUntil)
|
|
245
|
+
return Promise.resolve(cached);
|
|
246
|
+
const inFlight = grantUsageInFlight.get(cacheKey);
|
|
247
|
+
if (inFlight)
|
|
248
|
+
return inFlight;
|
|
249
|
+
const promise = (async () => {
|
|
250
|
+
const now = Date.now();
|
|
251
|
+
const { tokens } = group;
|
|
252
|
+
let accessToken = tokens.accessToken;
|
|
253
|
+
let error = null;
|
|
254
|
+
const accessExpired = typeof tokens.expiresAt === 'number' && tokens.expiresAt <= now;
|
|
255
|
+
if (accessExpired) {
|
|
256
|
+
if (group.includesActive) {
|
|
257
|
+
// The CLI owns the active grant; it refreshes on its next session.
|
|
258
|
+
accessToken = null;
|
|
259
|
+
error = 'Token expired — start any Claude session to refresh it';
|
|
260
|
+
}
|
|
261
|
+
else if (!tokens.refreshToken) {
|
|
262
|
+
accessToken = null;
|
|
263
|
+
error = 'Token expired and no refresh token stored — sign in to this account again';
|
|
264
|
+
}
|
|
265
|
+
else if (typeof tokens.refreshTokenExpiresAt === 'number' && tokens.refreshTokenExpiresAt <= now) {
|
|
266
|
+
accessToken = null;
|
|
267
|
+
error = 'Session expired — sign in to this account again';
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
const refreshed = await refreshOAuthGrant(tokens.refreshToken);
|
|
271
|
+
if ('error' in refreshed) {
|
|
272
|
+
accessToken = null;
|
|
273
|
+
error = refreshed.error;
|
|
274
|
+
const entry = {
|
|
275
|
+
rateLimits: null,
|
|
276
|
+
error,
|
|
277
|
+
fetchedAt: Date.now(),
|
|
278
|
+
validUntil: Date.now() + GRANT_REFRESH_FAIL_COOLDOWN_MS,
|
|
279
|
+
};
|
|
280
|
+
grantUsageCache.set(cacheKey, entry);
|
|
281
|
+
return entry;
|
|
282
|
+
}
|
|
283
|
+
for (const filePath of group.paths) {
|
|
284
|
+
try {
|
|
285
|
+
writeRefreshedTokens(filePath, refreshed);
|
|
286
|
+
}
|
|
287
|
+
catch (err) {
|
|
288
|
+
log.warn(`Refreshed grant but failed to persist to ${filePath}: ${err}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
log.log(`Refreshed dormant Claude credentials for profile(s): ${group.ids.join(', ')}`);
|
|
292
|
+
accessToken = refreshed.accessToken;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (!accessToken) {
|
|
296
|
+
const entry = {
|
|
297
|
+
rateLimits: null,
|
|
298
|
+
error: error ?? 'No OAuth token in credentials file',
|
|
299
|
+
fetchedAt: Date.now(),
|
|
300
|
+
validUntil: Date.now() + GRANT_USAGE_TTL_ERR_MS,
|
|
301
|
+
};
|
|
302
|
+
grantUsageCache.set(cacheKey, entry);
|
|
303
|
+
return entry;
|
|
304
|
+
}
|
|
305
|
+
const result = await fetchClaudeRateLimitsForToken(accessToken);
|
|
306
|
+
const entry = {
|
|
307
|
+
rateLimits: result.rateLimits,
|
|
308
|
+
error: result.error,
|
|
309
|
+
fetchedAt: Date.now(),
|
|
310
|
+
validUntil: Date.now() + (result.error ? GRANT_USAGE_TTL_ERR_MS : GRANT_USAGE_TTL_OK_MS),
|
|
311
|
+
};
|
|
312
|
+
grantUsageCache.set(cacheKey, entry);
|
|
313
|
+
return entry;
|
|
314
|
+
})().finally(() => grantUsageInFlight.delete(cacheKey));
|
|
315
|
+
grantUsageInFlight.set(cacheKey, promise);
|
|
316
|
+
return promise;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Session (5h) + weekly rate-limit gauges for every stored credential profile,
|
|
320
|
+
* keyed by profile id ("active" or name). Dormant grants are refreshed first
|
|
321
|
+
* (once per grant, tokens persisted back to every copy) so limits stay visible
|
|
322
|
+
* for accounts that haven't run in days.
|
|
323
|
+
*/
|
|
324
|
+
export async function getClaudeCredentialProfilesUsage() {
|
|
325
|
+
const list = listClaudeCredentialProfiles();
|
|
326
|
+
const candidates = [...(list.active ? [list.active] : []), ...list.profiles];
|
|
327
|
+
// Group profiles by grant: same refresh token (or same access token when a
|
|
328
|
+
// file has no refresh token) = same login, refreshed and fetched once.
|
|
329
|
+
const groups = new Map();
|
|
330
|
+
const usage = [];
|
|
331
|
+
for (const meta of candidates) {
|
|
332
|
+
if (!meta.valid) {
|
|
333
|
+
usage.push({ id: meta.id, rateLimits: null, error: 'Invalid credentials file', fetchedAt: Date.now() });
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
const tokens = readProfileTokens(meta.path);
|
|
337
|
+
const grantToken = tokens.refreshToken ?? tokens.accessToken;
|
|
338
|
+
if (!grantToken) {
|
|
339
|
+
usage.push({ id: meta.id, rateLimits: null, error: 'No OAuth token in credentials file', fetchedAt: Date.now() });
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
const key = grantCacheKey(grantToken);
|
|
343
|
+
const existing = groups.get(key);
|
|
344
|
+
if (existing) {
|
|
345
|
+
existing.ids.push(meta.id);
|
|
346
|
+
if (!existing.paths.includes(meta.path))
|
|
347
|
+
existing.paths.push(meta.path);
|
|
348
|
+
existing.includesActive = existing.includesActive || meta.id === 'active';
|
|
349
|
+
// Prefer a non-expired access token from any copy of the grant.
|
|
350
|
+
const expired = typeof existing.tokens.expiresAt === 'number' && existing.tokens.expiresAt <= Date.now();
|
|
351
|
+
if (expired && tokens.accessToken && (tokens.expiresAt ?? 0) > Date.now()) {
|
|
352
|
+
existing.tokens = tokens;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
else {
|
|
356
|
+
groups.set(key, { ids: [meta.id], paths: [meta.path], includesActive: meta.id === 'active', tokens });
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
await Promise.all(Array.from(groups.entries()).map(async ([key, group]) => {
|
|
360
|
+
const entry = await fetchUsageForGrant(key, group);
|
|
361
|
+
for (const id of group.ids) {
|
|
362
|
+
usage.push({ id, rateLimits: entry.rateLimits, error: entry.error, fetchedAt: entry.fetchedAt });
|
|
363
|
+
}
|
|
364
|
+
}));
|
|
365
|
+
return { usage };
|
|
366
|
+
}
|
|
367
|
+
function assertValidName(name) {
|
|
368
|
+
if (!isValidProfileName(name)) {
|
|
369
|
+
throw new Error(`Invalid profile name "${name}". Use 1–64 chars: letters, digits, dot, underscore, hyphen; must start with alphanumeric.`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
function ensureClaudeDir() {
|
|
373
|
+
const dir = getClaudeDir();
|
|
374
|
+
if (!fs.existsSync(dir)) {
|
|
375
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
/** Atomic write with restrictive permissions (credentials are secrets). */
|
|
379
|
+
function writeCredentialsAtomic(filePath, content) {
|
|
380
|
+
ensureClaudeDir();
|
|
381
|
+
const dir = path.dirname(filePath);
|
|
382
|
+
const tmp = path.join(dir, `.credentials.tmp.${process.pid}.${Date.now()}`);
|
|
383
|
+
fs.writeFileSync(tmp, content, { encoding: 'utf-8', mode: 0o600 });
|
|
384
|
+
fs.renameSync(tmp, filePath);
|
|
385
|
+
try {
|
|
386
|
+
fs.chmodSync(filePath, 0o600);
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
// best-effort on platforms that ignore mode
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Promote a named profile to the active credentials file (copy model).
|
|
394
|
+
*
|
|
395
|
+
* If the current active file is unique (does not match any named profile),
|
|
396
|
+
* `stashActiveAs` is required so the previous account is not lost.
|
|
397
|
+
* When the active file already matches a named profile, that named file is
|
|
398
|
+
* refreshed from active first (token refresh may have updated it).
|
|
399
|
+
*/
|
|
400
|
+
export function switchClaudeCredentialProfile(name, opts = {}) {
|
|
401
|
+
assertValidName(name);
|
|
402
|
+
const targetFile = namedPath(name);
|
|
403
|
+
if (!fs.existsSync(targetFile)) {
|
|
404
|
+
throw new Error(`Named profile "${name}" not found at ${targetFile}`);
|
|
405
|
+
}
|
|
406
|
+
const targetParsed = parseCredentialsFile(targetFile);
|
|
407
|
+
if (!targetParsed?.valid) {
|
|
408
|
+
throw new Error(`Named profile "${name}" is missing a valid OAuth token`);
|
|
409
|
+
}
|
|
410
|
+
const listing = listClaudeCredentialProfiles();
|
|
411
|
+
const activeFile = activePath();
|
|
412
|
+
let stashedAs = null;
|
|
413
|
+
const previousMatchesNamed = listing.active?.matchesNamed ?? null;
|
|
414
|
+
if (listing.active?.valid && listing.active.fingerprint) {
|
|
415
|
+
if (listing.active.fingerprint === targetParsed.fingerprint) {
|
|
416
|
+
// Already on this account — still refresh active from named in case of drift.
|
|
417
|
+
writeCredentialsAtomic(activeFile, targetParsed.raw);
|
|
418
|
+
resetClaudeRateLimitCache();
|
|
419
|
+
const after = listClaudeCredentialProfiles();
|
|
420
|
+
return {
|
|
421
|
+
ok: true,
|
|
422
|
+
active: after.active,
|
|
423
|
+
profiles: after.profiles,
|
|
424
|
+
stashedAs: null,
|
|
425
|
+
previousMatchesNamed,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
if (!previousMatchesNamed) {
|
|
429
|
+
const stash = opts.stashActiveAs;
|
|
430
|
+
if (!stash) {
|
|
431
|
+
throw new Error('Active credentials are not saved as a named profile. Pass stashActiveAs to keep them before switching.');
|
|
432
|
+
}
|
|
433
|
+
assertValidName(stash);
|
|
434
|
+
if (stash === name) {
|
|
435
|
+
throw new Error('stashActiveAs cannot be the same as the profile you are switching to');
|
|
436
|
+
}
|
|
437
|
+
const stashFile = namedPath(stash);
|
|
438
|
+
if (fs.existsSync(stashFile)) {
|
|
439
|
+
const existing = parseCredentialsFile(stashFile);
|
|
440
|
+
if (existing?.fingerprint && existing.fingerprint !== listing.active.fingerprint) {
|
|
441
|
+
throw new Error(`Profile "${stash}" already exists with different credentials. Choose another name.`);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
const activeRaw = parseCredentialsFile(activeFile)?.raw;
|
|
445
|
+
if (!activeRaw)
|
|
446
|
+
throw new Error('Failed to read active credentials for stash');
|
|
447
|
+
writeCredentialsAtomic(stashFile, activeRaw);
|
|
448
|
+
stashedAs = stash;
|
|
449
|
+
log.log(`Stashed previous active Claude credentials as profile "${stash}"`);
|
|
450
|
+
}
|
|
451
|
+
else {
|
|
452
|
+
// Refresh the named copy of the currently-active account (token refresh).
|
|
453
|
+
const matchedRaw = parseCredentialsFile(activeFile)?.raw;
|
|
454
|
+
if (matchedRaw) {
|
|
455
|
+
writeCredentialsAtomic(namedPath(previousMatchesNamed), matchedRaw);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
writeCredentialsAtomic(activeFile, targetParsed.raw);
|
|
460
|
+
resetClaudeRateLimitCache();
|
|
461
|
+
log.log(`Switched active Claude credentials to profile "${name}"`);
|
|
462
|
+
const after = listClaudeCredentialProfiles();
|
|
463
|
+
if (!after.active)
|
|
464
|
+
throw new Error('Active credentials missing after switch');
|
|
465
|
+
return {
|
|
466
|
+
ok: true,
|
|
467
|
+
active: after.active,
|
|
468
|
+
profiles: after.profiles,
|
|
469
|
+
stashedAs,
|
|
470
|
+
previousMatchesNamed,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Save (copy) the current active credentials as a named profile.
|
|
475
|
+
* Overwrites an existing named profile only when force=true or fingerprints match.
|
|
476
|
+
*/
|
|
477
|
+
export function saveActiveClaudeCredentialProfile(name, opts = {}) {
|
|
478
|
+
assertValidName(name);
|
|
479
|
+
const activeFile = activePath();
|
|
480
|
+
const activeParsed = parseCredentialsFile(activeFile);
|
|
481
|
+
if (!activeParsed?.valid || !activeParsed.raw) {
|
|
482
|
+
throw new Error('No valid active Claude credentials to save');
|
|
483
|
+
}
|
|
484
|
+
const dest = namedPath(name);
|
|
485
|
+
if (fs.existsSync(dest) && !opts.force) {
|
|
486
|
+
const existing = parseCredentialsFile(dest);
|
|
487
|
+
if (existing?.fingerprint && existing.fingerprint !== activeParsed.fingerprint) {
|
|
488
|
+
throw new Error(`Profile "${name}" already exists with different credentials. Pass force=true to overwrite.`);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
writeCredentialsAtomic(dest, activeParsed.raw);
|
|
492
|
+
log.log(`Saved active Claude credentials as profile "${name}"`);
|
|
493
|
+
return listClaudeCredentialProfiles();
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Rename a named profile file.
|
|
497
|
+
*/
|
|
498
|
+
export function renameClaudeCredentialProfile(from, to) {
|
|
499
|
+
assertValidName(from);
|
|
500
|
+
assertValidName(to);
|
|
501
|
+
if (from === to)
|
|
502
|
+
return listClaudeCredentialProfiles();
|
|
503
|
+
const src = namedPath(from);
|
|
504
|
+
const dest = namedPath(to);
|
|
505
|
+
if (!fs.existsSync(src))
|
|
506
|
+
throw new Error(`Named profile "${from}" not found`);
|
|
507
|
+
if (fs.existsSync(dest))
|
|
508
|
+
throw new Error(`Profile "${to}" already exists`);
|
|
509
|
+
fs.renameSync(src, dest);
|
|
510
|
+
try {
|
|
511
|
+
fs.chmodSync(dest, 0o600);
|
|
512
|
+
}
|
|
513
|
+
catch {
|
|
514
|
+
// ignore
|
|
515
|
+
}
|
|
516
|
+
log.log(`Renamed Claude credentials profile "${from}" → "${to}"`);
|
|
517
|
+
return listClaudeCredentialProfiles();
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Delete a named profile. Refuses to delete when it is the only copy of the
|
|
521
|
+
* currently active credentials (would leave no backup).
|
|
522
|
+
*/
|
|
523
|
+
export function deleteClaudeCredentialProfile(name) {
|
|
524
|
+
assertValidName(name);
|
|
525
|
+
const file = namedPath(name);
|
|
526
|
+
if (!fs.existsSync(file))
|
|
527
|
+
throw new Error(`Named profile "${name}" not found`);
|
|
528
|
+
const listing = listClaudeCredentialProfiles();
|
|
529
|
+
if (listing.active?.matchesNamed === name) {
|
|
530
|
+
// Active still has the tokens; deleting the named backup is allowed but
|
|
531
|
+
// means active becomes "unsaved". That is intentional (user asked to delete).
|
|
532
|
+
}
|
|
533
|
+
fs.unlinkSync(file);
|
|
534
|
+
log.log(`Deleted Claude credentials profile "${name}"`);
|
|
535
|
+
return listClaudeCredentialProfiles();
|
|
536
|
+
}
|
|
@@ -304,35 +304,12 @@ function parseRetryAfterMs(headerValue) {
|
|
|
304
304
|
return undefined;
|
|
305
305
|
}
|
|
306
306
|
/**
|
|
307
|
-
* Fetch the
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
* This is the RAW network call. Go through `getClaudeRateLimits()` instead —
|
|
313
|
-
* it caches, dedupes, and rate-limits calls to this so we don't hammer (and get
|
|
314
|
-
* 429'd by) the Anthropic endpoint.
|
|
307
|
+
* Fetch the `/usage` gauges for an arbitrary Claude OAuth access token.
|
|
308
|
+
* RAW network call, no caching — exported for the multi-account switcher,
|
|
309
|
+
* which shows each stored profile's session/weekly limits and does its own
|
|
310
|
+
* per-fingerprint caching (claude-credentials-service).
|
|
315
311
|
*/
|
|
316
|
-
async function
|
|
317
|
-
let accessToken;
|
|
318
|
-
try {
|
|
319
|
-
if (!fs.existsSync(CREDENTIALS_PATH)) {
|
|
320
|
-
return { rateLimits: null, error: 'No Claude CLI credentials found' };
|
|
321
|
-
}
|
|
322
|
-
const credentials = JSON.parse(fs.readFileSync(CREDENTIALS_PATH, 'utf-8'));
|
|
323
|
-
const oauth = credentials?.claudeAiOauth;
|
|
324
|
-
if (!oauth || typeof oauth.accessToken !== 'string' || oauth.accessToken === '') {
|
|
325
|
-
return { rateLimits: null, error: 'Claude CLI credentials are missing an OAuth token' };
|
|
326
|
-
}
|
|
327
|
-
if (typeof oauth.expiresAt === 'number' && oauth.expiresAt <= Date.now()) {
|
|
328
|
-
return { rateLimits: null, error: 'Claude CLI OAuth token has expired — run any Claude session to refresh it' };
|
|
329
|
-
}
|
|
330
|
-
accessToken = oauth.accessToken;
|
|
331
|
-
}
|
|
332
|
-
catch (err) {
|
|
333
|
-
log.warn(`Failed to read Claude credentials at ${CREDENTIALS_PATH}: ${err}`);
|
|
334
|
-
return { rateLimits: null, error: 'Failed to read Claude CLI credentials' };
|
|
335
|
-
}
|
|
312
|
+
export async function fetchClaudeRateLimitsForToken(accessToken) {
|
|
336
313
|
try {
|
|
337
314
|
const response = await fetch(OAUTH_USAGE_URL, {
|
|
338
315
|
headers: {
|
|
@@ -368,6 +345,36 @@ async function fetchClaudeRateLimitsFromApi() {
|
|
|
368
345
|
return { rateLimits: null, error: `Could not reach Anthropic usage endpoint (${reason})` };
|
|
369
346
|
}
|
|
370
347
|
}
|
|
348
|
+
/**
|
|
349
|
+
* Fetch the live session/weekly rate-limit gauges — the data behind the
|
|
350
|
+
* progress bars in the CLI's `/usage` panel — using the OAuth token the CLI
|
|
351
|
+
* keeps in `~/.claude/.credentials.json`. Returns the gauges or an error
|
|
352
|
+
* string (never throws); the caller folds either into the snapshot.
|
|
353
|
+
*
|
|
354
|
+
* This is the RAW network call. Go through `getClaudeRateLimits()` instead —
|
|
355
|
+
* it caches, dedupes, and rate-limits calls to this so we don't hammer (and get
|
|
356
|
+
* 429'd by) the Anthropic endpoint.
|
|
357
|
+
*/
|
|
358
|
+
async function fetchClaudeRateLimitsFromApi() {
|
|
359
|
+
try {
|
|
360
|
+
if (!fs.existsSync(CREDENTIALS_PATH)) {
|
|
361
|
+
return { rateLimits: null, error: 'No Claude CLI credentials found' };
|
|
362
|
+
}
|
|
363
|
+
const credentials = JSON.parse(fs.readFileSync(CREDENTIALS_PATH, 'utf-8'));
|
|
364
|
+
const oauth = credentials?.claudeAiOauth;
|
|
365
|
+
if (!oauth || typeof oauth.accessToken !== 'string' || oauth.accessToken === '') {
|
|
366
|
+
return { rateLimits: null, error: 'Claude CLI credentials are missing an OAuth token' };
|
|
367
|
+
}
|
|
368
|
+
if (typeof oauth.expiresAt === 'number' && oauth.expiresAt <= Date.now()) {
|
|
369
|
+
return { rateLimits: null, error: 'Claude CLI OAuth token has expired — run any Claude session to refresh it' };
|
|
370
|
+
}
|
|
371
|
+
return await fetchClaudeRateLimitsForToken(oauth.accessToken);
|
|
372
|
+
}
|
|
373
|
+
catch (err) {
|
|
374
|
+
log.warn(`Failed to read Claude credentials at ${CREDENTIALS_PATH}: ${err}`);
|
|
375
|
+
return { rateLimits: null, error: 'Failed to read Claude CLI credentials' };
|
|
376
|
+
}
|
|
377
|
+
}
|
|
371
378
|
// Account-wide, so a single cache line serves every agent/client. Never keyed.
|
|
372
379
|
let rateLimitCache = null;
|
|
373
380
|
// Last successful gauges, kept so we can serve them (stale) while backing off —
|