codex-copilot-dx 0.4.8 → 0.4.10
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 +18 -8
- package/bin/cli.mjs +12 -4
- package/package.json +3 -2
- package/src/adapter.mjs +55 -14
- package/src/auth.mjs +6 -1
- package/src/claude-desktop-config.mjs +7 -7
- package/src/doctor.mjs +207 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# codex-copilot-dx
|
|
2
2
|
|
|
3
|
-
Use [Codex Desktop](https://openai.com/codex), [Claude Code](https://claude.com/claude-code), and optionally Claude
|
|
3
|
+
Use [Codex Desktop](https://openai.com/codex), [Claude Code](https://claude.com/claude-code), and optionally Claude App with your **GitHub Copilot** subscription.
|
|
4
4
|
|
|
5
5
|
## How it works
|
|
6
6
|
|
|
@@ -8,7 +8,7 @@ A single in-process adapter (port `2026`) exposes both APIs over your Copilot su
|
|
|
8
8
|
|
|
9
9
|
- **Codex** -> OpenAI Responses API (`/v1/responses`, `/v1/responses/compact`); Responses-only models and compaction proxy directly, chat models convert to Chat Completions.
|
|
10
10
|
- **Claude Code** -> Anthropic Messages API (`/v1/messages`, `/v1/messages/count_tokens`), translated to/from Chat Completions.
|
|
11
|
-
- **Claude
|
|
11
|
+
- **Claude App** -> optional Claude Desktop App gateway profile using the same local Messages API plus local model discovery for the configured gateway key.
|
|
12
12
|
|
|
13
13
|
Supports both HTTP SSE streaming and non-streaming.
|
|
14
14
|
|
|
@@ -16,7 +16,7 @@ Supports both HTTP SSE streaming and non-streaming.
|
|
|
16
16
|
|
|
17
17
|
- GitHub Copilot subscription (Individual, Business, or Enterprise)
|
|
18
18
|
- Node.js 18+
|
|
19
|
-
- [Codex Desktop](https://openai.com/codex), [Claude Code](https://claude.com/claude-code), and/or Claude
|
|
19
|
+
- [Codex Desktop](https://openai.com/codex), [Claude Code](https://claude.com/claude-code), and/or Claude App installed
|
|
20
20
|
|
|
21
21
|
## Usage
|
|
22
22
|
|
|
@@ -38,9 +38,19 @@ Claude Code picks up the new `ANTHROPIC_BASE_URL` on its next launch.
|
|
|
38
38
|
|
|
39
39
|
Do not set Claude Code by manually exporting `ANTHROPIC_BASE_URL` or `ANTHROPIC_AUTH_TOKEN` in your shell. Let `codex-copilot-dx` write the local config files instead. If you previously exported those variables, remove them from shell startup files and restart the terminal before launching Claude Code.
|
|
40
40
|
|
|
41
|
-
###
|
|
41
|
+
### Diagnostics
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
Run a read-only config check without starting the adapter or changing files:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
codex-copilot-dx doctor
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The doctor checks the GitHub token, Codex config, Claude Code settings, Claude App gateway profile, and whether the local adapter port is listening.
|
|
50
|
+
|
|
51
|
+
### Claude App opt-in
|
|
52
|
+
|
|
53
|
+
Claude App support is opt-in so the default Codex Desktop and Claude Code setup stays unchanged:
|
|
44
54
|
|
|
45
55
|
```bash
|
|
46
56
|
npx codex-copilot-dx@latest --configure-claude-desktop
|
|
@@ -52,7 +62,7 @@ Or set:
|
|
|
52
62
|
CCDX_CONFIGURE_CLAUDE_DESKTOP=1 npx codex-copilot-dx@latest
|
|
53
63
|
```
|
|
54
64
|
|
|
55
|
-
This writes a local Claude
|
|
65
|
+
This writes a local Claude App 3P gateway profile that points to the adapter root URL, such as `http://127.0.0.1:2026`. The profile uses a generated local bearer key unless `CCDX_CLAUDE_DESKTOP_API_KEY` is set. Restart Claude App after running the command.
|
|
56
66
|
|
|
57
67
|
## Configuration
|
|
58
68
|
|
|
@@ -70,8 +80,8 @@ Environment variables:
|
|
|
70
80
|
| `CCDX_IMG_MIN_BYTES` | `100000` | Images smaller than this are left untouched |
|
|
71
81
|
| `CCDX_IMG_CONCURRENCY` | `4` | Concurrent image optimization tasks; values above `12` are capped at `12` |
|
|
72
82
|
| `CCDX_DISABLE_IMG_OPT` | unset | Set to `1` to disable image optimization |
|
|
73
|
-
| `CCDX_CONFIGURE_CLAUDE_DESKTOP` | unset | Set to `1` to write the Claude
|
|
74
|
-
| `CCDX_CLAUDE_DESKTOP_API_KEY` | generated for opt-in setup | Bearer key written into the Claude
|
|
83
|
+
| `CCDX_CONFIGURE_CLAUDE_DESKTOP` | unset | Set to `1` to write the Claude App 3P gateway profile during startup |
|
|
84
|
+
| `CCDX_CLAUDE_DESKTOP_API_KEY` | generated for opt-in setup | Bearer key written into the Claude App profile and recognized by the adapter for model discovery |
|
|
75
85
|
| `CCDX_CLAUDE_MODEL_ALIASES` | built-in Claude aliases | Comma-separated Desktop-to-upstream aliases, for example `claude-sonnet-4-6=claude-sonnet-4.6` |
|
|
76
86
|
| `CCDX_USAGE_PATH` | `~/.local/share/codex-copilot-dx/usage.jsonl` | Local JSONL token usage log |
|
|
77
87
|
| `CCDX_DISABLE_USAGE` | unset | Set to `1` to disable usage logging |
|
package/bin/cli.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { refreshVSCodeVersion } from "../src/copilot.mjs";
|
|
|
9
9
|
import { status } from "../src/status.mjs";
|
|
10
10
|
import { printUsageSummary } from "../src/usage.mjs";
|
|
11
11
|
import { checkForUpdate, localPackageVersion } from "../src/version.mjs";
|
|
12
|
+
import { runDoctor } from "../src/doctor.mjs";
|
|
12
13
|
|
|
13
14
|
const ADAPTER_PORT = parseInt(process.env.ADAPTER_PORT || "2026");
|
|
14
15
|
const ADAPTER_HOST = process.env.ADAPTER_HOST || "127.0.0.1";
|
|
@@ -30,9 +31,14 @@ if (command === "usage") {
|
|
|
30
31
|
process.exit(0);
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
if (command === "doctor" || command === "--doctor") {
|
|
35
|
+
await runDoctor({ port: ADAPTER_PORT, host: ADAPTER_HOST });
|
|
36
|
+
process.exit(0);
|
|
37
|
+
}
|
|
38
|
+
|
|
33
39
|
console.log(`
|
|
34
40
|
codex-copilot-dx v${LOCAL_VERSION}
|
|
35
|
-
Use Codex Desktop, Claude Code, and Claude
|
|
41
|
+
Use Codex Desktop, Claude Code, and Claude App with GitHub Copilot
|
|
36
42
|
`);
|
|
37
43
|
|
|
38
44
|
checkForUpdate({ currentVersion: LOCAL_VERSION }).then(({ latestVersion, updateAvailable }) => {
|
|
@@ -68,8 +74,10 @@ try {
|
|
|
68
74
|
host: ADAPTER_HOST,
|
|
69
75
|
gatewayApiKey: claudeDesktopApiKey,
|
|
70
76
|
});
|
|
71
|
-
console.log(status("ok", `Configured Claude
|
|
77
|
+
console.log(status("ok", `Configured Claude App gateway profile at ${result.baseUrl}`));
|
|
72
78
|
console.log(formatClaudeDesktopApplyResult(result));
|
|
79
|
+
} else {
|
|
80
|
+
console.log(status("ok", "Claude App support available with --configure-claude-desktop"));
|
|
73
81
|
}
|
|
74
82
|
|
|
75
83
|
// Launch Codex when available.
|
|
@@ -77,8 +85,8 @@ try {
|
|
|
77
85
|
|
|
78
86
|
console.log(`
|
|
79
87
|
${status("ok", CONFIGURE_CLAUDE_DESKTOP
|
|
80
|
-
? "Ready. Codex, Claude Code, and Claude
|
|
81
|
-
: "Ready. Codex and Claude Code are using your GitHub Copilot subscription.")}
|
|
88
|
+
? "Ready. Codex, Claude Code, and Claude App are using your GitHub Copilot subscription."
|
|
89
|
+
: "Ready. Codex and Claude Code are using your GitHub Copilot subscription. Claude App support is available with --configure-claude-desktop.")}
|
|
82
90
|
|
|
83
91
|
Adapter: http://${ADAPTER_HOST}:${ADAPTER_PORT}
|
|
84
92
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codex-copilot-dx",
|
|
3
|
-
"version": "0.4.
|
|
4
|
-
"description": "Use Codex Desktop, Claude Code, and Claude
|
|
3
|
+
"version": "0.4.10",
|
|
4
|
+
"description": "Use Codex Desktop, Claude Code, and Claude App with GitHub Copilot",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"codex-copilot-dx": "bin/cli.mjs"
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"copilot",
|
|
17
17
|
"github",
|
|
18
18
|
"claude",
|
|
19
|
+
"claude-app",
|
|
19
20
|
"claude-code",
|
|
20
21
|
"claude-desktop",
|
|
21
22
|
"anthropic",
|
package/src/adapter.mjs
CHANGED
|
@@ -122,6 +122,7 @@ export async function readJsonBody(req, {
|
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
function sendJsonError(res, err, fallbackStatus = 400) {
|
|
125
|
+
if (res.destroyed || res.writableEnded) return;
|
|
125
126
|
if (!res.headersSent) res.writeHead(err?.statusCode || fallbackStatus, { "Content-Type": "application/json" });
|
|
126
127
|
res.end(JSON.stringify({ error: err?.message || "Request failed" }));
|
|
127
128
|
}
|
|
@@ -164,30 +165,61 @@ export function writeOrDrain(res, chunk) {
|
|
|
164
165
|
});
|
|
165
166
|
}
|
|
166
167
|
|
|
167
|
-
function
|
|
168
|
+
export function isAbortLikeError(err) {
|
|
169
|
+
return err?.name === "AbortError" || /operation was aborted/i.test(String(err?.message || ""));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function abortErrorStatusCode(reason) {
|
|
173
|
+
if (reason === "upstream_timeout") return 504;
|
|
174
|
+
if (reason === "client_aborted" || reason === "client_closed") return 499;
|
|
175
|
+
return 400;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function createRequestAbort(req, res) {
|
|
168
179
|
const controller = new AbortController();
|
|
169
180
|
let timer = null;
|
|
170
181
|
let cleaned = false;
|
|
171
|
-
|
|
172
|
-
|
|
182
|
+
let reason = null;
|
|
183
|
+
const abort = (nextReason = "aborted") => {
|
|
184
|
+
if (!cleaned && !controller.signal.aborted) {
|
|
185
|
+
reason = nextReason;
|
|
186
|
+
controller.abort();
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
const onReqAborted = () => abort("client_aborted");
|
|
190
|
+
const onResClose = () => {
|
|
191
|
+
if (!res.writableEnded) abort("client_closed");
|
|
173
192
|
};
|
|
174
|
-
req.on("aborted",
|
|
175
|
-
res.on("close",
|
|
193
|
+
req.on("aborted", onReqAborted);
|
|
194
|
+
res.on("close", onResClose);
|
|
176
195
|
return {
|
|
177
196
|
signal: controller.signal,
|
|
197
|
+
get reason() { return reason; },
|
|
178
198
|
setTimeout(ms) {
|
|
179
199
|
if (timer) clearTimeout(timer);
|
|
180
|
-
if (ms > 0) timer = setTimeout(abort, ms);
|
|
200
|
+
if (ms > 0) timer = setTimeout(() => abort("upstream_timeout"), ms);
|
|
181
201
|
},
|
|
182
202
|
cleanup() {
|
|
183
203
|
cleaned = true;
|
|
184
204
|
if (timer) clearTimeout(timer);
|
|
185
|
-
req.off("aborted",
|
|
186
|
-
res.off("close",
|
|
205
|
+
req.off("aborted", onReqAborted);
|
|
206
|
+
res.off("close", onResClose);
|
|
187
207
|
},
|
|
188
208
|
};
|
|
189
209
|
}
|
|
190
210
|
|
|
211
|
+
function logRequestFailure(label, err, abort) {
|
|
212
|
+
if (!isAbortLikeError(err)) {
|
|
213
|
+
console.error(status("err", `${label} request failed: ${err.message}`));
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const reason = abort?.reason || "aborted";
|
|
218
|
+
err.statusCode ||= abortErrorStatusCode(reason);
|
|
219
|
+
const detail = reason === "upstream_timeout" ? `${reason} after ${UPSTREAM_TIMEOUT_MS}ms` : reason;
|
|
220
|
+
console.warn(status("warn", `${label} request aborted: ${detail}`));
|
|
221
|
+
}
|
|
222
|
+
|
|
191
223
|
function responsesInputItems(input) {
|
|
192
224
|
if (input === undefined || input === null) return [];
|
|
193
225
|
if (typeof input === "string") {
|
|
@@ -562,13 +594,16 @@ export function startAdapter(port = 2026, host = "127.0.0.1", options = {}) {
|
|
|
562
594
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
563
595
|
res.end(JSON.stringify(resp));
|
|
564
596
|
} catch (e) {
|
|
565
|
-
|
|
566
|
-
res.
|
|
597
|
+
logRequestFailure("Responses", e, abort);
|
|
598
|
+
if (!res.destroyed && !res.writableEnded) {
|
|
599
|
+
if (!res.headersSent) res.writeHead(e?.statusCode || 502);
|
|
600
|
+
res.end("Bad Gateway");
|
|
601
|
+
}
|
|
567
602
|
}
|
|
568
603
|
}
|
|
569
604
|
}
|
|
570
605
|
} catch (e) {
|
|
571
|
-
|
|
606
|
+
logRequestFailure("Responses", e, abort);
|
|
572
607
|
sendJsonError(res, e);
|
|
573
608
|
} finally {
|
|
574
609
|
abort.cleanup();
|
|
@@ -589,7 +624,7 @@ export function startAdapter(port = 2026, host = "127.0.0.1", options = {}) {
|
|
|
589
624
|
console.log(status("info", `responses compact model=${model} stream=${!!parsed.stream}`));
|
|
590
625
|
await proxyCopilotResponses(prepared, req, res, copilotResponsesCompact, { signal: abort.signal });
|
|
591
626
|
} catch (e) {
|
|
592
|
-
|
|
627
|
+
logRequestFailure("Responses compact", e, abort);
|
|
593
628
|
sendJsonError(res, e);
|
|
594
629
|
} finally {
|
|
595
630
|
abort.cleanup();
|
|
@@ -608,7 +643,13 @@ export function startAdapter(port = 2026, host = "127.0.0.1", options = {}) {
|
|
|
608
643
|
abort.setTimeout(UPSTREAM_TIMEOUT_MS);
|
|
609
644
|
listModels({ signal: abort.signal })
|
|
610
645
|
.then(({ status, body }) => { res.writeHead(status, { "Content-Type": "application/json" }); res.end(body); })
|
|
611
|
-
.catch((e) => {
|
|
646
|
+
.catch((e) => {
|
|
647
|
+
logRequestFailure("Models", e, abort);
|
|
648
|
+
if (!res.destroyed && !res.writableEnded) {
|
|
649
|
+
if (!res.headersSent) res.writeHead(e?.statusCode || 502);
|
|
650
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
651
|
+
}
|
|
652
|
+
})
|
|
612
653
|
.finally(() => abort.cleanup());
|
|
613
654
|
return;
|
|
614
655
|
}
|
|
@@ -671,7 +712,7 @@ export function startAdapter(port = 2026, host = "127.0.0.1", options = {}) {
|
|
|
671
712
|
res.end(JSON.stringify(anthropicMsg));
|
|
672
713
|
}
|
|
673
714
|
} catch (e) {
|
|
674
|
-
|
|
715
|
+
logRequestFailure("Messages", e, abort);
|
|
675
716
|
sendJsonError(res, e);
|
|
676
717
|
} finally {
|
|
677
718
|
abort.cleanup();
|
package/src/auth.mjs
CHANGED
|
@@ -6,7 +6,10 @@ import { status } from "./status.mjs";
|
|
|
6
6
|
|
|
7
7
|
const CLIENT_ID = "Iv1.b507a08c87ecfe98"; // Public GitHub Copilot client ID.
|
|
8
8
|
const SCOPE = "read:user";
|
|
9
|
-
|
|
9
|
+
|
|
10
|
+
export function githubTokenPath(home = os.homedir()) {
|
|
11
|
+
return path.join(home, ".local", "share", "copilot-api", "github_token");
|
|
12
|
+
}
|
|
10
13
|
|
|
11
14
|
// Map GitHub polling responses to a small local state machine.
|
|
12
15
|
export function interpretPoll(data) {
|
|
@@ -40,6 +43,7 @@ function openAndCopy(userCode, verificationUri) {
|
|
|
40
43
|
}
|
|
41
44
|
|
|
42
45
|
export async function ensureAuth() {
|
|
46
|
+
const GITHUB_TOKEN_PATH = githubTokenPath();
|
|
43
47
|
if (fs.existsSync(GITHUB_TOKEN_PATH)) {
|
|
44
48
|
console.log(status("ok", "GitHub token found"));
|
|
45
49
|
return;
|
|
@@ -90,6 +94,7 @@ export async function ensureAuth() {
|
|
|
90
94
|
}
|
|
91
95
|
|
|
92
96
|
function writeToken(token) {
|
|
97
|
+
const GITHUB_TOKEN_PATH = githubTokenPath();
|
|
93
98
|
fs.mkdirSync(path.dirname(GITHUB_TOKEN_PATH), { recursive: true });
|
|
94
99
|
fs.writeFileSync(GITHUB_TOKEN_PATH, token, { mode: 0o600 });
|
|
95
100
|
}
|
|
@@ -54,16 +54,16 @@ export function validateClaudeDesktopBaseUrl(raw) {
|
|
|
54
54
|
try {
|
|
55
55
|
parsed = new URL(raw);
|
|
56
56
|
} catch {
|
|
57
|
-
throw new Error(`Claude
|
|
57
|
+
throw new Error(`Claude App gateway base URL is not valid: ${raw}`);
|
|
58
58
|
}
|
|
59
59
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
60
|
-
throw new Error("Claude
|
|
60
|
+
throw new Error("Claude App gateway base URL must use http or https");
|
|
61
61
|
}
|
|
62
62
|
if (parsed.protocol === "http:" && !isLoopbackHost(parsed.hostname)) {
|
|
63
|
-
throw new Error("Claude
|
|
63
|
+
throw new Error("Claude App allows http only for loopback; use https for LAN or remote gateways");
|
|
64
64
|
}
|
|
65
65
|
if (parsed.pathname !== "/" || parsed.search || parsed.hash) {
|
|
66
|
-
throw new Error("Claude
|
|
66
|
+
throw new Error("Claude App gateway base URL must be the root URL without /v1, query, or hash");
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
|
|
@@ -84,7 +84,7 @@ export function computeClaudeDesktopProfile(sourceProfile = {}, {
|
|
|
84
84
|
} = {}) {
|
|
85
85
|
validateClaudeDesktopBaseUrl(baseUrl);
|
|
86
86
|
if (!String(gatewayApiKey || "").trim()) {
|
|
87
|
-
throw new Error("Claude
|
|
87
|
+
throw new Error("Claude App gateway API key is required");
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
const profile = {};
|
|
@@ -207,7 +207,7 @@ export function applyClaudeDesktopConfig({
|
|
|
207
207
|
|
|
208
208
|
export function formatClaudeDesktopApplyResult(result) {
|
|
209
209
|
return [
|
|
210
|
-
"Claude
|
|
210
|
+
"Claude App local gateway profile",
|
|
211
211
|
`profile id: ${result.profileId}`,
|
|
212
212
|
`profile path: ${result.profilePath}`,
|
|
213
213
|
`base url: ${result.baseUrl}`,
|
|
@@ -222,7 +222,7 @@ export function ensureClaudeDesktopConfig(port = 2026, host = "127.0.0.1") {
|
|
|
222
222
|
host,
|
|
223
223
|
gatewayApiKey: process.env.CCDX_CLAUDE_DESKTOP_API_KEY || process.env.CCDX_PROXY_API_KEY || generatedClaudeDesktopApiKey(),
|
|
224
224
|
});
|
|
225
|
-
console.log(status("ok", `Configured Claude
|
|
225
|
+
console.log(status("ok", `Configured Claude App gateway profile at ${result.baseUrl}`));
|
|
226
226
|
console.log(formatClaudeDesktopApplyResult(result));
|
|
227
227
|
return result;
|
|
228
228
|
}
|
package/src/doctor.mjs
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import net from "node:net";
|
|
5
|
+
import { githubTokenPath } from "./auth.mjs";
|
|
6
|
+
import { claudeDesktopPaths } from "./claude-desktop-config.mjs";
|
|
7
|
+
import { status } from "./status.mjs";
|
|
8
|
+
|
|
9
|
+
function localGatewayBaseUrl(host, port) {
|
|
10
|
+
const safeHost = String(host || "127.0.0.1");
|
|
11
|
+
const urlHost = safeHost.includes(":") && !safeHost.startsWith("[") ? `[${safeHost}]` : safeHost;
|
|
12
|
+
return `http://${urlHost}:${port}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function readText(filePath) {
|
|
16
|
+
try {
|
|
17
|
+
return { ok: true, text: fs.readFileSync(filePath, "utf8") };
|
|
18
|
+
} catch (e) {
|
|
19
|
+
return { ok: false, missing: e?.code === "ENOENT", error: e };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function readJson(filePath) {
|
|
24
|
+
const raw = readText(filePath);
|
|
25
|
+
if (!raw.ok) return raw;
|
|
26
|
+
try {
|
|
27
|
+
return { ok: true, json: JSON.parse(raw.text) };
|
|
28
|
+
} catch (e) {
|
|
29
|
+
return { ok: false, parseError: true, error: e };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function tomlString(content, key) {
|
|
34
|
+
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
35
|
+
const match = new RegExp(`^\\s*${escaped}\\s*=\\s*"([^"]*)"`, "m").exec(content);
|
|
36
|
+
return match?.[1] || "";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function displayPath(home, filePath) {
|
|
40
|
+
const rel = path.relative(home, filePath);
|
|
41
|
+
if (!rel.startsWith("..") && !path.isAbsolute(rel)) return `~/${rel}`;
|
|
42
|
+
return filePath;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function valueLabel(value) {
|
|
46
|
+
return value ? `"${value}"` : "missing";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function inspectGitHubToken({ home = os.homedir() } = {}) {
|
|
50
|
+
const filePath = githubTokenPath(home);
|
|
51
|
+
const token = readText(filePath);
|
|
52
|
+
if (!token.ok) {
|
|
53
|
+
return [{ kind: "warn", message: `GitHub token not found at ${displayPath(home, filePath)}` }];
|
|
54
|
+
}
|
|
55
|
+
if (!token.text.trim()) {
|
|
56
|
+
return [{ kind: "err", message: `GitHub token file is empty at ${displayPath(home, filePath)}` }];
|
|
57
|
+
}
|
|
58
|
+
return [{ kind: "ok", message: "GitHub token found" }];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function inspectCodexConfig({ home = os.homedir(), port = 2026 } = {}) {
|
|
62
|
+
const filePath = path.join(home, ".codex", "config.toml");
|
|
63
|
+
const expectedBaseUrl = `http://127.0.0.1:${port}/v1`;
|
|
64
|
+
const expectedAnthropicBaseUrl = `http://127.0.0.1:${port}`;
|
|
65
|
+
const config = readText(filePath);
|
|
66
|
+
if (!config.ok) {
|
|
67
|
+
return [{ kind: "warn", message: `Codex config not found at ${displayPath(home, filePath)}` }];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const checks = [];
|
|
71
|
+
const baseUrl = tomlString(config.text, "openai_base_url");
|
|
72
|
+
checks.push(baseUrl === expectedBaseUrl
|
|
73
|
+
? { kind: "ok", message: `Codex base URL points to ${expectedBaseUrl}` }
|
|
74
|
+
: { kind: "warn", message: `Codex base URL is ${valueLabel(baseUrl)}; expected "${expectedBaseUrl}"` });
|
|
75
|
+
|
|
76
|
+
const missing = [];
|
|
77
|
+
if (tomlString(config.text, "OPENAI_BASE_URL") !== expectedBaseUrl) missing.push("OPENAI_BASE_URL");
|
|
78
|
+
if (tomlString(config.text, "OPENAI_API_KEY") !== "dummy") missing.push("OPENAI_API_KEY");
|
|
79
|
+
if (tomlString(config.text, "ANTHROPIC_BASE_URL") !== expectedAnthropicBaseUrl) missing.push("ANTHROPIC_BASE_URL");
|
|
80
|
+
if (tomlString(config.text, "ANTHROPIC_AUTH_TOKEN") !== "dummy") missing.push("ANTHROPIC_AUTH_TOKEN");
|
|
81
|
+
checks.push(missing.length === 0
|
|
82
|
+
? { kind: "ok", message: "Codex shell env local API keys are configured" }
|
|
83
|
+
: { kind: "warn", message: `Codex shell env local API keys need update: ${missing.join(", ")}` });
|
|
84
|
+
return checks;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function inspectClaudeCodeConfig({ home = os.homedir(), port = 2026 } = {}) {
|
|
88
|
+
const filePath = path.join(home, ".claude", "settings.json");
|
|
89
|
+
const expectedBaseUrl = `http://127.0.0.1:${port}`;
|
|
90
|
+
const settings = readJson(filePath);
|
|
91
|
+
if (!settings.ok) {
|
|
92
|
+
const reason = settings.parseError ? `could not parse: ${settings.error.message}` : `not found at ${displayPath(home, filePath)}`;
|
|
93
|
+
return [{ kind: settings.parseError ? "err" : "warn", message: `Claude Code settings ${reason}` }];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const env = settings.json?.env || {};
|
|
97
|
+
const missing = [];
|
|
98
|
+
if (env.ANTHROPIC_BASE_URL !== expectedBaseUrl) missing.push("ANTHROPIC_BASE_URL");
|
|
99
|
+
if (env.ANTHROPIC_AUTH_TOKEN !== "dummy") missing.push("ANTHROPIC_AUTH_TOKEN");
|
|
100
|
+
return [missing.length === 0
|
|
101
|
+
? { kind: "ok", message: `Claude Code points to ${expectedBaseUrl}` }
|
|
102
|
+
: { kind: "warn", message: `Claude Code settings need update: ${missing.join(", ")}` }];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function inspectClaudeAppConfig({
|
|
106
|
+
home = os.homedir(),
|
|
107
|
+
platform = process.platform,
|
|
108
|
+
env = process.env,
|
|
109
|
+
host = "127.0.0.1",
|
|
110
|
+
port = 2026,
|
|
111
|
+
} = {}) {
|
|
112
|
+
const paths = claudeDesktopPaths(home, platform, env);
|
|
113
|
+
const expectedBaseUrl = localGatewayBaseUrl(host, port);
|
|
114
|
+
const checks = [];
|
|
115
|
+
|
|
116
|
+
const normalConfig = readJson(paths.normalConfigPath);
|
|
117
|
+
const threepConfig = readJson(paths.threepConfigPath);
|
|
118
|
+
if (normalConfig.ok && threepConfig.ok
|
|
119
|
+
&& normalConfig.json?.deploymentMode === "3p"
|
|
120
|
+
&& threepConfig.json?.deploymentMode === "3p") {
|
|
121
|
+
checks.push({ kind: "ok", message: "Claude App deployment mode is 3p" });
|
|
122
|
+
} else {
|
|
123
|
+
checks.push({ kind: "warn", message: "Claude App deployment mode is not fully configured for 3p" });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const meta = readJson(paths.metaPath);
|
|
127
|
+
const appliedId = meta.ok ? String(meta.json?.appliedId || "").trim() : "";
|
|
128
|
+
if (!appliedId) {
|
|
129
|
+
checks.push({ kind: "warn", message: "Claude App gateway profile is not configured; run with --configure-claude-desktop" });
|
|
130
|
+
return checks;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const profilePath = path.join(paths.configLibraryPath, `${appliedId}.json`);
|
|
134
|
+
const profile = readJson(profilePath);
|
|
135
|
+
if (!profile.ok) {
|
|
136
|
+
checks.push({ kind: "warn", message: `Claude App active gateway profile not found at ${displayPath(home, profilePath)}` });
|
|
137
|
+
return checks;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const p = profile.json || {};
|
|
141
|
+
const missing = [];
|
|
142
|
+
if (p.inferenceProvider !== "gateway") missing.push("inferenceProvider");
|
|
143
|
+
if (p.inferenceGatewayBaseUrl !== expectedBaseUrl) missing.push(`inferenceGatewayBaseUrl expected "${expectedBaseUrl}"`);
|
|
144
|
+
if (p.inferenceGatewayAuthScheme !== "bearer") missing.push("inferenceGatewayAuthScheme");
|
|
145
|
+
if (!String(p.inferenceGatewayApiKey || "").trim()) missing.push("inferenceGatewayApiKey");
|
|
146
|
+
if (!String(p.inferenceModels || "").trim()) missing.push("inferenceModels");
|
|
147
|
+
checks.push(missing.length === 0
|
|
148
|
+
? { kind: "ok", message: `Claude App gateway profile points to ${expectedBaseUrl}` }
|
|
149
|
+
: { kind: "warn", message: `Claude App gateway profile needs update: ${missing.join(", ")}` });
|
|
150
|
+
return checks;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function connectHost(host) {
|
|
154
|
+
const normalized = String(host || "127.0.0.1").replace(/^\[(.*)\]$/, "$1");
|
|
155
|
+
if (normalized === "0.0.0.0") return "127.0.0.1";
|
|
156
|
+
if (normalized === "::") return "::1";
|
|
157
|
+
return normalized;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function checkAdapterListening({ host = "127.0.0.1", port = 2026, timeoutMs = 500 } = {}) {
|
|
161
|
+
return new Promise((resolve) => {
|
|
162
|
+
const socket = net.createConnection({ host: connectHost(host), port });
|
|
163
|
+
const done = (ok) => {
|
|
164
|
+
socket.removeAllListeners();
|
|
165
|
+
socket.destroy();
|
|
166
|
+
resolve(ok);
|
|
167
|
+
};
|
|
168
|
+
socket.setTimeout(timeoutMs);
|
|
169
|
+
socket.once("connect", () => done(true));
|
|
170
|
+
socket.once("timeout", () => done(false));
|
|
171
|
+
socket.once("error", () => done(false));
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function collectDoctorChecks({
|
|
176
|
+
home = os.homedir(),
|
|
177
|
+
platform = process.platform,
|
|
178
|
+
env = process.env,
|
|
179
|
+
host = "127.0.0.1",
|
|
180
|
+
port = 2026,
|
|
181
|
+
checkAdapter = true,
|
|
182
|
+
checkAdapterListeningFn = checkAdapterListening,
|
|
183
|
+
} = {}) {
|
|
184
|
+
const checks = [
|
|
185
|
+
...inspectGitHubToken({ home }),
|
|
186
|
+
...inspectCodexConfig({ home, port }),
|
|
187
|
+
...inspectClaudeCodeConfig({ home, port }),
|
|
188
|
+
...inspectClaudeAppConfig({ home, platform, env, host, port }),
|
|
189
|
+
];
|
|
190
|
+
|
|
191
|
+
if (checkAdapter) {
|
|
192
|
+
const listening = await checkAdapterListeningFn({ host, port });
|
|
193
|
+
checks.push(listening
|
|
194
|
+
? { kind: "ok", message: `Adapter is listening on ${localGatewayBaseUrl(host, port)}` }
|
|
195
|
+
: { kind: "warn", message: `Adapter is not listening on ${localGatewayBaseUrl(host, port)}` });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return checks;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export async function runDoctor(options = {}) {
|
|
202
|
+
const log = options.log || console.log;
|
|
203
|
+
log("codex-copilot-dx doctor");
|
|
204
|
+
const checks = await collectDoctorChecks(options);
|
|
205
|
+
for (const check of checks) log(status(check.kind, check.message));
|
|
206
|
+
return checks;
|
|
207
|
+
}
|