codex-copilot-dx 0.5.6 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,205 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { atomicWriteFileSync } from "./atomic-file.mjs";
5
+ import {
6
+ githubTokenLockPath,
7
+ githubTokenMetadataPath,
8
+ githubTokenPath,
9
+ } from "./auth.mjs";
10
+ import {
11
+ githubTokenFingerprint,
12
+ normalizeGithubIdentity,
13
+ } from "./github-identity.mjs";
14
+ import { withFileLock } from "./lock.mjs";
15
+ import { parsePositiveInteger, RUNTIME_DEFAULTS } from "./runtime-config.mjs";
16
+
17
+ export const AUTH_PROFILE_CODEX = "codex";
18
+ export const AUTH_PROFILE_CLAUDE = "claude";
19
+
20
+ const AUTH_PROFILES = new Set([AUTH_PROFILE_CODEX, AUTH_PROFILE_CLAUDE]);
21
+
22
+ function checkedProfile(profile) {
23
+ const value = String(profile || "").trim().toLowerCase();
24
+ if (!AUTH_PROFILES.has(value)) throw new Error(`Unsupported authentication profile: ${profile}`);
25
+ return value;
26
+ }
27
+
28
+ export function authProfilePaths(profile, { home = os.homedir() } = {}) {
29
+ const name = checkedProfile(profile);
30
+ if (name === AUTH_PROFILE_CODEX) {
31
+ return {
32
+ profile: name,
33
+ tokenPath: githubTokenPath(home),
34
+ metadataPath: githubTokenMetadataPath(home),
35
+ lockPath: githubTokenLockPath(home),
36
+ };
37
+ }
38
+
39
+ const directory = path.join(home, ".local", "share", "copilot-api", "profiles", AUTH_PROFILE_CLAUDE);
40
+ const tokenPath = path.join(directory, "github_token");
41
+ return {
42
+ profile: name,
43
+ tokenPath,
44
+ metadataPath: `${tokenPath}.account.json`,
45
+ lockPath: `${tokenPath}.lock`,
46
+ };
47
+ }
48
+
49
+ function readOptionalFile(filePath) {
50
+ try {
51
+ return { exists: true, data: fs.readFileSync(filePath), mode: fs.statSync(filePath).mode & 0o777 };
52
+ } catch (error) {
53
+ if (error?.code === "ENOENT") return { exists: false, data: null, mode: 0o600 };
54
+ throw error;
55
+ }
56
+ }
57
+
58
+ function parsedMetadata(snapshot) {
59
+ if (!snapshot.exists) return { value: null, malformed: false };
60
+ try {
61
+ return { value: JSON.parse(snapshot.data.toString("utf8")), malformed: false };
62
+ } catch {
63
+ return { value: null, malformed: true };
64
+ }
65
+ }
66
+
67
+ export function readAuthProfileCredentials(profile, { home = os.homedir() } = {}) {
68
+ const paths = authProfilePaths(profile, { home });
69
+ const tokenSnapshot = readOptionalFile(paths.tokenPath);
70
+ const metadataSnapshot = readOptionalFile(paths.metadataPath);
71
+ const token = tokenSnapshot.exists ? tokenSnapshot.data.toString("utf8").trim() : "";
72
+ const metadataResult = parsedMetadata(metadataSnapshot);
73
+ const metadataFingerprint = metadataResult.value?.token_fingerprint;
74
+ const metadataMatchesToken = Boolean(token)
75
+ && Boolean(metadataFingerprint)
76
+ && metadataFingerprint === githubTokenFingerprint(token);
77
+ const metadataIdentity = normalizeGithubIdentity(metadataResult.value);
78
+ const identity = metadataMatchesToken ? metadataIdentity : null;
79
+
80
+ if (paths.profile === AUTH_PROFILE_CODEX) {
81
+ const configured = tokenSnapshot.exists;
82
+ return {
83
+ profile: paths.profile,
84
+ configured,
85
+ valid: configured && Boolean(token),
86
+ reason: !configured ? "unconfigured" : token ? "" : "empty_token",
87
+ token,
88
+ identity,
89
+ metadata: metadataResult.value,
90
+ paths,
91
+ };
92
+ }
93
+
94
+ // Metadata is written last, but any credential artifact reserves the Claude
95
+ // profile. A partial write must fail closed instead of borrowing Codex.
96
+ const configured = metadataSnapshot.exists || tokenSnapshot.exists;
97
+ let reason = "";
98
+ if (!configured) reason = "unconfigured";
99
+ else if (!metadataSnapshot.exists) reason = "missing_metadata";
100
+ else if (metadataResult.malformed) reason = "metadata_malformed";
101
+ else if (!tokenSnapshot.exists) reason = "missing_token";
102
+ else if (!token) reason = "empty_token";
103
+ else if (!metadataIdentity) reason = "metadata_identity_missing";
104
+ else if (!metadataFingerprint || metadataFingerprint !== githubTokenFingerprint(token)) reason = "token_metadata_mismatch";
105
+
106
+ return {
107
+ profile: paths.profile,
108
+ configured,
109
+ valid: configured && !reason,
110
+ reason,
111
+ token,
112
+ identity,
113
+ metadata: metadataResult.value,
114
+ paths,
115
+ };
116
+ }
117
+
118
+ function lockOptions(env = process.env) {
119
+ return {
120
+ timeoutMs: parsePositiveInteger(env.CCDX_TOKEN_LOCK_TIMEOUT_MS, RUNTIME_DEFAULTS.tokenLockTimeoutMs),
121
+ staleMs: parsePositiveInteger(env.CCDX_TOKEN_LOCK_STALE_MS, RUNTIME_DEFAULTS.tokenLockStaleMs),
122
+ };
123
+ }
124
+
125
+ export function withAuthProfileLock(profile, fn, {
126
+ home = os.homedir(),
127
+ env = process.env,
128
+ ...options
129
+ } = {}) {
130
+ const { lockPath } = authProfilePaths(profile, { home });
131
+ return withFileLock(lockPath, fn, { ...lockOptions(env), ...options });
132
+ }
133
+
134
+ function writeSecret(filePath, data, writeFile) {
135
+ writeFile(filePath, data, { mode: 0o600 });
136
+ fs.chmodSync(filePath, 0o600);
137
+ }
138
+
139
+ function restoreSnapshot(filePath, snapshot, writeFile) {
140
+ if (snapshot.exists) {
141
+ writeSecret(filePath, snapshot.data, writeFile);
142
+ return;
143
+ }
144
+ try {
145
+ fs.unlinkSync(filePath);
146
+ } catch (error) {
147
+ if (error?.code !== "ENOENT") throw error;
148
+ }
149
+ }
150
+
151
+ export function writeClaudeAuthProfile(token, identity, {
152
+ home = os.homedir(),
153
+ now = () => new Date(),
154
+ writeFile = atomicWriteFileSync,
155
+ } = {}) {
156
+ const cleanToken = String(token || "").trim();
157
+ const normalizedIdentity = normalizeGithubIdentity(identity);
158
+ if (!cleanToken) throw new Error("Claude GitHub token is empty");
159
+ if (!normalizedIdentity) throw new Error("Claude GitHub account identity is missing");
160
+
161
+ const paths = authProfilePaths(AUTH_PROFILE_CLAUDE, { home });
162
+ const directory = path.dirname(paths.tokenPath);
163
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
164
+ fs.chmodSync(directory, 0o700);
165
+
166
+ const previousToken = readOptionalFile(paths.tokenPath);
167
+ const previousMetadata = readOptionalFile(paths.metadataPath);
168
+ const timestamp = now();
169
+ const updatedAt = timestamp instanceof Date ? timestamp.toISOString() : new Date(timestamp).toISOString();
170
+ const metadata = `${JSON.stringify({
171
+ profile: AUTH_PROFILE_CLAUDE,
172
+ ...normalizedIdentity,
173
+ token_fingerprint: githubTokenFingerprint(cleanToken),
174
+ updated_at: updatedAt,
175
+ }, null, 2)}\n`;
176
+
177
+ try {
178
+ writeSecret(paths.tokenPath, cleanToken, writeFile);
179
+ // Commit the activation marker last. Readers never observe a configured
180
+ // Claude profile unless the token and its fingerprint agree.
181
+ writeSecret(paths.metadataPath, metadata, writeFile);
182
+ } catch (error) {
183
+ try {
184
+ restoreSnapshot(paths.tokenPath, previousToken, writeFile);
185
+ restoreSnapshot(paths.metadataPath, previousMetadata, writeFile);
186
+ } catch (rollbackError) {
187
+ error.rollbackError = rollbackError;
188
+ }
189
+ throw error;
190
+ }
191
+
192
+ return {
193
+ profile: AUTH_PROFILE_CLAUDE,
194
+ identity: normalizedIdentity,
195
+ paths,
196
+ };
197
+ }
198
+
199
+ export function profileReauthMessage(profile, { home = os.homedir() } = {}) {
200
+ const name = checkedProfile(profile);
201
+ if (name === AUTH_PROFILE_CLAUDE) {
202
+ return "Claude authentication is unavailable. Run `ccdx auth login claude --reauth`, then restart ccdx.";
203
+ }
204
+ return `Codex authentication is unavailable at ${authProfilePaths(name, { home }).tokenPath}. Run ccdx again to log in.`;
205
+ }
@@ -0,0 +1,493 @@
1
+ import os from "node:os";
2
+ import { spawn } from "node:child_process";
3
+ import {
4
+ fetchGithubIdentity,
5
+ interpretPoll,
6
+ validateGithubToken,
7
+ } from "./auth.mjs";
8
+ import {
9
+ AUTH_PROFILE_CLAUDE,
10
+ AUTH_PROFILE_CODEX,
11
+ readAuthProfileCredentials,
12
+ withAuthProfileLock,
13
+ writeClaudeAuthProfile,
14
+ } from "./auth-profile.mjs";
15
+ import { buildHeaders, DEFAULT_API_BASE, FALLBACK_VSCODE_VERSION } from "./copilot.mjs";
16
+ import {
17
+ githubIdentitiesEqual,
18
+ githubTokenFingerprint,
19
+ normalizeGithubIdentity,
20
+ } from "./github-identity.mjs";
21
+ import { saveModelCache } from "./model-cache.mjs";
22
+ import { status } from "./status.mjs";
23
+
24
+ const CLIENT_ID = "Iv1.b507a08c87ecfe98";
25
+ const SCOPE = "read:user";
26
+ const DEVICE_CODE_URL = "https://github.com/login/device/code";
27
+ const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
28
+ const DEFAULT_DEVICE_INTERVAL_SECONDS = 5;
29
+ const DEFAULT_DEVICE_EXPIRES_SECONDS = 900;
30
+
31
+ function abortError(signal) {
32
+ if (signal?.reason instanceof Error) return signal.reason;
33
+ const error = new Error("The operation was aborted");
34
+ error.name = "AbortError";
35
+ return error;
36
+ }
37
+
38
+ function sleep(ms, { signal } = {}) {
39
+ if (signal?.aborted) return Promise.reject(abortError(signal));
40
+ return new Promise((resolve, reject) => {
41
+ const cleanup = () => signal?.removeEventListener("abort", onAbort);
42
+ const onAbort = () => {
43
+ clearTimeout(timer);
44
+ cleanup();
45
+ reject(abortError(signal));
46
+ };
47
+ const timer = setTimeout(() => {
48
+ cleanup();
49
+ resolve();
50
+ }, ms);
51
+ signal?.addEventListener("abort", onAbort, { once: true });
52
+ });
53
+ }
54
+
55
+ function openAndCopy(userCode, verificationUri) {
56
+ if (process.platform !== "darwin") return;
57
+ try {
58
+ const clipboard = spawn("pbcopy");
59
+ clipboard.on("error", () => {});
60
+ clipboard.stdin.on("error", () => {});
61
+ clipboard.stdin.end(userCode);
62
+ } catch {}
63
+ try {
64
+ const browser = spawn("open", [verificationUri], { detached: true, stdio: "ignore" });
65
+ browser.on("error", () => {});
66
+ } catch {}
67
+ }
68
+
69
+ function responseDetail(value) {
70
+ return String(value || "").replace(/\s+/g, " ").trim().slice(0, 240);
71
+ }
72
+
73
+ async function jsonResponse(response, label) {
74
+ if (!response.ok) throw new Error(`${label} failed with HTTP ${response.status}`);
75
+ try {
76
+ return await response.json();
77
+ } catch {
78
+ throw new Error(`${label} returned invalid JSON`);
79
+ }
80
+ }
81
+
82
+ export async function requestDeviceFlowToken({
83
+ fetchImpl = fetch,
84
+ signal,
85
+ log = console.log,
86
+ openAndCopyFn = openAndCopy,
87
+ sleepImpl = sleep,
88
+ now = Date.now,
89
+ } = {}) {
90
+ const codeResponse = await fetchImpl(DEVICE_CODE_URL, {
91
+ method: "POST",
92
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
93
+ body: JSON.stringify({ client_id: CLIENT_ID, scope: SCOPE }),
94
+ signal,
95
+ });
96
+ const code = await jsonResponse(codeResponse, "Device code request");
97
+ if (!code.device_code || !code.user_code || !code.verification_uri) {
98
+ throw new Error("Device code response is missing required fields");
99
+ }
100
+
101
+ log(`\n${status("info", `Open ${code.verification_uri}`)}\n${status("info", `Enter Claude account code: ${code.user_code}`)}\n`);
102
+ openAndCopyFn(code.user_code, code.verification_uri);
103
+
104
+ let waitMs = (Number(code.interval) || DEFAULT_DEVICE_INTERVAL_SECONDS) * 1000;
105
+ const expiresMs = (Number(code.expires_in) || DEFAULT_DEVICE_EXPIRES_SECONDS) * 1000;
106
+ const deadline = now() + expiresMs;
107
+
108
+ while (now() < deadline) {
109
+ await sleepImpl(waitMs, { signal });
110
+ const pollResponse = await fetchImpl(ACCESS_TOKEN_URL, {
111
+ method: "POST",
112
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
113
+ body: JSON.stringify({
114
+ client_id: CLIENT_ID,
115
+ device_code: code.device_code,
116
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
117
+ }),
118
+ signal,
119
+ });
120
+ if (!pollResponse.ok) continue;
121
+ const result = interpretPoll(await pollResponse.json());
122
+ if (result.state === "done") return result.token;
123
+ if (result.state === "slow") {
124
+ waitMs += 5000;
125
+ continue;
126
+ }
127
+ if (result.state === "fail") throw new Error(`Claude login failed: ${result.error}`);
128
+ }
129
+ throw new Error("Claude login failed: device code expired");
130
+ }
131
+
132
+ async function codexIdentity({ home, fetchImpl, signal }) {
133
+ const codex = readAuthProfileCredentials(AUTH_PROFILE_CODEX, { home });
134
+ if (!codex.valid) throw new Error("The existing Codex GitHub account is not configured; refusing to create an unbound Claude profile");
135
+ const tokenFingerprint = githubTokenFingerprint(codex.token);
136
+ if (codex.identity) return { identity: codex.identity, tokenFingerprint };
137
+ const identity = await fetchGithubIdentity(codex.token, { fetchImpl, signal });
138
+ if (!identity.ok) throw new Error("Could not verify the existing Codex GitHub account; its credential was not changed");
139
+ return { identity: normalizeGithubIdentity(identity), tokenFingerprint };
140
+ }
141
+
142
+ function claudeModels(payload) {
143
+ const models = Array.isArray(payload) ? payload : payload?.data;
144
+ if (!Array.isArray(models)) throw new Error("Claude account model catalog contained no model list");
145
+ return models.filter((model) => {
146
+ const id = String(model?.id || "").trim();
147
+ const vendor = String(model?.vendor || model?.owned_by || "").trim().toLowerCase();
148
+ const policy = String(model?.policy?.state || "").trim().toLowerCase();
149
+ const endpoints = Array.isArray(model?.supported_endpoints) ? model.supported_endpoints : [];
150
+ return Boolean(id)
151
+ && (id.toLowerCase().startsWith("claude-") || vendor === "anthropic")
152
+ && model?.model_picker_enabled === true
153
+ && (!policy || policy === "enabled")
154
+ && (endpoints.includes("/chat/completions") || endpoints.includes("/v1/messages"));
155
+ });
156
+ }
157
+
158
+ function catalogModels(payload) {
159
+ const models = Array.isArray(payload) ? payload : payload?.data;
160
+ if (!Array.isArray(models)) throw new Error("Copilot model catalog contained no model list");
161
+ return models;
162
+ }
163
+
164
+ async function fetchCopilotCatalog(tokenData, {
165
+ fetchImpl,
166
+ signal,
167
+ vscodeVersion = FALLBACK_VSCODE_VERSION,
168
+ label = "Copilot",
169
+ } = {}) {
170
+ const apiBase = tokenData?.endpoints?.api || DEFAULT_API_BASE;
171
+ const headers = buildHeaders({
172
+ token: tokenData.token,
173
+ version: vscodeVersion,
174
+ initiator: "user",
175
+ vision: false,
176
+ });
177
+ const modelResponse = await fetchImpl(`${apiBase}/models`, {
178
+ headers,
179
+ redirect: "error",
180
+ signal,
181
+ });
182
+ const modelText = await modelResponse.text();
183
+ if (!modelResponse.ok) {
184
+ const detail = responseDetail(modelText);
185
+ throw new Error(`${label} model catalog failed with HTTP ${modelResponse.status}${detail ? `: ${detail}` : ""}`);
186
+ }
187
+
188
+ let catalog;
189
+ try {
190
+ catalog = JSON.parse(modelText);
191
+ } catch {
192
+ throw new Error(`${label} model catalog returned invalid JSON`);
193
+ }
194
+ catalogModels(catalog);
195
+ return { catalog, apiBase };
196
+ }
197
+
198
+ export async function validateClaudeCandidate(githubToken, {
199
+ home = os.homedir(),
200
+ expectedLogin = "",
201
+ fetchImpl = fetch,
202
+ signal,
203
+ vscodeVersion = FALLBACK_VSCODE_VERSION,
204
+ } = {}) {
205
+ const validation = await validateGithubToken(githubToken, { fetchImpl, signal });
206
+ if (!validation.ok) {
207
+ const httpStatus = validation.status ? ` (HTTP ${validation.status})` : "";
208
+ throw new Error(`Claude GitHub/Copilot validation failed: ${validation.reason}${httpStatus}`);
209
+ }
210
+
211
+ const identity = normalizeGithubIdentity(validation);
212
+ if (!identity) throw new Error("Claude GitHub account identity is missing");
213
+ const pinnedLogin = String(expectedLogin || "").trim();
214
+ if (pinnedLogin && identity.login.toLowerCase() !== pinnedLogin.toLowerCase()) {
215
+ throw new Error(`Authorized GitHub account ${identity.login || identity.id} does not match requested Claude account ${pinnedLogin}`);
216
+ }
217
+
218
+ const existingCodex = await codexIdentity({ home, fetchImpl, signal });
219
+ if (githubIdentitiesEqual(identity, existingCodex.identity)) {
220
+ throw new Error(`Claude must use a different GitHub account; ${identity.login || identity.id} is already the Codex account`);
221
+ }
222
+
223
+ const { catalog, apiBase } = await fetchCopilotCatalog(validation.copilotTokenData, {
224
+ fetchImpl,
225
+ signal,
226
+ vscodeVersion,
227
+ label: "Claude account",
228
+ });
229
+ const models = claudeModels(catalog);
230
+ if (!models.length) {
231
+ throw new Error(`GitHub account ${identity.login || identity.id} has Copilot access but advertises no enabled Claude models`);
232
+ }
233
+
234
+ return {
235
+ identity,
236
+ models,
237
+ catalog,
238
+ apiBase,
239
+ codexTokenFingerprint: existingCodex.tokenFingerprint,
240
+ };
241
+ }
242
+
243
+ export async function authorizeClaudeProfile({
244
+ home = os.homedir(),
245
+ env = process.env,
246
+ reauth = false,
247
+ expectedLogin = "",
248
+ fetchImpl = fetch,
249
+ signal,
250
+ log = console.log,
251
+ openAndCopyFn = openAndCopy,
252
+ sleepImpl = sleep,
253
+ now = Date.now,
254
+ saveModelCacheFn = saveModelCache,
255
+ } = {}) {
256
+ return withAuthProfileLock(AUTH_PROFILE_CLAUDE, async () => {
257
+ const current = readAuthProfileCredentials(AUTH_PROFILE_CLAUDE, { home });
258
+ if (current.configured && current.valid && !reauth) {
259
+ const pinnedLogin = String(expectedLogin || "").trim();
260
+ const currentLogin = String(current.identity?.login || "");
261
+ if (pinnedLogin && currentLogin.toLowerCase() !== pinnedLogin.toLowerCase()) {
262
+ throw new Error(`Claude is already authenticated as ${currentLogin || current.identity?.id || "another account"}; use --reauth to authorize ${pinnedLogin}`);
263
+ }
264
+ return { changed: false, profile: AUTH_PROFILE_CLAUDE, identity: current.identity, models: [] };
265
+ }
266
+ if (current.configured && !reauth) {
267
+ throw new Error(`Claude profile is configured but invalid (${current.reason}); run ccdx auth login claude --reauth`);
268
+ }
269
+
270
+ const githubToken = await requestDeviceFlowToken({
271
+ fetchImpl,
272
+ signal,
273
+ log,
274
+ openAndCopyFn,
275
+ sleepImpl,
276
+ now,
277
+ });
278
+ const candidate = await validateClaudeCandidate(githubToken, {
279
+ home,
280
+ expectedLogin,
281
+ fetchImpl,
282
+ signal,
283
+ });
284
+ await withAuthProfileLock(AUTH_PROFILE_CODEX, () => {
285
+ const currentCodex = readAuthProfileCredentials(AUTH_PROFILE_CODEX, { home });
286
+ if (!currentCodex.valid
287
+ || githubTokenFingerprint(currentCodex.token) !== candidate.codexTokenFingerprint) {
288
+ throw new Error("Codex authentication changed while Claude was being authorized; no Claude credential was saved. Retry the command.");
289
+ }
290
+ writeClaudeAuthProfile(githubToken, candidate.identity, { home });
291
+ }, { home, env });
292
+ let modelCacheSaved = false;
293
+ try {
294
+ modelCacheSaved = saveModelCacheFn(candidate.catalog, {
295
+ home,
296
+ profile: AUTH_PROFILE_CLAUDE,
297
+ credentialFingerprint: githubTokenFingerprint(githubToken),
298
+ }) === true;
299
+ if (!modelCacheSaved) throw new Error("model catalog was not cacheable");
300
+ } catch (error) {
301
+ log(status("warn", `Claude authentication succeeded, but its model cache could not be saved (${error.message})`));
302
+ }
303
+ log(status("ok", `Claude authenticated as ${candidate.identity.login || candidate.identity.id}; existing Codex authentication was not changed`));
304
+ return {
305
+ changed: true,
306
+ profile: AUTH_PROFILE_CLAUDE,
307
+ identity: candidate.identity,
308
+ models: candidate.models,
309
+ catalog: candidate.catalog,
310
+ apiBase: candidate.apiBase,
311
+ modelCacheSaved,
312
+ };
313
+ }, { home, env });
314
+ }
315
+
316
+ function publicProfile(credentials, source) {
317
+ return {
318
+ configured: credentials.configured,
319
+ valid: credentials.valid,
320
+ reason: credentials.reason,
321
+ login: credentials.identity?.login || "",
322
+ id: credentials.identity?.id || "",
323
+ source,
324
+ };
325
+ }
326
+
327
+ function readStatusCredentials(profile, home) {
328
+ try {
329
+ return readAuthProfileCredentials(profile, { home });
330
+ } catch {
331
+ // Treat an unreadable optional profile as configured-but-invalid. This
332
+ // keeps status useful without leaking a filesystem path or silently
333
+ // borrowing the other account.
334
+ return {
335
+ profile,
336
+ configured: true,
337
+ valid: false,
338
+ reason: "credential_read_failed",
339
+ token: "",
340
+ identity: null,
341
+ };
342
+ }
343
+ }
344
+
345
+ export function authStatus({ home = os.homedir() } = {}) {
346
+ const codex = readStatusCredentials(AUTH_PROFILE_CODEX, home);
347
+ const claude = readStatusCredentials(AUTH_PROFILE_CLAUDE, home);
348
+ return {
349
+ profiles: {
350
+ codex: publicProfile(codex, "legacy"),
351
+ claude: publicProfile(claude, "isolated"),
352
+ },
353
+ routing: {
354
+ responses: AUTH_PROFILE_CODEX,
355
+ messages: claude.configured ? AUTH_PROFILE_CLAUDE : AUTH_PROFILE_CODEX,
356
+ },
357
+ };
358
+ }
359
+
360
+ async function inspectProfileOnline(credentials, {
361
+ fetchImpl,
362
+ timeoutMs,
363
+ inherited = false,
364
+ } = {}) {
365
+ if (inherited) {
366
+ return { checked: false, inherited: true, ok: null, reason: "inherits_codex" };
367
+ }
368
+ if (!credentials.valid) {
369
+ return { checked: false, inherited: false, ok: false, reason: credentials.reason || "unconfigured" };
370
+ }
371
+
372
+ const controller = new AbortController();
373
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
374
+ try {
375
+ const validation = await validateGithubToken(credentials.token, {
376
+ fetchImpl,
377
+ signal: controller.signal,
378
+ });
379
+ if (!validation.ok) {
380
+ return {
381
+ checked: true,
382
+ inherited: false,
383
+ ok: false,
384
+ reason: validation.reason,
385
+ httpStatus: validation.status || null,
386
+ };
387
+ }
388
+ const { catalog, apiBase } = await fetchCopilotCatalog(validation.copilotTokenData, {
389
+ fetchImpl,
390
+ signal: controller.signal,
391
+ label: credentials.profile === AUTH_PROFILE_CLAUDE ? "Claude account" : "Codex account",
392
+ });
393
+ const models = catalogModels(catalog);
394
+ let upstreamHost = "GitHub Copilot";
395
+ try { upstreamHost = new URL(apiBase).hostname; } catch {}
396
+ return {
397
+ checked: true,
398
+ inherited: false,
399
+ ok: true,
400
+ login: validation.login || credentials.identity?.login || "",
401
+ models: models.length,
402
+ claudeModels: claudeModels(catalog).length,
403
+ upstreamHost,
404
+ };
405
+ } catch (error) {
406
+ return {
407
+ checked: true,
408
+ inherited: false,
409
+ ok: false,
410
+ reason: controller.signal.aborted ? `timed out after ${timeoutMs}ms` : error.message,
411
+ };
412
+ } finally {
413
+ clearTimeout(timer);
414
+ }
415
+ }
416
+
417
+ export async function authStatusOnline({
418
+ home = os.homedir(),
419
+ fetchImpl = fetch,
420
+ timeoutMs = 10000,
421
+ } = {}) {
422
+ const local = authStatus({ home });
423
+ const codexCredentials = readStatusCredentials(AUTH_PROFILE_CODEX, home);
424
+ const claudeCredentials = readStatusCredentials(AUTH_PROFILE_CLAUDE, home);
425
+ const [codexOnline, claudeOnline] = await Promise.all([
426
+ inspectProfileOnline(codexCredentials, { fetchImpl, timeoutMs }),
427
+ inspectProfileOnline(claudeCredentials, {
428
+ fetchImpl,
429
+ timeoutMs,
430
+ inherited: !claudeCredentials.configured,
431
+ }),
432
+ ]);
433
+ return {
434
+ ...local,
435
+ profiles: {
436
+ codex: { ...local.profiles.codex, online: codexOnline },
437
+ claude: { ...local.profiles.claude, online: claudeOnline },
438
+ },
439
+ };
440
+ }
441
+
442
+ function accountLabel(profile) {
443
+ if (!profile.configured) return "not configured";
444
+ if (!profile.valid) return `invalid (${profile.reason})`;
445
+ return profile.login || profile.id || "configured";
446
+ }
447
+
448
+ function onlineStatusLine(name, profile) {
449
+ const online = profile.online;
450
+ if (!online) return null;
451
+ if (online.inherited) return status("info", `${name} online: inherits the Codex profile`);
452
+ if (!online.ok) {
453
+ const httpStatus = online.httpStatus ? ` (HTTP ${online.httpStatus})` : "";
454
+ return status("warn", `${name} online: ${online.reason || "unavailable"}${httpStatus}`);
455
+ }
456
+ const account = online.login ? `${online.login}; ` : "";
457
+ return status("ok", `${name} online: ${account}${online.models} models, ${online.claudeModels} Claude`);
458
+ }
459
+
460
+ export function formatAuthStatus(snapshot = authStatus(), { commandName = "ccdx" } = {}) {
461
+ const claudeLabel = snapshot.profiles.claude.configured
462
+ ? `${accountLabel(snapshot.profiles.claude)} [isolated profile]`
463
+ : "inherits Codex [no isolated profile]";
464
+ const claudeKind = snapshot.profiles.claude.valid
465
+ ? "ok"
466
+ : snapshot.profiles.claude.configured ? "warn" : "info";
467
+ return [
468
+ `${commandName} auth status`,
469
+ status(snapshot.profiles.codex.valid ? "ok" : "warn", `Codex: ${accountLabel(snapshot.profiles.codex)} [legacy path]`),
470
+ onlineStatusLine("Codex", snapshot.profiles.codex),
471
+ status(claudeKind, `Claude: ${claudeLabel}`),
472
+ onlineStatusLine("Claude", snapshot.profiles.claude),
473
+ status("info", `Routing: responses -> ${snapshot.routing.responses}; messages -> ${snapshot.routing.messages}`),
474
+ ].filter(Boolean).join("\n");
475
+ }
476
+
477
+ export async function runAuthCommand({
478
+ action = "status",
479
+ profile = "",
480
+ online = false,
481
+ reauth = false,
482
+ expectedLogin = "",
483
+ commandName = "ccdx",
484
+ ...options
485
+ } = {}) {
486
+ if (action === "status") {
487
+ const snapshot = online ? await authStatusOnline(options) : authStatus(options);
488
+ return { action, output: formatAuthStatus(snapshot, { commandName }), snapshot };
489
+ }
490
+ if (profile !== AUTH_PROFILE_CLAUDE) throw new Error("Only the isolated Claude profile can be changed by this command");
491
+ if (action === "login") return { action, ...(await authorizeClaudeProfile({ ...options, reauth, expectedLogin })) };
492
+ throw new Error(`Unsupported auth action: ${action}`);
493
+ }