codex-copilot-dx 0.6.1 → 0.6.2

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/README.md CHANGED
@@ -34,6 +34,8 @@ command remains an equivalent compatibility alias. For a one-off run without a
34
34
  global install, use `npx codex-copilot-dx@latest`.
35
35
  Both installed commands expose the same options and subcommands; help, version,
36
36
  doctor, status, and argument-error output use the command name that was invoked.
37
+ Run `ccdx <command> --help` (including `auth login claude --help` and
38
+ `pms status --help`) for command-specific behavior and side-effect details.
37
39
 
38
40
  To query the models currently advertised as selectable for the saved Codex
39
41
  Copilot account, run:
@@ -60,11 +62,14 @@ while using a different personal account that has Claude models enabled, run:
60
62
  ccdx auth login claude --github-login <personal-github-login>
61
63
  ```
62
64
 
63
- This is the only command that starts the additional Device Flow. It verifies
65
+ This is the only command that may start the additional Device Flow. Before
66
+ opening a browser, it checks compatible local Copilot credentials, excludes the
67
+ current Codex identity, and reuses an unambiguous valid account. It then verifies
64
68
  the selected GitHub identity, Copilot entitlement, and an enabled Claude model
65
69
  before saving anything. It refuses the current Codex account and never changes,
66
70
  migrates, or deletes the existing Codex credential. The optional login pin is
67
- recommended so authorizing the wrong browser account fails before activation.
71
+ recommended so a machine with multiple accounts selects only the requested
72
+ identity. Use `--reauth` to bypass local discovery and force Device Flow.
68
73
 
69
74
  The existing Codex token remains at
70
75
  `~/.local/share/copilot-api/github_token`. The isolated Claude credential is
@@ -80,7 +85,8 @@ Once enabled, routing is fixed by API surface rather than by model name:
80
85
  - `/v1/messages/count_tokens` remains local and uses neither account.
81
86
 
82
87
  The two profiles keep separate service tokens, API bases, refresh/backoff state,
83
- model endpoint metadata, and model caches. If the isolated Claude profile later
88
+ model endpoint metadata, and credential-bound model caches. Switching either
89
+ GitHub account invalidates that profile's old cache instead of borrowing it. If the isolated Claude profile later
84
90
  becomes invalid, Claude requests fail with a reauthentication instruction while
85
91
  Codex remains available; CCDX never silently falls back to the enterprise
86
92
  account. Reauthorize it explicitly with:
@@ -114,6 +120,15 @@ ccdx auth login claude --reauth --github-login <personal-github-login>
114
120
  ccdx pms setup
115
121
  ```
116
122
 
123
+ Inspect the installed version, exact patch integrity, isolated Claude profile,
124
+ and relay without changing files:
125
+
126
+ ```bash
127
+ ccdx pms status
128
+ ```
129
+
130
+ `pm-studio status` and `pm-studio setup` are equivalent long-form aliases.
131
+
117
132
  `ccdx pms setup` performs only local preflight, backup, staging, integrity,
118
133
  signing, and replacement work. It never starts Device Flow, the adapter,
119
134
  Codex, or PM Studio, and it does not modify PM profiles, cached model lists, or
@@ -232,7 +247,11 @@ ccdx doctor
232
247
 
233
248
  The command exits with status `1` when it finds an invalid configuration and `0` when checks contain only OK or warning results.
234
249
 
235
- The doctor checks the GitHub token, Codex config, Claude Code settings, Claude App gateway profile, and whether the local adapter port is listening.
250
+ The doctor checks the GitHub token, Codex config, Claude Code settings, Claude
251
+ App gateway profile, adapter availability, and an installed PM Studio bundle.
252
+ It ends with passed/warning/error totals and deduplicated next-step commands.
253
+ An absent PM Studio installation is ignored; unsupported versions are reported
254
+ without modification, while integrity drift remains an error.
236
255
 
237
256
  For a read-only live check of the saved GitHub token, Copilot entitlement, and models endpoint, run:
238
257
 
@@ -267,7 +286,7 @@ loopback endpoint directly:
267
286
  curl -s http://127.0.0.1:2026/_ccdx/status | jq
268
287
  ```
269
288
 
270
- The status endpoint is restricted to the socket's real loopback address, even when LAN binding is explicitly enabled. It reports fixed-size request counters, bounded TTFT/TPOT histograms for streaming routes, admission pressure, memory use, response-history size, image queue/cache state, model cache counts, and token expiry state. It never retains metric samples or includes prompts, completions, tool arguments, image content, account names, or token values. API responses always include a safe `X-Request-Id` for correlation. To include the same ID as `request_id` in related terminal and file log lines, start the adapter with:
289
+ The status endpoint is restricted to the socket's real loopback address, even when LAN binding is explicitly enabled. It reports fixed-size request counters, bounded TTFT/TPOT histograms for streaming routes, admission pressure, memory use, response-history size, image queue/cache state, profile model cache counts, PM relay route totals, and token expiry state. It never retains metric samples or includes prompts, completions, tool arguments, image content, account names, or token values. API responses always include a safe `X-Request-Id` for correlation. If the adapter is not running, the CLI prints the exact start-and-retry command. To include the same ID as `request_id` in related terminal and file log lines, start the adapter with:
271
290
 
272
291
  ```bash
273
292
  ccdx --show-request-id
@@ -287,9 +306,13 @@ ccdx
287
306
  Claude App support is opt-in so the default Codex Desktop and Claude Code setup stays unchanged:
288
307
 
289
308
  ```bash
290
- npx codex-copilot-dx@latest --configure-claude-desktop
309
+ npx codex-copilot-dx@latest start --configure-claude-app
291
310
  ```
292
311
 
312
+ The previous `--configure-claude-desktop` spelling remains a compatibility
313
+ alias. Startup reports Claude App as ready only when a matching managed gateway
314
+ key is restored or the profile is configured in that run.
315
+
293
316
  Or set:
294
317
 
295
318
  ```bash
package/bin/cli.mjs CHANGED
@@ -39,7 +39,7 @@ try {
39
39
  }
40
40
 
41
41
  if (CLI.command === "help") {
42
- console.log(cliHelp(CLI_NAME));
42
+ console.log(cliHelp(CLI_NAME, CLI.helpTopic));
43
43
  process.exit(0);
44
44
  }
45
45
  if (CLI.command === "version") {
@@ -125,6 +125,11 @@ if (CLI.command === "update") {
125
125
  }
126
126
  if (CLI.command === "pms") {
127
127
  try {
128
+ if (CLI.action === "status") {
129
+ const { runPmStudioStatus } = await import("../src/pm-studio-status.mjs");
130
+ const result = await runPmStudioStatus({ commandName: CLI_NAME });
131
+ process.exit(result.ok ? 0 : 1);
132
+ }
128
133
  const { runPmStudioSetup } = await import("../src/pm-studio-setup.mjs");
129
134
  await runPmStudioSetup({ commandName: CLI_NAME });
130
135
  process.exit(0);
@@ -206,7 +211,7 @@ async function reuseRunningAdapterIfAvailable() {
206
211
  console.log(status("warn", "Existing adapter is running; skip Claude App profile update unless CCDX_CLAUDE_DESKTOP_API_KEY or CCDX_PROXY_API_KEY is set"));
207
212
  }
208
213
  } else {
209
- console.log(status("ok", "Claude App support available with --configure-claude-desktop"));
214
+ console.log(status("ok", "Claude App support available with --configure-claude-app"));
210
215
  }
211
216
 
212
217
  await openCodex();
@@ -270,6 +275,7 @@ try {
270
275
  codexClient: profileRuntime.codexClient,
271
276
  claudeClient: profileRuntime.claudeClient,
272
277
  claudeMode: profileRuntime.claudeMode,
278
+ codexCredentialFingerprint: profileRuntime.codexCredentialFingerprint,
273
279
  claudeCredentialFingerprint: profileRuntime.claudeCredentialFingerprint,
274
280
  timeoutMs: MODEL_REFRESH_TIMEOUT_MS,
275
281
  commandName: CLI_NAME,
@@ -329,7 +335,7 @@ try {
329
335
  console.log(status("ok", `Configured Claude App gateway profile at ${result.baseUrl}`));
330
336
  console.log(formatClaudeDesktopApplyResult(result));
331
337
  } else {
332
- console.log(status("ok", "Claude App support available with --configure-claude-desktop"));
338
+ console.log(status("ok", "Claude App support available with --configure-claude-app"));
333
339
  }
334
340
 
335
341
  // Launch Codex when available.
@@ -345,8 +351,10 @@ try {
345
351
  modelRefreshTimer.unref?.();
346
352
  }
347
353
 
354
+ const readyClients = ["Codex App", "Claude Code"];
355
+ if (claudeDesktopApiKey) readyClients.splice(1, 0, "Claude App");
348
356
  console.log(`
349
- ${status("ok", "Ready, Claude Code, Claude App and Codex App are now ready to use")}
357
+ ${status("ok", `Ready, ${readyClients.join(", ")} ${readyClients.length === 1 ? "is" : "are"} ready to use`)}
350
358
 
351
359
  Adapter: ${adapterBaseUrl(ADAPTER_HOST, ADAPTER_PORT)}
352
360
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "description": "Use Codex Desktop, Claude Code, and Claude App with GitHub Copilot",
5
5
  "author": "Dale Xiao",
6
6
  "type": "module",
@@ -8,6 +8,7 @@ import { setTimeout as delay } from "node:timers/promises";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import sharp from "sharp";
10
10
  import { optimizeImagesInBody, prepareResponsesPayload } from "../src/image-optimization.mjs";
11
+ import { createPmStudioModelRouter } from "../src/profile-routing.mjs";
11
12
 
12
13
  const packageRoot = fileURLToPath(new URL("../", import.meta.url));
13
14
 
@@ -235,6 +236,34 @@ const duplicateImages = {
235
236
  optimizer_calls: duplicateCalls,
236
237
  };
237
238
 
239
+ const routingCatalog = {
240
+ data: Array.from({ length: 1000 }, (_, index) => index % 5 === 0
241
+ ? {
242
+ id: `claude-benchmark-${index}`,
243
+ vendor: "Anthropic",
244
+ model_picker_enabled: true,
245
+ supported_endpoints: ["/chat/completions"],
246
+ }
247
+ : { id: `gpt-benchmark-${index}`, vendor: "OpenAI", supported_endpoints: ["/responses"] }),
248
+ };
249
+ const modelRouter = createPmStudioModelRouter({
250
+ getCatalog: () => routingCatalog,
251
+ isClaudeEnabled: () => true,
252
+ });
253
+ const routingIterations = 100000;
254
+ const routingStarted = performance.now();
255
+ let claudeClassifications = 0;
256
+ for (let index = 0; index < routingIterations; index += 1) {
257
+ if (modelRouter.classify("claude-benchmark-0") === "claude") claudeClassifications += 1;
258
+ }
259
+ const pmModelRouting = {
260
+ catalog_models: routingCatalog.data.length,
261
+ iterations: routingIterations,
262
+ elapsed_ms: +(performance.now() - routingStarted).toFixed(1),
263
+ claude_classifications: claudeClassifications,
264
+ availability_rebuilds: modelRouter.diagnostics().rebuilds,
265
+ };
266
+
238
267
  const pixels = deterministicPixels(1024 * 1024 * 3, 0x87654321);
239
268
  const webp = await sharp(pixels, { raw: { width: 1024, height: 1024, channels: 3 } })
240
269
  .webp({ quality: 100 })
@@ -281,6 +310,7 @@ const report = {
281
310
  adapter_import: adapterImport,
282
311
  token_count_100kb: tokenCount,
283
312
  duplicate_images: duplicateImages,
313
+ pm_model_routing: pmModelRouting,
284
314
  oversized_payload: oversizedPayload,
285
315
  };
286
316
 
@@ -308,6 +338,10 @@ console.log(JSON.stringify(report, null, 2));
308
338
 
309
339
  if (checkMode) {
310
340
  const failures = [];
341
+ if (report.pm_model_routing.availability_rebuilds !== 1
342
+ || report.pm_model_routing.claude_classifications !== report.pm_model_routing.iterations) {
343
+ failures.push("PM Studio model routing did not reuse the memoized catalog classification");
344
+ }
311
345
  const proxy = report.large_token_count.proxy;
312
346
  const array = report.large_token_count.materialized_array;
313
347
  if (proxy.tokens !== array.tokens) failures.push(`token counts differ: proxy=${proxy.tokens} array=${array.tokens}`);
package/src/auth.mjs CHANGED
@@ -326,6 +326,7 @@ export async function discoverGithubToken({
326
326
  fetchImpl = fetch,
327
327
  signal,
328
328
  excludeTokens = [],
329
+ excludeIdentities = [],
329
330
  expectedIdentity,
330
331
  strictExpectedIdentity = false,
331
332
  } = {}) {
@@ -348,6 +349,10 @@ export async function discoverGithubToken({
348
349
 
349
350
  const validation = await validateGithubToken(token, { fetchImpl, signal });
350
351
  if (validation.ok) {
352
+ if (excludeIdentities.some((identity) => githubIdentityMatchesExpected(validation, identity))) {
353
+ failures.push({ source, validation, reason: "github_account_excluded" });
354
+ continue;
355
+ }
351
356
  const candidate = { ok: true, token, source, validation };
352
357
  const explicitSource = source.type === "env" || source.type === "token-file";
353
358
  if (expectedIdentity && (strictExpectedIdentity || !explicitSource)) {
package/src/cli-auth.mjs CHANGED
@@ -1,8 +1,10 @@
1
1
  import os from "node:os";
2
2
  import { spawn } from "node:child_process";
3
3
  import {
4
+ discoverGithubToken,
4
5
  fetchGithubIdentity,
5
6
  interpretPoll,
7
+ sourceDescription,
6
8
  validateGithubToken,
7
9
  } from "./auth.mjs";
8
10
  import {
@@ -19,6 +21,7 @@ import {
19
21
  normalizeGithubIdentity,
20
22
  } from "./github-identity.mjs";
21
23
  import { saveModelCache } from "./model-cache.mjs";
24
+ import { profileRouting } from "./profile-routing.mjs";
22
25
  import { status } from "./status.mjs";
23
26
 
24
27
  const CLIENT_ID = "Iv1.b507a08c87ecfe98";
@@ -267,14 +270,45 @@ export async function authorizeClaudeProfile({
267
270
  throw new Error(`Claude profile is configured but invalid (${current.reason}); run ccdx auth login claude --reauth`);
268
271
  }
269
272
 
270
- const githubToken = await requestDeviceFlowToken({
271
- fetchImpl,
272
- signal,
273
- log,
274
- openAndCopyFn,
275
- sleepImpl,
276
- now,
277
- });
273
+ let githubToken = "";
274
+ if (!reauth) {
275
+ const codex = readAuthProfileCredentials(AUTH_PROFILE_CODEX, { home });
276
+ if (!codex.valid) {
277
+ throw new Error("The existing Codex GitHub account is not configured; refusing to create an unbound Claude profile");
278
+ }
279
+ const existingCodex = await codexIdentity({ home, fetchImpl, signal });
280
+ const pinnedLogin = String(expectedLogin || "").trim();
281
+ const discovered = await discoverGithubToken({
282
+ home,
283
+ env,
284
+ fetchImpl,
285
+ signal,
286
+ excludeTokens: [codex.token],
287
+ excludeIdentities: [existingCodex.identity],
288
+ expectedIdentity: pinnedLogin ? { login: pinnedLogin } : undefined,
289
+ strictExpectedIdentity: Boolean(pinnedLogin),
290
+ });
291
+ if (discovered.ambiguous) {
292
+ const accounts = [...new Set(discovered.candidates
293
+ .map((candidate) => candidate.login || candidate.id || "unknown"))];
294
+ throw new Error(`Multiple reusable GitHub Copilot accounts were found (${accounts.join(", ")}). Use --github-login or CCDX_GITHUB_TOKEN_PATH to select the Claude account.`);
295
+ }
296
+ if (discovered.ok) {
297
+ githubToken = discovered.token;
298
+ const login = discovered.validation?.login ? ` for ${discovered.validation.login}` : "";
299
+ log(status("info", `Reusing a local Copilot credential from ${sourceDescription(discovered.source)}${login}`));
300
+ }
301
+ }
302
+ if (!githubToken) {
303
+ githubToken = await requestDeviceFlowToken({
304
+ fetchImpl,
305
+ signal,
306
+ log,
307
+ openAndCopyFn,
308
+ sleepImpl,
309
+ now,
310
+ });
311
+ }
278
312
  const candidate = await validateClaudeCandidate(githubToken, {
279
313
  home,
280
314
  expectedLogin,
@@ -350,10 +384,7 @@ export function authStatus({ home = os.homedir() } = {}) {
350
384
  codex: publicProfile(codex, "legacy"),
351
385
  claude: publicProfile(claude, "isolated"),
352
386
  },
353
- routing: {
354
- responses: AUTH_PROFILE_CODEX,
355
- messages: claude.configured ? AUTH_PROFILE_CLAUDE : AUTH_PROFILE_CODEX,
356
- },
387
+ routing: profileRouting({ claudeConfigured: claude.configured }),
357
388
  };
358
389
  }
359
390
 
@@ -1,7 +1,29 @@
1
1
  const HELP_COMMANDS = new Set(["help", "--help", "-h"]);
2
2
  const VERSION_COMMANDS = new Set(["version", "--version", "-v"]);
3
- const START_OPTIONS = new Set(["--configure-claude-desktop", "--show-request-id"]);
3
+ const START_OPTIONS = new Map([
4
+ ["--configure-claude-desktop", "configure-claude-app"],
5
+ ["--configure-claude-app", "configure-claude-app"],
6
+ ["--show-request-id", "show-request-id"],
7
+ ]);
4
8
  const CLI_COMMANDS = new Set(["ccdx", "codex-copilot-dx"]);
9
+ const PM_COMMANDS = new Set(["pms", "pm-studio"]);
10
+ const HELP_TOPICS = new Set([
11
+ "",
12
+ "start",
13
+ "auth",
14
+ "auth status",
15
+ "auth login",
16
+ "doctor",
17
+ "status",
18
+ "models",
19
+ "pms",
20
+ "pms setup",
21
+ "pms status",
22
+ "usage",
23
+ "auto-review-model",
24
+ "update",
25
+ "version",
26
+ ]);
5
27
 
6
28
  function unexpectedArgs(args) {
7
29
  throw new Error(`Unexpected argument${args.length === 1 ? "" : "s"}: ${args.join(" ")}`);
@@ -39,40 +61,97 @@ function checkedProfile(value, { allowAll = false } = {}) {
39
61
  return profile;
40
62
  }
41
63
 
64
+ function baseCommand(command, extra = {}) {
65
+ return {
66
+ command,
67
+ configureClaudeDesktop: false,
68
+ showRequestId: false,
69
+ online: false,
70
+ compat: false,
71
+ ...extra,
72
+ };
73
+ }
74
+
75
+ function checkedHelpTopic(parts = []) {
76
+ const normalized = parts.map((part, index) => {
77
+ if (index === 0 && part === "pm-studio") return "pms";
78
+ return part;
79
+ }).join(" ");
80
+ if (!HELP_TOPICS.has(normalized)) throw new Error(`Unknown help topic: ${parts.join(" ")}`);
81
+ return normalized;
82
+ }
83
+
84
+ function helpCommand(parts = []) {
85
+ return baseCommand("help", { helpTopic: checkedHelpTopic(parts) });
86
+ }
87
+
88
+ function nestedHelp(command, rest) {
89
+ if (!rest.length || !HELP_COMMANDS.has(rest.at(-1))) return null;
90
+ const args = rest.slice(0, -1);
91
+ if (PM_COMMANDS.has(command)) {
92
+ if (args.length > 1) unexpectedArgs(args.slice(1));
93
+ return helpCommand(["pms", ...args]);
94
+ }
95
+ if (command === "auth") {
96
+ if (args[0] === "login" && args[1] === "claude" && args.length === 2) {
97
+ return helpCommand(["auth", "login"]);
98
+ }
99
+ if (args.length > 1) unexpectedArgs(args.slice(1));
100
+ return helpCommand(["auth", ...args]);
101
+ }
102
+ if (args.length) unexpectedArgs(args);
103
+ return helpCommand([command]);
104
+ }
105
+
106
+ function parseStartOptions(args) {
107
+ const seen = new Set();
108
+ for (const option of args) {
109
+ const kind = START_OPTIONS.get(option);
110
+ if (!kind || seen.has(kind)) unexpectedArgs([option]);
111
+ seen.add(kind);
112
+ }
113
+ return baseCommand("start", {
114
+ configureClaudeDesktop: seen.has("configure-claude-app"),
115
+ showRequestId: seen.has("show-request-id"),
116
+ });
117
+ }
118
+
42
119
  export function parseCliArgs(args = []) {
43
120
  const [command, ...rest] = args;
44
- if (!command) return { command: "start", configureClaudeDesktop: false, showRequestId: false, online: false, compat: false };
121
+ if (!command) return baseCommand("start");
45
122
  if (HELP_COMMANDS.has(command)) {
46
- if (rest.length) unexpectedArgs(rest);
47
- return { command: "help", configureClaudeDesktop: false, showRequestId: false, online: false, compat: false };
123
+ const topic = rest[0] === "auth" && rest[1] === "login" && rest[2] === "claude"
124
+ ? ["auth", "login"]
125
+ : rest;
126
+ if (topic.length > 2) unexpectedArgs(topic.slice(2));
127
+ return helpCommand(topic);
48
128
  }
129
+ const requestedHelp = nestedHelp(command, rest);
130
+ if (requestedHelp) return requestedHelp;
49
131
  if (VERSION_COMMANDS.has(command)) {
50
132
  if (rest.length) unexpectedArgs(rest);
51
- return { command: "version", configureClaudeDesktop: false, showRequestId: false, online: false, compat: false };
133
+ return baseCommand("version");
52
134
  }
135
+ if (command === "start") return parseStartOptions(rest);
53
136
  if (command === "status" || command === "--status") {
54
137
  if (rest.length) unexpectedArgs(rest);
55
- return { command: "status", configureClaudeDesktop: false, showRequestId: false, online: false, compat: false };
138
+ return baseCommand("status");
56
139
  }
57
140
  if (command === "usage") {
58
141
  if (rest.length) unexpectedArgs(rest);
59
- return { command: "usage", configureClaudeDesktop: false, showRequestId: false, online: false, compat: false };
142
+ return baseCommand("usage");
60
143
  }
61
- if (command === "pms") {
144
+ if (PM_COMMANDS.has(command)) {
62
145
  const [action, ...pmsArgs] = rest;
63
- if (!action) throw new Error("Missing pms action: expected setup");
64
- if (action !== "setup") throw new Error(`Unknown pms action: ${action}`);
146
+ if (!action) throw new Error("Missing pms action: expected setup or status");
147
+ if (!new Set(["setup", "status"]).has(action)) throw new Error(`Unknown pms action: ${action}`);
65
148
  if (pmsArgs.length) unexpectedArgs(pmsArgs);
66
- return { command: "pms", action: "setup", configureClaudeDesktop: false, showRequestId: false, online: false, compat: false };
149
+ return baseCommand("pms", { action });
67
150
  }
68
151
  if (command === "models") {
69
152
  const options = parseOptions(rest, new Map([["--profile", "value"]]));
70
153
  return {
71
- command: "models",
72
- configureClaudeDesktop: false,
73
- showRequestId: false,
74
- online: false,
75
- compat: false,
154
+ ...baseCommand("models"),
76
155
  profile: checkedProfile(options["--profile"]),
77
156
  };
78
157
  }
@@ -82,13 +161,10 @@ export function parseCliArgs(args = []) {
82
161
  if (action === "status") {
83
162
  const options = parseOptions(authArgs, new Map([["--online", "flag"]]));
84
163
  return {
85
- command: "auth",
164
+ ...baseCommand("auth"),
86
165
  action: "status",
87
166
  profile: "",
88
- configureClaudeDesktop: false,
89
- showRequestId: false,
90
167
  online: Boolean(options["--online"]),
91
- compat: false,
92
168
  expectedLogin: "",
93
169
  reauth: false,
94
170
  };
@@ -102,13 +178,9 @@ export function parseCliArgs(args = []) {
102
178
  ["--reauth", "flag"],
103
179
  ]));
104
180
  return {
105
- command: "auth",
181
+ ...baseCommand("auth"),
106
182
  action: "login",
107
183
  profile: "claude",
108
- configureClaudeDesktop: false,
109
- showRequestId: false,
110
- online: false,
111
- compat: false,
112
184
  expectedLogin: options["--github-login"] || "",
113
185
  reauth: Boolean(options["--reauth"]),
114
186
  };
@@ -117,7 +189,7 @@ export function parseCliArgs(args = []) {
117
189
  }
118
190
  if (command === "auto-review-model") {
119
191
  if (rest.length) unexpectedArgs(rest);
120
- return { command: "auto-review-model", configureClaudeDesktop: false, showRequestId: false, online: false, compat: false };
192
+ return baseCommand("auto-review-model");
121
193
  }
122
194
  if (command === "update") {
123
195
  if (rest.length > 1) unexpectedArgs(rest.slice(1));
@@ -126,11 +198,7 @@ export function parseCliArgs(args = []) {
126
198
  throw new Error(`Update source must be npm or github: ${source}`);
127
199
  }
128
200
  return {
129
- command: "update",
130
- configureClaudeDesktop: false,
131
- showRequestId: false,
132
- online: false,
133
- compat: false,
201
+ ...baseCommand("update"),
134
202
  updateSource: source === "gh" ? "github" : source,
135
203
  };
136
204
  }
@@ -141,29 +209,13 @@ export function parseCliArgs(args = []) {
141
209
  ["--profile", "value"],
142
210
  ]));
143
211
  return {
144
- command: "doctor",
145
- configureClaudeDesktop: false,
146
- showRequestId: false,
212
+ ...baseCommand("doctor"),
147
213
  online: Boolean(options["--online"]),
148
214
  compat: Boolean(options["--compat"]),
149
215
  profile: checkedProfile(options["--profile"], { allowAll: true }),
150
216
  };
151
217
  }
152
- if (START_OPTIONS.has(command)) {
153
- const options = [command, ...rest];
154
- const invalid = options.filter((arg) => !START_OPTIONS.has(arg));
155
- if (invalid.length) unexpectedArgs(invalid);
156
- for (const option of START_OPTIONS) {
157
- if (options.filter((arg) => arg === option).length > 1) unexpectedArgs([option]);
158
- }
159
- return {
160
- command: "start",
161
- configureClaudeDesktop: options.includes("--configure-claude-desktop"),
162
- showRequestId: options.includes("--show-request-id"),
163
- online: false,
164
- compat: false,
165
- };
166
- }
218
+ if (START_OPTIONS.has(command)) return parseStartOptions([command, ...rest]);
167
219
  throw new Error(`Unknown command or option: ${command}`);
168
220
  }
169
221
 
@@ -207,16 +259,39 @@ export function cliCommandName(executable = process.argv[1]) {
207
259
  return CLI_COMMANDS.has(name) ? name : "ccdx";
208
260
  }
209
261
 
210
- export function cliHelp(commandName = "ccdx") {
262
+ function topicHelp(name, topic) {
263
+ const sections = {
264
+ start: `Usage:\n ${name} [start] [--configure-claude-app] [--show-request-id]\n\nStarts or reuses the local adapter, updates Codex and Claude Code configuration, and opens Codex.\n--configure-claude-app also creates or updates the managed Claude App gateway profile.`,
265
+ auth: `Usage:\n ${name} auth status [--online]\n ${name} auth login claude [--github-login <login>] [--reauth]\n\nShows the two account profiles or configures the isolated Claude account. Device login starts only when no reusable local Copilot credential is available or --reauth is used.`,
266
+ "auth status": `Usage:\n ${name} auth status [--online]\n\nShows account routing without exposing credentials. --online also verifies both configured Copilot entitlements and model catalogs.`,
267
+ "auth login": `Usage:\n ${name} auth login claude [--github-login <login>] [--reauth]\n\nReuses a compatible local Copilot credential when possible. --reauth forces GitHub Device Flow.`,
268
+ doctor: `Usage:\n ${name} doctor [--online] [--compat] [--profile codex|claude|all]\n\nChecks local credentials, client configuration, PM Studio state, and the adapter. --online validates account entitlement; --compat sends minimal inference requests and consumes a small amount of Copilot usage.`,
269
+ status: `Usage:\n ${name} status\n\nShows bounded runtime, routing, performance, cache, queue, and PM relay metrics from a running adapter.`,
270
+ models: `Usage:\n ${name} models [--profile codex|claude]\n\nPerforms a fresh, read-only Copilot model-directory lookup for one saved profile.`,
271
+ pms: `Usage:\n ${name} pms status\n ${name} pms setup\n ${name} pm-studio status\n ${name} pm-studio setup\n\nInspects or installs the version-locked PM Studio loopback patch. setup modifies the installed app only after all preflight checks pass.`,
272
+ "pms setup": `Usage:\n ${name} pms setup\n\nBacks up, patches, verifies, and ad-hoc signs an exactly supported PM Studio bundle. Unknown versions and drift are never modified.`,
273
+ "pms status": `Usage:\n ${name} pms status\n\nRead-only inspection of the installed PM Studio version, patch integrity, Claude profile, and relay availability.`,
274
+ usage: `Usage:\n ${name} usage\n\nSummarizes local token usage metadata without reading prompt or completion content.`,
275
+ "auto-review-model": `Usage:\n ${name} auto-review-model\n\nInteractively selects an enabled Responses model for Codex Auto-review.`,
276
+ update: `Usage:\n ${name} update [npm|github]\n\nUpdates the global package from the configured npm registry or GitHub main. With no source, an interactive terminal prompts for one.`,
277
+ version: `Usage:\n ${name} --version`,
278
+ };
279
+ return sections[topic];
280
+ }
281
+
282
+ export function cliHelp(commandName = "ccdx", topic = "") {
211
283
  const name = CLI_COMMANDS.has(commandName) ? commandName : "ccdx";
212
284
  const alias = name === "ccdx" ? "codex-copilot-dx" : "ccdx";
285
+ const normalizedTopic = checkedHelpTopic(topic ? topic.split(" ") : []);
286
+ if (normalizedTopic) return `${topicHelp(name, normalizedTopic)}\n\nEquivalent command: ${alias}`;
213
287
  return `Usage:
214
- ${name} [--configure-claude-desktop] [--show-request-id]
288
+ ${name} [start] [--configure-claude-app] [--show-request-id]
215
289
  ${name} auth status [--online]
216
290
  ${name} auth login claude [--github-login <login>] [--reauth]
217
291
  ${name} doctor [--online] [--compat] [--profile codex|claude|all]
218
292
  ${name} status
219
293
  ${name} models [--profile codex|claude]
294
+ ${name} pms status
220
295
  ${name} pms setup
221
296
  ${name} usage
222
297
  ${name} auto-review-model
@@ -224,5 +299,18 @@ export function cliHelp(commandName = "ccdx") {
224
299
  ${name} --version
225
300
  ${name} --help
226
301
 
302
+ Commands:
303
+ start Start or reuse the adapter and configure local clients
304
+ auth Inspect or configure the Codex and Claude accounts
305
+ doctor Diagnose local config; optional live and inference checks
306
+ status Show runtime routing, performance, queue, and cache health
307
+ models Query a saved account's live Copilot model catalog
308
+ pms, pm-studio Inspect or install the PM Studio patch
309
+ usage Summarize locally recorded token usage
310
+ auto-review-model Select the Codex Auto-review Responses model
311
+ update Update the global package from npm or GitHub
312
+
313
+ Run ${name} <command> --help for command details.
314
+
227
315
  Equivalent command: ${alias}`;
228
316
  }
@@ -70,6 +70,33 @@ function routingTarget(value) {
70
70
  return "unknown";
71
71
  }
72
72
 
73
+ function pmRouteSummary(requests = {}) {
74
+ const routes = requests.by_route;
75
+ if (!routes || typeof routes !== "object") return null;
76
+ const names = ["pm_models", "pm_chat_completions", "pm_responses", "pm_embeddings"];
77
+ const totals = names.reduce((summary, name) => {
78
+ const route = routes[name] || {};
79
+ summary.total += finiteNumber(route.total) || 0;
80
+ summary.active += finiteNumber(route.active) || 0;
81
+ summary.errors += finiteNumber(route.errors) || 0;
82
+ return summary;
83
+ }, { total: 0, active: 0, errors: 0 });
84
+ return `PM relay: ${count(totals.total)} requests, ${count(totals.active)} active, ${count(totals.errors)} errors; models ${count(routes.pm_models?.total)}, chat ${count(routes.pm_chat_completions?.total)}, responses ${count(routes.pm_responses?.total)}, embeddings ${count(routes.pm_embeddings?.total)}`;
85
+ }
86
+
87
+ function errorCode(error) {
88
+ const seen = new Set();
89
+ const pending = [error];
90
+ while (pending.length) {
91
+ const current = pending.shift();
92
+ if (!current || typeof current !== "object" || seen.has(current)) continue;
93
+ seen.add(current);
94
+ if (typeof current.code === "string") return current.code;
95
+ pending.push(current.cause, ...(Array.isArray(current.errors) ? current.errors : []));
96
+ }
97
+ return "";
98
+ }
99
+
73
100
  export async function readAdapterStatus({
74
101
  host = "127.0.0.1",
75
102
  port = 2026,
@@ -101,12 +128,16 @@ export async function readAdapterStatus({
101
128
  }
102
129
  return { baseUrl, data };
103
130
  } catch (error) {
131
+ const code = errorCode(error);
104
132
  const reason = ctrl.signal.aborted
105
133
  ? `timed out after ${timeoutMs}ms`
106
- : error?.cause?.code === "ECONNREFUSED"
134
+ : code === "ECONNREFUSED" || code === "ENETUNREACH"
107
135
  ? "adapter is not running"
108
136
  : error?.message || String(error);
109
- throw new Error(`Could not read adapter status at ${baseUrl}: ${reason}`);
137
+ const nextStep = reason === "adapter is not running"
138
+ ? `. Run ccdx start, then retry ccdx status`
139
+ : "";
140
+ throw new Error(`Could not read adapter status at ${baseUrl}: ${reason}${nextStep}`);
110
141
  } finally {
111
142
  clearTimeout(timer);
112
143
  }
@@ -143,6 +174,8 @@ export function formatAdapterStatus({ baseUrl, data }, { commandName = "ccdx", c
143
174
  if (data.routing) {
144
175
  lines.push(status("info", `Routing: /v1/responses -> ${routingTarget(data.routing.responses)}; /v1/messages -> ${routingTarget(data.routing.messages)}`));
145
176
  }
177
+ const pmSummary = pmRouteSummary(requests);
178
+ if (pmSummary) lines.push(status("info", pmSummary));
146
179
  lines.push(status("info", `Limits: request body ${mebibytes(limits.max_body_bytes)}, decoded body ${mebibytes(limits.max_decoded_body_bytes)}`));
147
180
  return lines.join("\n");
148
181
  }
package/src/doctor.mjs CHANGED
@@ -251,7 +251,11 @@ export function inspectAuthProfiles({ home = os.homedir() } = {}) {
251
251
  if (!claude.configured) {
252
252
  checks.push({ kind: "ok", message: "Claude authentication profile is not isolated and inherits Codex" });
253
253
  } else if (!claude.valid) {
254
- checks.push({ kind: "err", message: `Claude isolated authentication profile is invalid: ${claude.reason}` });
254
+ checks.push({
255
+ kind: "err",
256
+ message: `Claude isolated authentication profile is invalid: ${claude.reason}`,
257
+ fix: "ccdx auth login claude --reauth",
258
+ });
255
259
  } else {
256
260
  const account = claude.identity?.login ? ` for ${claude.identity.login}` : "";
257
261
  checks.push({ kind: "ok", message: `Claude isolated authentication profile is configured${account}` });
@@ -455,7 +459,11 @@ export function inspectClaudeAppConfig({
455
459
  const meta = readJson(paths.metaPath);
456
460
  const appliedId = meta.ok ? String(meta.json?.appliedId || "").trim() : "";
457
461
  if (!appliedId) {
458
- checks.push({ kind: "warn", message: "Claude App gateway profile is not configured; run with --configure-claude-desktop" });
462
+ checks.push({
463
+ kind: "warn",
464
+ message: "Claude App gateway profile is not configured",
465
+ fix: "ccdx start --configure-claude-app",
466
+ });
459
467
  return checks;
460
468
  }
461
469
 
@@ -508,6 +516,7 @@ export async function collectDoctorChecks({
508
516
  host = "127.0.0.1",
509
517
  port = 2026,
510
518
  checkAdapter = true,
519
+ checkPmStudio = checkAdapter,
511
520
  checkAdapterListeningFn = checkAdapterListening,
512
521
  checkRunningAdapterFn = checkRunningAdapter,
513
522
  online = false,
@@ -517,6 +526,7 @@ export async function collectDoctorChecks({
517
526
  onlineTimeoutMs = 10000,
518
527
  compatTimeoutMs = 120000,
519
528
  inspectAdapterCompatibilityFn = inspectAdapterCompatibility,
529
+ inspectPmStudioHealthFn,
520
530
  } = {}) {
521
531
  const checks = [
522
532
  ...inspectAuthProfiles({ home }),
@@ -552,7 +562,43 @@ export async function collectDoctorChecks({
552
562
  const listening = await checkAdapterListeningFn({ host, port });
553
563
  checks.push(listening
554
564
  ? { kind: "warn", message: `A service is listening on ${localGatewayBaseUrl(host, port)}, but it is not a compatible codex-copilot-dx adapter` }
555
- : { kind: "warn", message: `Adapter is not listening on ${localGatewayBaseUrl(host, port)}` });
565
+ : {
566
+ kind: "warn",
567
+ message: `Adapter is not listening on ${localGatewayBaseUrl(host, port)}`,
568
+ fix: "ccdx start",
569
+ });
570
+ }
571
+ }
572
+
573
+ if (checkPmStudio && platform === "darwin") {
574
+ const inspect = inspectPmStudioHealthFn || (async (options) => {
575
+ const module = await import("./pm-studio-status.mjs");
576
+ return module.inspectPmStudioStatus(options);
577
+ });
578
+ const pm = await inspect({ home });
579
+ const version = pm.app.metadata ? `${pm.app.metadata.version} build ${pm.app.metadata.build}` : "";
580
+ if (pm.app.state === "patched") {
581
+ checks.push({ kind: "ok", message: `PM Studio ${version} patch is verified` });
582
+ if (!pm.claude.valid) {
583
+ checks.push({
584
+ kind: "warn",
585
+ message: "PM Studio Claude routing requires a valid isolated Claude profile",
586
+ fix: "ccdx auth login claude --reauth",
587
+ });
588
+ }
589
+ if (!pm.adapter?.ok) {
590
+ checks.push({ kind: "warn", message: "PM Studio relay is not currently available", fix: "ccdx start" });
591
+ }
592
+ } else if (pm.app.state === "clean") {
593
+ checks.push({
594
+ kind: "warn",
595
+ message: `PM Studio ${version} is supported but not patched`,
596
+ fix: "ccdx pms setup",
597
+ });
598
+ } else if (pm.app.state === "unsupported") {
599
+ checks.push({ kind: "warn", message: `PM Studio ${version} has no exact patch recipe; no files will be changed` });
600
+ } else if (pm.app.state === "drift" || pm.app.state === "error") {
601
+ checks.push({ kind: "err", message: `PM Studio integrity check failed: ${pm.app.issues.join("; ")}` });
556
602
  }
557
603
  }
558
604
 
@@ -604,5 +650,12 @@ export async function runDoctor(options = {}) {
604
650
  log(`${options.commandName || "ccdx"} doctor${flags.length ? ` ${flags.join(" ")}` : ""}`);
605
651
  const checks = await collectDoctorChecks(options);
606
652
  for (const check of checks) log(status(check.kind, check.message));
653
+ const totals = { ok: 0, warn: 0, err: 0 };
654
+ for (const check of checks) totals[check.kind] = (totals[check.kind] || 0) + 1;
655
+ log(status(totals.err ? "err" : totals.warn ? "warn" : "ok",
656
+ `Summary: ${totals.ok} passed, ${totals.warn} warning(s), ${totals.err} error(s)`));
657
+ for (const fix of new Set(checks.map((check) => check.fix).filter(Boolean))) {
658
+ log(status("info", `Next step: ${fix}`));
659
+ }
607
660
  return checks;
608
661
  }
@@ -4,6 +4,7 @@ import { copilotRuntimeStatus } from "./copilot.mjs";
4
4
  import { imageOptimizationStats } from "./image-optimization.mjs";
5
5
  import { responseHistoryStats } from "./response-history.mjs";
6
6
  import { loadRuntimeConfig } from "./runtime-config.mjs";
7
+ import { profileRouting } from "./profile-routing.mjs";
7
8
 
8
9
  export { ADAPTER_STATUS_PATH };
9
10
  const OBSERVABILITY_RUNTIME_CONFIG = loadRuntimeConfig();
@@ -191,10 +192,7 @@ export function runtimeStatusPayload({
191
192
  codex: { mode: "legacy", client: codexRuntime, models: codexModels },
192
193
  claude: { mode: resolvedClaudeMode, client: claudeRuntime, models: claudeModels },
193
194
  },
194
- routing: {
195
- responses: "codex",
196
- messages: resolvedClaudeMode === "isolated" ? "claude" : "codex",
197
- },
195
+ routing: profileRouting({ claudeMode: resolvedClaudeMode }),
198
196
  limits: {
199
197
  max_body_bytes: OBSERVABILITY_RUNTIME_CONFIG.maxBodyBytes,
200
198
  max_decoded_body_bytes: OBSERVABILITY_RUNTIME_CONFIG.maxDecodedBodyBytes,
@@ -10,6 +10,7 @@ import {
10
10
  writeOrDrain,
11
11
  } from "./http-transport.mjs";
12
12
  import { isLoopbackAddress } from "./observability.mjs";
13
+ import { createPmStudioModelRouter } from "./profile-routing.mjs";
13
14
  import { loadRuntimeConfig, parsePositiveInteger } from "./runtime-config.mjs";
14
15
  import { requireUpstreamEventStream } from "./stream-contract.mjs";
15
16
  import { safeUpstreamResponseHeaders } from "./upstream-headers.mjs";
@@ -147,48 +148,6 @@ function catalogData(catalog) {
147
148
  return Array.isArray(catalog?.data) ? catalog.data : null;
148
149
  }
149
150
 
150
- function originalClaudeModels(registry) {
151
- return catalogData(registry?.models) || [];
152
- }
153
-
154
- function isClaudeCatalogEntry(model) {
155
- const id = String(model?.id || "").trim();
156
- const vendor = String(model?.vendor || "").trim().toLowerCase();
157
- return Boolean(id) && (vendor === "anthropic" || id.toLowerCase().startsWith("claude-"));
158
- }
159
-
160
- function isAllowedClaudeModel(model) {
161
- return isClaudeCatalogEntry(model)
162
- && String(model?.vendor || "").trim().toLowerCase() === "anthropic"
163
- && model?.model_picker_enabled !== false
164
- && Array.isArray(model?.supported_endpoints)
165
- && model.supported_endpoints.includes("/chat/completions");
166
- }
167
-
168
- function claudeAvailability(options) {
169
- const enabled = options.claudeMode === "isolated"
170
- && options.claudeProfile?.valid === true
171
- && typeof options.claudeClient?.chatCompletions === "function";
172
- const catalog = originalClaudeModels(options.claudeModelRegistry);
173
- const allowed = new Map();
174
- const known = new Set();
175
- for (const model of catalog) {
176
- const id = String(model?.id || "").trim();
177
- if (!id || !isClaudeCatalogEntry(model)) continue;
178
- known.add(id);
179
- if (enabled && isAllowedClaudeModel(model) && !allowed.has(id)) allowed.set(id, model);
180
- }
181
- return { allowed, known };
182
- }
183
-
184
- function classifyModel(modelId, options) {
185
- const id = String(modelId || "").trim();
186
- const { allowed, known } = claudeAvailability(options);
187
- if (allowed.has(id)) return "claude";
188
- if (known.has(id) || id.toLowerCase().startsWith("claude-")) return "unsupported_claude";
189
- return "enterprise";
190
- }
191
-
192
151
  async function responseSnapshot(response) {
193
152
  return {
194
153
  status: response.status,
@@ -218,10 +177,10 @@ function rejectUpstreamRedirect(response) {
218
177
  return response;
219
178
  }
220
179
 
221
- function mergeModelCatalog(snapshot, options) {
180
+ function mergeModelCatalog(snapshot, modelRouter) {
222
181
  if (!snapshot.ok) return null;
223
- const { allowed } = claudeAvailability(options);
224
- if (!allowed.size) return null;
182
+ const allowed = modelRouter.allowedModels();
183
+ if (!allowed.length) return null;
225
184
  let enterprise;
226
185
  try {
227
186
  enterprise = JSON.parse(snapshot.body.toString("utf8"));
@@ -232,7 +191,8 @@ function mergeModelCatalog(snapshot, options) {
232
191
  if (!data) return null;
233
192
  const seen = new Set(data.map((model) => String(model?.id || "").trim()).filter(Boolean));
234
193
  const additions = [];
235
- for (const [id, model] of allowed) {
194
+ for (const model of allowed) {
195
+ const id = String(model?.id || "").trim();
236
196
  if (seen.has(id)) continue;
237
197
  seen.add(id);
238
198
  additions.push(model);
@@ -332,6 +292,12 @@ export function createPmStudioRelayHandler(options = {}) {
332
292
  const validated = new Map();
333
293
  const validationFlights = new Map();
334
294
  let activeModelRequests = 0;
295
+ const modelRouter = createPmStudioModelRouter({
296
+ getCatalog: () => options.claudeModelRegistry?.models,
297
+ isClaudeEnabled: () => options.claudeMode === "isolated"
298
+ && options.claudeProfile?.valid === true
299
+ && typeof options.claudeClient?.chatCompletions === "function",
300
+ });
335
301
 
336
302
  function isValidated(fingerprint) {
337
303
  const expiresAt = validated.get(fingerprint);
@@ -452,7 +418,7 @@ export function createPmStudioRelayHandler(options = {}) {
452
418
  sendSnapshot(res, snapshot);
453
419
  return;
454
420
  }
455
- const merged = mergeModelCatalog(snapshot, options);
421
+ const merged = mergeModelCatalog(snapshot, modelRouter);
456
422
  if (!merged) {
457
423
  sendSnapshot(res, snapshot);
458
424
  return;
@@ -480,7 +446,7 @@ export function createPmStudioRelayHandler(options = {}) {
480
446
  }
481
447
  abort.setTimeout(requestTimeoutMs, timeoutReason);
482
448
 
483
- const modelType = classifyModel(body?.model, options);
449
+ const modelType = modelRouter.classify(body?.model);
484
450
  if (modelType === "unsupported_claude" || (modelType === "claude" && pathname !== "/chat/completions")) {
485
451
  throw localError(400, "model_not_supported", "The requested Claude model is not available for this endpoint");
486
452
  }
@@ -0,0 +1,130 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import {
5
+ AUTH_PROFILE_CLAUDE,
6
+ readAuthProfileCredentials,
7
+ } from "./auth-profile.mjs";
8
+ import { PM_STUDIO_RECIPES } from "./pm-studio-asar.mjs";
9
+ import {
10
+ createPmStudioSetupOperations,
11
+ DEFAULT_PM_STUDIO_APP_PATH,
12
+ inspectPmStudioApp,
13
+ PM_STUDIO_CLAUDE_AUTH_COMMAND,
14
+ } from "./pm-studio-setup.mjs";
15
+ import { checkRunningAdapter } from "./running-adapter.mjs";
16
+ import { status } from "./status.mjs";
17
+
18
+ const PM_RELAY_HOST = "127.0.0.1";
19
+ const PM_RELAY_PORT = 2026;
20
+
21
+ function selectRecipe(metadata, recipes) {
22
+ return recipes.find((recipe) => recipe.version === String(metadata.version)
23
+ && recipe.build === String(metadata.build)
24
+ && recipe.bundleIdentifier === String(metadata.bundleIdentifier));
25
+ }
26
+
27
+ function safeMessage(error) {
28
+ return String(error?.message || "inspection failed").replace(/\s+/g, " ").trim();
29
+ }
30
+
31
+ function inspectClaudeProfile(home, readCredentials) {
32
+ try {
33
+ const credentials = readCredentials(AUTH_PROFILE_CLAUDE, { home });
34
+ return {
35
+ configured: credentials.configured === true,
36
+ valid: credentials.valid === true,
37
+ reason: credentials.reason || "",
38
+ login: credentials.identity?.login || "",
39
+ };
40
+ } catch (error) {
41
+ return { configured: true, valid: false, reason: safeMessage(error), login: "" };
42
+ }
43
+ }
44
+
45
+ export async function inspectPmStudioStatus({
46
+ appPath = DEFAULT_PM_STUDIO_APP_PATH,
47
+ home = os.homedir(),
48
+ recipes = PM_STUDIO_RECIPES,
49
+ operationOverrides = {},
50
+ existsSync = fs.existsSync,
51
+ inspectApp = inspectPmStudioApp,
52
+ readClaudeCredentials = readAuthProfileCredentials,
53
+ checkRunningAdapterFn = checkRunningAdapter,
54
+ } = {}) {
55
+ const operations = createPmStudioSetupOperations(operationOverrides);
56
+ let app;
57
+ if (!existsSync(appPath)) {
58
+ app = { state: "not_installed", metadata: null, issues: [] };
59
+ } else {
60
+ try {
61
+ const metadata = operations.readBundleMetadata({
62
+ appPath,
63
+ infoPlistPath: path.join(appPath, "Contents/Info.plist"),
64
+ processRunner: operations.processRunner,
65
+ });
66
+ const normalized = {
67
+ version: String(metadata.version),
68
+ build: String(metadata.build),
69
+ bundleIdentifier: String(metadata.bundleIdentifier),
70
+ };
71
+ const recipe = selectRecipe(normalized, recipes);
72
+ app = recipe
73
+ ? { ...inspectApp({ appPath, recipe, operations }), recipe: recipe.id }
74
+ : { state: "unsupported", metadata: normalized, issues: [], recipe: "" };
75
+ } catch (error) {
76
+ app = { state: "error", metadata: null, issues: [safeMessage(error)], recipe: "" };
77
+ }
78
+ }
79
+
80
+ const claude = inspectClaudeProfile(home, readClaudeCredentials);
81
+ let adapter;
82
+ try {
83
+ adapter = await checkRunningAdapterFn({ host: PM_RELAY_HOST, port: PM_RELAY_PORT });
84
+ } catch (error) {
85
+ adapter = { ok: false, error };
86
+ }
87
+ const operational = app.state === "patched" && claude.valid && adapter?.ok === true;
88
+ return { ok: operational, appPath, app, claude, adapter };
89
+ }
90
+
91
+ function appStatusLine(result, commandName) {
92
+ const { app } = result;
93
+ const version = app.metadata ? `${app.metadata.version} build ${app.metadata.build}` : "";
94
+ if (app.state === "patched") return status("ok", `PM Studio ${version} is patched and verified`);
95
+ if (app.state === "clean") return status("warn", `PM Studio ${version} is supported but not patched; run ${commandName} pms setup`);
96
+ if (app.state === "unsupported") return status("err", `PM Studio ${version} has no exact patch recipe; no files will be changed`);
97
+ if (app.state === "drift") return status("err", `PM Studio ${version} does not match the clean or patched recipe: ${app.issues.join("; ")}`);
98
+ if (app.state === "not_installed") return status("warn", `PM Studio is not installed at ${result.appPath}`);
99
+ return status("err", `PM Studio could not be inspected: ${app.issues.join("; ")}`);
100
+ }
101
+
102
+ export function formatPmStudioStatus(result, { commandName = "ccdx" } = {}) {
103
+ const lines = [
104
+ `${commandName} pms status`,
105
+ appStatusLine(result, commandName),
106
+ ];
107
+ if (result.claude.valid) {
108
+ const account = result.claude.login ? ` for ${result.claude.login}` : "";
109
+ lines.push(status("ok", `Isolated Claude profile is valid${account}`));
110
+ } else {
111
+ const reason = result.claude.configured && result.claude.reason ? ` (${result.claude.reason})` : "";
112
+ lines.push(status("err", `Isolated Claude profile is not ready${reason}; run ${PM_STUDIO_CLAUDE_AUTH_COMMAND}`));
113
+ }
114
+ if (result.adapter?.ok) {
115
+ lines.push(status("ok", `PM relay is available at ${result.adapter.baseUrl}`));
116
+ } else if (result.adapter?.incompatible) {
117
+ lines.push(status("err", `The adapter at ${result.adapter.baseUrl} is incompatible; stop it and run ${commandName} start`));
118
+ } else {
119
+ lines.push(status("warn", `PM relay is not running at http://${PM_RELAY_HOST}:${PM_RELAY_PORT}; run ${commandName} start`));
120
+ }
121
+ lines.push(status("info", "Routing: PM GPT uses the PM Studio bearer; eligible Claude chat uses the isolated Claude profile"));
122
+ return lines.join("\n");
123
+ }
124
+
125
+ export async function runPmStudioStatus(options = {}) {
126
+ const result = await inspectPmStudioStatus(options);
127
+ const output = formatPmStudioStatus(result, options);
128
+ (options.log || console.log)(output);
129
+ return { ...result, output };
130
+ }
@@ -33,6 +33,7 @@ export function createProfileModelRuntime({
33
33
  codexClient,
34
34
  claudeClient = codexClient,
35
35
  claudeMode = "inherited",
36
+ codexCredentialFingerprint = "",
36
37
  claudeCredentialFingerprint = "",
37
38
  home,
38
39
  env = process.env,
@@ -93,7 +94,7 @@ export function createProfileModelRuntime({
93
94
  function loadCached({ client, profile, registry, ownsClaudeModels }) {
94
95
  const credentialFingerprint = profile === "claude"
95
96
  ? String(claudeCredentialFingerprint || "").trim()
96
- : "";
97
+ : String(codexCredentialFingerprint || "").trim();
97
98
  if (profile === "claude" && !credentialFingerprint) return false;
98
99
  const cached = loadModelCache({ home, profile, credentialFingerprint });
99
100
  if (!cached) return false;
@@ -133,7 +134,7 @@ export function createProfileModelRuntime({
133
134
  function persist(models, profile) {
134
135
  const credentialFingerprint = profile === "claude"
135
136
  ? String(claudeCredentialFingerprint || "").trim()
136
- : "";
137
+ : String(codexCredentialFingerprint || "").trim();
137
138
  if (profile === "claude" && !credentialFingerprint) return;
138
139
  try {
139
140
  saveModelCache(models, { home, profile, credentialFingerprint });
@@ -0,0 +1,74 @@
1
+ function catalogData(catalog) {
2
+ if (Array.isArray(catalog)) return catalog;
3
+ return Array.isArray(catalog?.data) ? catalog.data : [];
4
+ }
5
+
6
+ function isClaudeCatalogEntry(model) {
7
+ const id = String(model?.id || "").trim();
8
+ const vendor = String(model?.vendor || "").trim().toLowerCase();
9
+ return Boolean(id) && (vendor === "anthropic" || id.toLowerCase().startsWith("claude-"));
10
+ }
11
+
12
+ function isAllowedClaudeModel(model) {
13
+ return isClaudeCatalogEntry(model)
14
+ && String(model?.vendor || "").trim().toLowerCase() === "anthropic"
15
+ && model?.model_picker_enabled !== false
16
+ && Array.isArray(model?.supported_endpoints)
17
+ && model.supported_endpoints.includes("/chat/completions");
18
+ }
19
+
20
+ export function profileRouting({ claudeMode = "inherited", claudeConfigured = false } = {}) {
21
+ const isolated = claudeMode === "isolated" || claudeConfigured === true;
22
+ return Object.freeze({ responses: "codex", messages: isolated ? "claude" : "codex" });
23
+ }
24
+
25
+ export function createPmStudioModelRouter({ getCatalog, isClaudeEnabled } = {}) {
26
+ if (typeof getCatalog !== "function") throw new Error("getCatalog is required");
27
+ if (typeof isClaudeEnabled !== "function") throw new Error("isClaudeEnabled is required");
28
+
29
+ const unset = Symbol("unset");
30
+ let cachedCatalog = unset;
31
+ let cachedEnabled = false;
32
+ let cachedAllowed = new Map();
33
+ let cachedKnown = new Set();
34
+ let rebuilds = 0;
35
+
36
+ function availability() {
37
+ const catalog = getCatalog();
38
+ const enabled = isClaudeEnabled() === true;
39
+ if (catalog === cachedCatalog && enabled === cachedEnabled) {
40
+ return { allowed: cachedAllowed, known: cachedKnown };
41
+ }
42
+
43
+ const allowed = new Map();
44
+ const known = new Set();
45
+ for (const model of catalogData(catalog)) {
46
+ const id = String(model?.id || "").trim();
47
+ if (!id || !isClaudeCatalogEntry(model)) continue;
48
+ known.add(id);
49
+ if (enabled && isAllowedClaudeModel(model) && !allowed.has(id)) allowed.set(id, model);
50
+ }
51
+ cachedCatalog = catalog;
52
+ cachedEnabled = enabled;
53
+ cachedAllowed = allowed;
54
+ cachedKnown = known;
55
+ rebuilds += 1;
56
+ return { allowed, known };
57
+ }
58
+
59
+ return Object.freeze({
60
+ classify(modelId) {
61
+ const id = String(modelId || "").trim();
62
+ const { allowed, known } = availability();
63
+ if (allowed.has(id)) return "claude";
64
+ if (known.has(id) || id.toLowerCase().startsWith("claude-")) return "unsupported_claude";
65
+ return "enterprise";
66
+ },
67
+ allowedModels() {
68
+ return [...availability().allowed.values()];
69
+ },
70
+ diagnostics() {
71
+ return { rebuilds, allowed: cachedAllowed.size, known: cachedKnown.size };
72
+ },
73
+ });
74
+ }
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  AUTH_PROFILE_CLAUDE,
3
+ AUTH_PROFILE_CODEX,
3
4
  authProfilePaths,
4
5
  profileReauthMessage,
5
6
  readAuthProfileCredentials,
@@ -45,6 +46,16 @@ export function createProfileRuntime({
45
46
  };
46
47
  const credentials = readCredentials();
47
48
  const claudeProfile = safeProfileStatus(credentials);
49
+ let codexCredentialFingerprint = "";
50
+ try {
51
+ const codexCredentials = readAuthProfileCredentials(AUTH_PROFILE_CODEX, { home });
52
+ if (codexCredentials.valid) {
53
+ codexCredentialFingerprint = githubTokenFingerprint(codexCredentials.token);
54
+ }
55
+ } catch {
56
+ // The Codex client remains authoritative if its optional metadata cannot
57
+ // be inspected; only credential-bound cache reuse is disabled.
58
+ }
48
59
 
49
60
  if (!credentials.configured) {
50
61
  return Object.freeze({
@@ -52,6 +63,7 @@ export function createProfileRuntime({
52
63
  claudeClient: codexClient,
53
64
  claudeMode: "inherited",
54
65
  claudeProfile,
66
+ codexCredentialFingerprint,
55
67
  claudeCredentialFingerprint: "",
56
68
  });
57
69
  }
@@ -73,6 +85,7 @@ export function createProfileRuntime({
73
85
  claudeClient,
74
86
  claudeMode: "isolated",
75
87
  claudeProfile,
88
+ codexCredentialFingerprint,
76
89
  claudeCredentialFingerprint: credentials.valid
77
90
  ? githubTokenFingerprint(credentials.token)
78
91
  : "",