codex-copilot-dx 0.7.5 → 0.7.6

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
@@ -1,6 +1,6 @@
1
1
  # codex-copilot-dx
2
2
 
3
- Use Codex App with GPT models provided by your **GitHub Copilot** subscription.
3
+ Use Codex App with GPT models available to your **GitHub Copilot** account.
4
4
 
5
5
  CCDX is a local compatibility adapter for Codex App. It configures Codex to use an OpenAI Responses API on loopback while GitHub authentication, model discovery, and inference stay backed by GitHub Copilot.
6
6
 
@@ -14,7 +14,7 @@ One in-process adapter listens on `127.0.0.1:2026` by default and exposes:
14
14
 
15
15
  Responses-only models and compaction use Copilot's Responses transport directly. For GPT models that advertise only Chat Completions, CCDX converts the supported text, image, and function-tool subset and rejects incompatible request shapes explicitly. Both streaming SSE and non-streaming responses are supported.
16
16
 
17
- The adapter preserves Codex App behavior around response history, encrypted reasoning state, function/custom tools, images, compaction, cancellation, retries, and usage metadata. Model routing follows the live Copilot catalog instead of a hard-coded GPT model list.
17
+ The adapter handles response history, encrypted reasoning state, function/custom tools, images, compaction, cancellation, retries, and usage metadata within the compatibility boundaries below. Routing prefers endpoint metadata from Copilot's live or cached catalog; when metadata is unavailable, a small built-in set identifies known Responses-only GPT models.
18
18
 
19
19
  ### Fast and Standard modes
20
20
 
@@ -24,13 +24,13 @@ Codex Auto-review uses the hidden `codex-auto-review` model ID. CCDX maps it to
24
24
 
25
25
  ### GPT-6 model catalog
26
26
 
27
- Codex App requests a versioned, capability-rich model catalog that is different from Copilot's raw model list. CCDX reads the complete catalog bundled with the installed Codex App and preserves every other model entry and capability. It makes `gpt-6-astra` visible only when the live Copilot catalog advertises that exact model as enabled, selectable, OpenAI-owned, and Responses-capable.
27
+ Codex App requests a versioned, capability-rich model catalog that is different from Copilot's raw model list. CCDX reads the complete catalog bundled with the installed Codex App and preserves every other model entry and capability. It exposes the bundled `gpt-6-astra` entry only when Copilot's available catalog advertises that exact model as enabled, selectable, OpenAI-owned, and Responses-capable.
28
28
 
29
- CCDX 0.7.2 exposes only the verified GPT-6 Standard path and removes its unverified speed-tier metadata. Codex Ultra remains available because the client maps it to the model's supported multi-agent reasoning effort. GPT-5.6 Sol Fast remains unchanged. The bundled catalog is matched to the Codex client version, cached by application binary identity, and reloaded automatically after an App update.
29
+ CCDX exposes the GPT-6 Standard path and removes its unverified speed-tier metadata. It leaves the bundled reasoning-effort options unchanged, including Ultra when supplied by the installed catalog. GPT-5.6 Sol Fast follows the separate rule above. Versioned model discovery matches the bundled catalog to the client's `client_version`, caches it by application binary identity, and reloads it after an App update. If a matching usable catalog cannot be loaded, the versioned models request returns `503`; an unversioned request returns the Copilot-style list.
30
30
 
31
31
  ## Prerequisites
32
32
 
33
- - GitHub Copilot subscription (Individual, Business, or Enterprise)
33
+ - A GitHub account with Copilot access to the desired models
34
34
  - Node.js 22.15 or newer, for built-in Zstandard request decompression
35
35
  - Codex App on macOS
36
36
 
@@ -41,7 +41,7 @@ npm install -g codex-copilot-dx@latest
41
41
  ccdx
42
42
  ```
43
43
 
44
- `ccdx` is the primary launcher. The old `codex-copilot-dx` executable remains temporarily as a compatibility shim: it prints a deprecation warning at most once every seven days in interactive terminals, runs `ccdx`, and remains silent in scripts. It will be removed in a future breaking release.
44
+ `ccdx` is the primary launcher. The old `codex-copilot-dx` executable remains temporarily as a compatibility shim: it prints a deprecation warning at most once every seven days in interactive terminals, runs `ccdx`, and remains silent in scripts. The warning timestamp is stored under `XDG_CACHE_HOME` or `~/.cache`; `doctor config` bypasses that warning cache to remain read-only. The alias will be removed in a future breaking release.
45
45
 
46
46
  For a one-off run without a global install:
47
47
 
@@ -53,15 +53,29 @@ On a normal launch, CCDX:
53
53
 
54
54
  1. Prints the installed package version and checks for a newer npm release in the background.
55
55
  2. Reuses a compatible running adapter when available; otherwise it reuses a compatible local Copilot credential or starts GitHub Device Flow when authentication is required.
56
- 3. Refreshes Copilot model metadata and listens on the configured loopback address.
57
- 4. Updates `~/.codex/config.toml` to use the local `/v1` endpoint while preserving unrelated Codex settings.
56
+ 3. Loads a usable model cache or attempts a live refresh, then starts listening. A cache older than two hours refreshes in the background; a fresh cache can be used immediately. A failed refresh retains the last-known-good list when one is available.
57
+ 4. Checks the startup-managed settings in `~/.codex/config.toml`, including the local `/v1` endpoint.
58
58
  5. Attempts to open Codex App on macOS unless `CCDX_AUTO_LAUNCH` disables it.
59
59
 
60
- The `OPENAI_API_KEY=dummy` value written into the Codex shell environment is only a client-side placeholder. It is not a GitHub credential. CCDX exchanges the saved GitHub OAuth credential for short-lived Copilot service tokens internally.
60
+ The `OPENAI_API_KEY=dummy` value used in the Codex shell environment is only a client-side placeholder. It is not a GitHub credential. CCDX exchanges the saved GitHub OAuth credential for short-lived Copilot service tokens internally.
61
61
 
62
- On startup, CCDX also adds `context_management = true` under `[features]` when the key is missing, creating the section if needed. Existing values, including `false`, are preserved. Existing inline or dotted `features` declarations and nested context-management settings are left intact to avoid conflicting TOML definitions. This enables Codex's development-stage context-management feature by default for users who have not configured it. A configuration that already matches is not rewritten.
62
+ On startup, CCDX also adds `context_management = true` under `[features]` when the key is missing, creating the section if needed. Existing values, including `false`, are preserved. Existing inline or dotted `features` declarations and nested context-management settings are left intact to avoid conflicting TOML definitions. This opts unconfigured users into Codex context management; support and behavior depend on the installed Codex version. A configuration that already matches is not rewritten.
63
63
 
64
- If a compatible adapter is already running, a later launch reuses it and refreshes the local Codex configuration. After updating the package, stop the old adapter before starting the new version; the CLI refuses to silently reuse an incompatible protocol version.
64
+ If a compatible adapter is already running, a later launch reuses it and checks the local Codex configuration. Reuse requires the same package version, protocol version, and required capabilities. After updating the package, stop the old adapter before starting the new version. Installing or updating the package alone does not restart the adapter or update Codex configuration.
65
+
66
+ ### Codex configuration writes
67
+
68
+ Normal startup and reuse of an existing adapter check `~/.codex/config.toml` once, after startup prerequisites succeed. There is no file watcher or periodic configuration rewrite. The path is based on the OS home directory; CCDX does not redirect it through `CODEX_HOME` or edit project-level Codex files.
69
+
70
+ | Setting | Existing file | New file |
71
+ | --- | --- | --- |
72
+ | Top-level `openai_base_url` | Add or update to the adapter's `/v1` address | Set to the adapter address |
73
+ | `model_context_window` | Add `1000000` only when missing | `1000000` |
74
+ | `model_auto_compact_token_limit` | Add `900000` only when missing | `900000` |
75
+ | `[shell_environment_policy.set]` OpenAI URL/key | Update `OPENAI_BASE_URL` and set `OPENAI_API_KEY="dummy"` when this section is recognized; leave an absent section absent | Create the section and set both keys; set `shell_environment_policy.inherit="core"` |
76
+ | `[features]` `context_management` | Add `true` when missing; preserve explicit values and alternative declarations as described above | `true` |
77
+
78
+ The two token limits are fixed defaults, not detected model capabilities. Existing custom values are retained. Writes target the listed keys with line-based edits and atomic replacement; this is not a general TOML formatter. The shell environment updater recognizes the literal `[shell_environment_policy.set]` header. Use `ccdx doctor config` to inspect parsed values and preview what this writer would do with the current file. No actual change means no file rewrite.
65
79
 
66
80
  ## Version 0.7.0 migration
67
81
 
@@ -91,7 +105,7 @@ Interactive terminals use aligned tables for `models`, `auth status`, and `usage
91
105
  ccdx auto-review-model
92
106
  ```
93
107
 
94
- The selector first queries the running adapter, including its last-known-good model list when live refresh is unavailable, then falls back to the non-expired local model cache. It offers only enabled models that advertise a Responses endpoint.
108
+ The selector first queries the running adapter, including its last-known-good model list when live refresh is unavailable, then falls back to a local model cache no older than seven days. It offers model IDs that advertise a Responses endpoint and are not explicitly hidden from the model picker. This selection does not itself verify policy or inference access.
95
109
 
96
110
  The selection is saved in `~/.config/codex-copilot-dx/config.json`, or under `XDG_CONFIG_HOME` when set. Choosing `gpt-5.5` clears the override and restores the package default. A running adapter reads the setting on the next Auto-review request.
97
111
 
@@ -138,13 +152,25 @@ If a configured npm mirror has not synchronized the current release yet, use the
138
152
 
139
153
  ## Diagnostics
140
154
 
141
- Run a read-only local configuration check:
155
+ Inspect only the local Codex configuration:
156
+
157
+ ```bash
158
+ ccdx doctor config
159
+ ```
160
+
161
+ This command reads `~/.codex/config.toml` and checks UTF-8/TOML syntax, correctly scoped CCDX-managed URL/environment settings, positive integer context limits, their relative ordering, and `features.context_management`. Explicit `false` is valid. A missing optional shell environment section is valid for an existing file. Structured context-management settings are retained and reported as unverified for the installed Codex version.
162
+
163
+ It also runs the existing startup writer in memory and lists the managed keys that would change. A missing file produces a creation preview. A preview that yields invalid TOML or changes unowned settings is reported as an error rather than applied. No files are written, including debug logs; it does not read credentials, contact the adapter or upstream, start Device Flow, or open Codex App. It uses `ADAPTER_HOST` and `ADAPTER_PORT` for the expected URL. Unrelated startup/probe timeout settings do not affect this config-only command.
164
+
165
+ The syntax check uses a TOML 1.1 parser and validates only the listed managed fields, not the full installed Codex schema. It does not merge project files, profiles, environment overrides, or command-line overrides into an effective Codex configuration. Syntax errors report line/column without source snippets; configured URL and key values are not printed. The command accepts no `--online`, `--compat`, or write/fix options.
166
+
167
+ For the broader credential/configuration/adapter diagnostic:
142
168
 
143
169
  ```bash
144
170
  ccdx doctor
145
171
  ```
146
172
 
147
- The doctor checks the saved GitHub credential, Codex configuration, and adapter availability without starting the adapter or changing files. It exits with status `1` for an invalid configuration and `0` when results contain only OK or warning rows.
173
+ The ordinary doctor reuses the same configuration checks, inspects the saved GitHub credential, and probes the local adapter. It does not start the adapter or modify credentials or Codex configuration; when `CCDX_LOG_PATH` is set, this broader command can write its diagnostics to that log. Both doctor modes exit `1` when any check is an error, and `0` for OK/warning-only results, including missing configuration or an explicit feature opt-out. Unsupported arguments or invalid relevant CLI options exit `2`.
148
174
 
149
175
  To verify the saved GitHub token, Copilot entitlement, and live models endpoint without starting Device Flow or changing the credential:
150
176
 
@@ -188,19 +214,21 @@ When the saved token is missing, `ccdx` looks for compatible local Copilot GitHu
188
214
 
189
215
  After an account is selected, automatic `401`/`403` recovery and in-process token rotation accept only that same GitHub account. Concurrent callers share token refresh work without sharing cancellation, and a still-valid Copilot token may be used briefly after a transient refresh failure. Explicit token variables remain the intentional way to select another account.
190
216
 
191
- If refresh still fails with `401` or `403`, the saved GitHub OAuth credential may be expired, revoked, or missing Copilot access. Remove that exact saved token and start again to trigger Device Flow:
217
+ If refresh still fails with `401` or `403`, the saved GitHub OAuth credential may be expired, revoked, or missing Copilot access. After stopping the adapter, remove that exact saved token and start again to rerun authentication recovery:
192
218
 
193
219
  ```bash
194
220
  rm ~/.local/share/copilot-api/github_token
195
221
  ccdx
196
222
  ```
197
223
 
224
+ Recovery first tries compatible local credentials and starts Device Flow only when none can be imported. Set `CCDX_DISABLE_TOKEN_DISCOVERY=1` for that launch if a fresh Device Flow is intended after removing the saved token.
225
+
198
226
  ## Security
199
227
 
200
228
  - CCDX binds to loopback by default. Non-loopback hosts are rejected unless `CCDX_ALLOW_LAN=1` is set explicitly.
201
229
  - Enabling LAN binding exposes a Copilot-backed API to other reachable machines. Provide your own host firewall and network isolation; do not expose the listener to an untrusted network.
202
230
  - The GitHub OAuth credential is stored locally at `~/.local/share/copilot-api/github_token`. Short-lived Copilot service tokens remain in process memory and are refreshed as needed.
203
- - Runtime status, usage, and debug logs exclude authorization headers, prompts, completions, tool arguments, and image content.
231
+ - Runtime status and usage records retain metadata rather than request/response content. Diagnostic logging records status and error messages; it is not a general redaction layer for arbitrary error text. See Debug logging before sharing logs.
204
232
  - Request and response bodies are bounded. Queues, history, image work, SSE parsing, retries, and shutdown all have explicit resource or time limits.
205
233
 
206
234
  ## Configuration
@@ -232,9 +260,9 @@ ccdx
232
260
  | `CCDX_TERMINAL_ANIMATION` | auto | Show the selected transient animation while an interactive terminal has active requests and no output for 800 ms; set to `0`, `false`, `no`, or `off` to disable |
233
261
  | `CCDX_IMG_MAX_DIM` | `2048` | Maximum long edge in pixels for image downscaling |
234
262
  | `CCDX_IMG_QUALITY` | `82` | Initial WebP quality for image re-encoding |
235
- | `CCDX_IMG_MIN_BYTES` | `100000` | In-bounds images smaller than this remain unchanged; oversized images are still downscaled |
263
+ | `CCDX_IMG_MIN_BYTES` | `100000` | In-bounds images smaller than this skip re-encoding; oversized dimensions still trigger an optimization attempt |
236
264
  | `CCDX_IMG_CONCURRENCY` | `2` | Global concurrent image-optimization tasks; values above `12` are capped |
237
- | `CCDX_IMG_MAX_INPUT_PIXELS` | `40000000` | Maximum decoded pixels accepted for one image |
265
+ | `CCDX_IMG_MAX_INPUT_PIXELS` | `40000000` | Sharp's per-image decode limit; an optimization failure retains the original image, subject to the overall forwarded-body limit |
238
266
  | `CCDX_IMG_CACHE_MAX_BYTES` | `67108864` | Process-local byte ceiling for cached image transforms; set to `0` to disable the cache |
239
267
  | `CCDX_DISABLE_IMG_OPT` | unset | Set to `1` to disable image optimization |
240
268
  | `CCDX_AUTO_LAUNCH` | enabled | Set to `0`, `false`, `no`, or `off` to start without opening Codex App |
@@ -244,10 +272,10 @@ ccdx
244
272
  | `CCDX_GITHUB_LOGIN` | saved account | Require automatic discovery and recovery to use this GitHub login |
245
273
  | `CCDX_DISABLE_TOKEN_DISCOVERY` | unset | Set to `1` to skip local token discovery and use the saved token or Device Flow |
246
274
  | `CCDX_TOKEN_LOCK_TIMEOUT_MS` | `600000` | Maximum time to wait for another process to finish GitHub token login or import |
247
- | `CCDX_TOKEN_LOCK_STALE_MS` | `900000` | Age after which a stale GitHub-token lock can be removed |
275
+ | `CCDX_TOKEN_LOCK_STALE_MS` | `900000` | Minimum age for stale-lock recovery; a lock with a live owner is not removed |
248
276
  | `CCDX_EXISTING_ADAPTER_TIMEOUT_MS` | `500` | Timeout for detecting an already-running local adapter during startup |
249
277
  | `CCDX_MODEL_REFRESH_TIMEOUT_MS` | `5000` | Timeout for Copilot model refresh or live Auto-review model lookup |
250
- | `CCDX_MODEL_REFRESH_INTERVAL_MS` | `7200000` | Model refresh interval; successful lists are cached as last-known-good data |
278
+ | `CCDX_MODEL_REFRESH_INTERVAL_MS` | `7200000` | Periodic model refresh interval; `0` disables periodic refresh, while startup cache/refresh behavior still applies |
251
279
  | `CCDX_RESPONSE_HISTORY_MAX_BYTES` | `67108864` | Total in-memory byte budget for locally expanded Responses history |
252
280
  | `CCDX_RESPONSE_HISTORY_MAX_ENTRIES` | `4096` | Maximum stored incremental Responses history nodes |
253
281
  | `CCDX_USAGE_PATH` | `~/.local/share/codex-copilot-dx/usage.jsonl` | Local JSONL token-usage log |
@@ -267,27 +295,31 @@ The summary covers the current log and its single rotated backup, not an unbound
267
295
 
268
296
  ## Debug logging
269
297
 
270
- Set `CCDX_LOG_PATH=1` to mirror terminal logs to `~/.local/share/codex-copilot-dx/debug.log`, or provide a custom path. Add `CCDX_LOG_LEVEL=debug` for upstream attempts, retry causes, status codes, and timings. Debug logs do not include prompts, completions, request bodies, or authorization tokens.
298
+ Set `CCDX_LOG_PATH=1` to mirror terminal logs to `~/.local/share/codex-copilot-dx/debug.log`, or provide a custom path. Add `CCDX_LOG_LEVEL=debug` for upstream attempts, retry causes, status codes, and timings. The adapter does not deliberately dump request/response bodies or authorization headers. The log mirror writes console text verbatim, including diagnostic/error messages that can originate in external components, so review logs before sharing them. `ccdx doctor config` bypasses file logging entirely.
271
299
 
272
300
  ## Large histories and images
273
301
 
274
302
  Long computer-use sessions can accumulate screenshots that are sent again on later turns. This can increase local preparation time, upstream handshake latency, and the chance of a `413 Payload Too Large` response.
275
303
 
276
- CCDX automatically downsamples embedded screenshots to model-appropriate pixel bounds, with a conservative 2048 px fallback, and initially encodes them as WebP quality 82. It never replaces an image with a larger encoding and uses one global concurrency limit across direct images and function/custom-tool outputs.
304
+ The response-history cache is process-local and byte/entry bounded. Restarting the adapter discards it; a `previous_response_id` stored only in that process becomes unavailable. CCDX does not persist conversation history to disk. Clients can still submit explicit history in the request body, subject to the same limits.
305
+
306
+ CCDX attempts to downsample inline base64 images to model-appropriate pixel bounds, with a 2048 px long-edge fallback, and initially encodes them as WebP quality 82. It never replaces an image with a larger encoding and uses one global concurrency limit across direct images and function/custom-tool outputs. GIFs and non-inline references are not re-encoded. An encoding/decode failure keeps the original image; the final request-byte limit still applies.
277
307
 
278
308
  The final serialized UTF-8 body is measured before forwarding. Above the configured limit, unique source images are processed largest-first from their originals at quality 75 / 1600 px and then quality 65 / 1280 px, stopping when the body fits. If necessary, the forwarded view omits older duplicate images, historical tool images, other historical images, and finally old tool outputs while preserving current input and tool-call skeletons. Requests that still do not fit receive a structured local `413`.
279
309
 
280
310
  Completed image transforms are reused from a byte-bounded process-local LRU cache, and concurrent requests for the same transform share one encode. Failures are not cached and request cancellation remains isolated.
281
311
 
282
- Ordinary visual history stays byte-for-byte unchanged while it has at most 24 historical images and the expanded body is at most 18 MiB. Above either threshold, the temporary upstream view keeps every current-turn image and up to 16 recent historical images, targeting 16 MiB. After a preparation, handshake, upstream timeout, or HTTP `408`, the next retry uses an 8-image, 10 MiB recovery window for ten minutes. Two successful requests or a successful compaction clear recovery mode. CCDX never transparently repeats an ambiguous timed-out POST, and it does not delete local history.
312
+ The visual-history pressure pass does not omit images while there are at most 24 historical images and the expanded body is at most 18 MiB; normal image encoding and other limits still apply. Above either threshold, this pass preserves current-turn images and keeps up to 16 recent historical images, targeting 16 MiB. The separate 50-image and total-body limits remain in force. After an eligible large-visual-history preparation, handshake, upstream timeout, or HTTP `408`, the next retry uses an 8-image, 10 MiB recovery window for ten minutes. Two successful requests or a successful compaction clear recovery mode. CCDX never transparently repeats an ambiguous timed-out POST, and it does not delete local history.
283
313
 
284
314
  When expanded history exceeds Copilot's 50-image request limit, CCDX removes older duplicate occurrences first and then keeps the 50 most recent images. This affects only the forwarded request; local history remains complete and current images are preferred.
285
315
 
286
316
  For `/v1/responses/compact`, CCDX sends one terminal `compaction_trigger`, validates completed compaction state, and preserves the complete returned window as the canonical next context. Only a validated window becomes a new local history root. Existing older branches remain available until normal history eviction.
287
317
 
288
- Successful streaming responses are accepted only when the upstream body is SSE and reaches its protocol terminal event. Unexpected EOF becomes a protocol-native stream error, and repeated blank tool-argument deltas are stopped per tool call. Safe quota, retry, model, trace, and upstream request-ID metadata are forwarded; cookies, authorization, body-length, and encoding headers are not.
318
+ Changing the model or transport of a continuation can require removing incompatible encrypted history from the forwarded request. A specific upstream encrypted-state verification failure can also trigger one sanitized retry. CCDX does not decrypt that state locally.
319
+
320
+ Successful streaming responses are accepted only when the upstream body is SSE and reaches its protocol terminal event. Unexpected EOF becomes a protocol-native stream error, and repeated blank tool-argument deltas are stopped per tool call. Allowlisted rate-limit, retry, model, trace, and upstream request-ID metadata are forwarded; cookies, authorization, body-length, and encoding headers are not.
289
321
 
290
- CCDX 0.7.3 keeps each native Responses message on its first upstream ID when Copilot changes that ID during streaming. Message deltas, completion events, and cached history stay consistent, preventing duplicate live messages in Codex App. Tool identities, reasoning/encrypted content, message phases, and speed-tier routing are unchanged. Stable events retain their original SSE bytes; changed events are rewritten with bounded, backpressured streaming.
322
+ CCDX keeps each native Responses message on its first upstream ID when Copilot changes that ID during streaming. Message deltas, completion events, and cached history stay consistent, addressing this cause of duplicate live messages in Codex App. Tool identities, reasoning/encrypted content, message phases, and speed-tier routing are unchanged by this ID normalization. Stable events retain their original SSE bytes; changed events are rewritten with bounded, backpressured streaming.
291
323
 
292
324
  Newer Codex App builds can advertise an `image_gen` namespace that already exists upstream. CCDX removes that exact conflicting client tool before forwarding and retries once only when Copilot explicitly reports an image namespace collision. Image input and screenshot optimization remain enabled.
293
325
 
@@ -301,7 +333,9 @@ npm run bench:payload
301
333
 
302
334
  `bench:check` covers both SSE parsers, including native Responses identity rewriting, 1/4/8 MiB fragmented events, write batching, and slow-client backpressure. It also repeats four concurrent image preparations across six rounds using a desktop-sized synthetic screenshot and a public-domain photograph. Checks enforce content preservation, single-pass JSON parsing, bounded copying, cache reuse, settled work, and bounded retained heap/ArrayBuffers after warm-up and GC. Sampled RSS and absolute timings remain report-only because allocators, runtimes, and machines differ; the fixtures do not establish production latency or visual model accuracy.
303
335
 
304
- `npm test` runs unit and handler-level tests. `npm run test:smoke` starts a real local adapter with fully injected offline upstreams. `npm run bench:check` enforces linear SSE scanning, image/tool processing, and request-admission resource limits. `npm run pack:check` verifies npm tarball contents without publishing. `npm run bench:payload` is a report-only isolated-process benchmark for 5–60 MiB image payloads and does not contact Copilot. CI runs verification on the supported Node.js release lines.
336
+ `npm test` runs unit, handler, CLI, and documentation-contract tests. `npm run test:smoke` starts a real local adapter with fully injected offline upstreams. `npm run bench:check` enforces linear SSE scanning, image/tool processing, and request-admission resource limits. `npm run pack:check` verifies npm tarball contents without publishing. `npm run bench:payload` is a report-only isolated-process benchmark for 5–60 MiB synthetic padded-image payloads and does not contact Copilot. CI runs the complete `verify` gate on Node 22.15.0 and 24.x.
337
+
338
+ The [documentation index](https://github.com/DaleXiao/codex-copilot-dx/blob/main/docs/README.md) distinguishes current guidance from historical release evidence. Release reports retain the measurements and limits of their specific versions; they do not establish current live-provider compatibility.
305
339
 
306
340
  From a source checkout, `node scripts/codex-message-id-replay.mjs --codex /absolute/path/to/codex` checks message identity against an installed Codex runtime using only synthetic SSE, a local mock server, and automatically cleaned temporary Codex directories. It does not use the real client configuration or call Copilot.
307
341
 
package/bin/cli.mjs CHANGED
@@ -12,7 +12,7 @@ import { runDoctor } from "../src/doctor.mjs";
12
12
  import { adapterBaseUrl, checkRunningAdapter } from "../src/running-adapter.mjs";
13
13
  import { assertSafeAdapterHost, isLoopbackHost } from "../src/security.mjs";
14
14
  import { runInBackground } from "../src/startup.mjs";
15
- import { cliCommandName, cliHelp, parseAdapterProbeOptions, parseCliArgs, parseRuntimeOptions } from "../src/cli-options.mjs";
15
+ import { cliCommandName, cliHelp, parseAdapterAddressOptions, parseAdapterProbeOptions, parseCliArgs, parseRuntimeOptions } from "../src/cli-options.mjs";
16
16
  import { formatAdapterStatus, readAdapterStatus } from "../src/cli-status.mjs";
17
17
  import { closeHttpServer } from "../src/shutdown.mjs";
18
18
  import { runAutoReviewModelCommand } from "../src/auto-review-model.mjs";
@@ -143,6 +143,23 @@ if (CLI.command === "update") {
143
143
  process.exit(1);
144
144
  }
145
145
  }
146
+ if (CLI.command === "doctor" && CLI.configOnly) {
147
+ let probe;
148
+ try {
149
+ probe = parseAdapterAddressOptions(process.env);
150
+ } catch (e) {
151
+ console.error(e.message);
152
+ process.exit(2);
153
+ }
154
+ const checks = await runDoctor({
155
+ commandName: CLI_NAME,
156
+ configOnly: true,
157
+ host: probe.adapterHost,
158
+ port: probe.adapterPort,
159
+ });
160
+ process.exit(checks.some((check) => check.kind === "err") ? 1 : 0);
161
+ }
162
+
146
163
  let RUNTIME;
147
164
  try {
148
165
  RUNTIME = parseRuntimeOptions(process.env);
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- if (process.stderr.isTTY === true) {
3
+ const configDoctor = ["doctor", "--doctor"].includes(process.argv[2]) && process.argv[3] === "config";
4
+ if (process.stderr.isTTY === true && !configDoctor) {
4
5
  const {
5
6
  LEGACY_COMMAND_WARNING,
6
7
  shouldShowLegacyCommandWarning,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.7.5",
3
+ "version": "0.7.6",
4
4
  "description": "Use Codex App with GitHub Copilot",
5
5
  "author": "Dale Xiao",
6
6
  "type": "module",
@@ -48,6 +48,7 @@
48
48
  "prepublishOnly": "npm run verify"
49
49
  },
50
50
  "dependencies": {
51
- "sharp": "^0.35.3"
51
+ "sharp": "^0.35.3",
52
+ "smol-toml": "1.8.0"
52
53
  }
53
54
  }
@@ -16,6 +16,7 @@ const HELP_TOPICS = new Set([
16
16
  "auth",
17
17
  "auth status",
18
18
  "doctor",
19
+ "doctor config",
19
20
  "status",
20
21
  "models",
21
22
  "usage",
@@ -126,11 +127,16 @@ export function parseCliArgs(args = []) {
126
127
  || (command === "start" && rest.some((option) => RETIRED_CLAUDE_OPTIONS.has(option)))
127
128
  || (command === "auth" && rest[0] === "login" && rest[1] === "claude")
128
129
  || (command === "models" && requestedProfile === "claude")
129
- || (command === "doctor" && ["claude", "all"].includes(requestedProfile));
130
+ || (command === "doctor" && rest[0] !== "config" && ["claude", "all"].includes(requestedProfile));
130
131
  if (PM_COMMANDS.has(command)) return baseCommand("retired", { integration: "PM Studio" });
131
132
  if (retiredClaudeInvocation) {
132
133
  return baseCommand("retired", { integration: "Claude App and Claude Code" });
133
134
  }
135
+ if ((command === "doctor" || command === "--doctor") && rest[0] === "config") {
136
+ if (rest.length === 2 && HELP_COMMANDS.has(rest[1])) return helpCommand(["doctor", "config"]);
137
+ if (rest.length > 1) unexpectedArgs(rest.slice(1));
138
+ return baseCommand("doctor", { configOnly: true });
139
+ }
134
140
  const requestedHelp = nestedHelp(command, rest);
135
141
  if (requestedHelp) return requestedHelp;
136
142
  if (VERSION_COMMANDS.has(command)) {
@@ -245,10 +251,16 @@ function integerEnv(env, name, fallback, { min, max = Number.MAX_SAFE_INTEGER })
245
251
  return value;
246
252
  }
247
253
 
248
- export function parseRuntimeOptions(env = process.env) {
254
+ export function parseAdapterAddressOptions(env = process.env) {
249
255
  return {
250
256
  adapterPort: integerEnv(env, "ADAPTER_PORT", 2026, { min: 1, max: 65535 }),
251
257
  adapterHost: String(env.ADAPTER_HOST || "127.0.0.1").trim() || "127.0.0.1",
258
+ };
259
+ }
260
+
261
+ export function parseRuntimeOptions(env = process.env) {
262
+ return {
263
+ ...parseAdapterAddressOptions(env),
252
264
  modelRefreshTimeoutMs: integerEnv(env, "CCDX_MODEL_REFRESH_TIMEOUT_MS", 5000, { min: 1, max: MAX_TIMER_DELAY_MS }),
253
265
  existingAdapterTimeoutMs: integerEnv(env, "CCDX_EXISTING_ADAPTER_TIMEOUT_MS", 500, { min: 1, max: MAX_TIMER_DELAY_MS }),
254
266
  modelRefreshIntervalMs: integerEnv(env, "CCDX_MODEL_REFRESH_INTERVAL_MS", 2 * 60 * 60 * 1000, { min: 0, max: MAX_TIMER_DELAY_MS }),
@@ -261,8 +273,7 @@ export function parseRuntimeOptions(env = process.env) {
261
273
 
262
274
  export function parseAdapterProbeOptions(env = process.env) {
263
275
  return {
264
- adapterPort: integerEnv(env, "ADAPTER_PORT", 2026, { min: 1, max: 65535 }),
265
- adapterHost: String(env.ADAPTER_HOST || "127.0.0.1").trim() || "127.0.0.1",
276
+ ...parseAdapterAddressOptions(env),
266
277
  existingAdapterTimeoutMs: integerEnv(env, "CCDX_EXISTING_ADAPTER_TIMEOUT_MS", 500, { min: 1, max: MAX_TIMER_DELAY_MS }),
267
278
  };
268
279
  }
@@ -276,12 +287,13 @@ function topicHelp(name, topic) {
276
287
  start: `Usage:\n ${name} [start] [--show-request-id]\n\nStarts or reuses the local adapter, updates Codex configuration, and attempts to open Codex App when auto-launch is enabled and supported.`,
277
288
  auth: `Usage:\n ${name} auth status [--online] [--format table|plain]\n\nShows the saved GitHub Copilot account without exposing credentials. --online also verifies its entitlement and model catalog.`,
278
289
  "auth status": `Usage:\n ${name} auth status [--online] [--format table|plain]\n\nShows the saved GitHub Copilot account without exposing credentials. --online also verifies its entitlement and model catalog. Interactive terminals use a table by default.`,
279
- doctor: `Usage:\n ${name} doctor [--online] [--compat]\n\nChecks the local credential, Codex configuration, and adapter. --online validates account entitlement; --compat sends minimal inference requests and consumes a small amount of Copilot usage.`,
290
+ doctor: `Usage:\n ${name} doctor [--online] [--compat]\n ${name} doctor config\n\nChecks the local credential, Codex configuration, and adapter. --online validates account entitlement; --compat sends minimal inference requests and consumes a small amount of Copilot usage. The config subcommand checks only the local file and previews startup changes, without network, authentication, or file writes.`,
291
+ "doctor config": `Usage:\n ${name} doctor config\n\nReads ~/.codex/config.toml, checks TOML syntax and CCDX-managed settings, and previews the existing startup writer's changes. Does not merge Codex profiles or validate the complete installed Codex schema. No authentication, network requests, file writes, or adapter/GUI startup. Existing context_management=false is valid. Exit 1 for errors, 0 for OK/warnings; unsupported arguments exit 2.`,
280
292
  status: `Usage:\n ${name} status\n\nShows bounded runtime, routing, performance, queue, and cache metrics from a running adapter.`,
281
293
  models: `Usage:\n ${name} models [--format table|plain]\n\nPerforms a fresh, read-only Copilot model-directory lookup for the saved account. Interactive terminals use a table by default.`,
282
294
  usage: `Usage:\n ${name} usage [--format table|plain]\n\nSummarizes local token usage metadata without reading prompt or completion content. Interactive terminals use a table by default.`,
283
295
  animation: `Usage:\n ${name} animation\n\nInteractively selects the terminal activity animation used the next time the adapter starts.`,
284
- "auto-review-model": `Usage:\n ${name} auto-review-model\n\nInteractively selects an enabled Responses model for Codex Auto-review.`,
296
+ "auto-review-model": `Usage:\n ${name} auto-review-model\n\nInteractively selects an advertised Responses model for Codex Auto-review.`,
285
297
  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.`,
286
298
  version: `Usage:\n ${name} --version`,
287
299
  };
@@ -296,6 +308,7 @@ export function cliHelp(commandName = "ccdx", topic = "") {
296
308
  ${name} [start] [--show-request-id]
297
309
  ${name} auth status [--online] [--format table|plain]
298
310
  ${name} doctor [--online] [--compat]
311
+ ${name} doctor config
299
312
  ${name} status
300
313
  ${name} models [--format table|plain]
301
314
  ${name} usage [--format table|plain]
@@ -0,0 +1,162 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { isDeepStrictEqual } from "node:util";
5
+ import { parse } from "smol-toml";
6
+ import { computeUpdatedCodexConfig, initialCodexConfig } from "./config.mjs";
7
+ import { adapterBaseUrl } from "./running-adapter.mjs";
8
+
9
+ const MANAGED_KEYS = [
10
+ "openai_base_url",
11
+ "model_context_window",
12
+ "model_auto_compact_token_limit",
13
+ "shell_environment_policy.inherit",
14
+ "shell_environment_policy.set.OPENAI_BASE_URL",
15
+ "shell_environment_policy.set.OPENAI_API_KEY",
16
+ "features.context_management",
17
+ ];
18
+
19
+ function field(config, key) {
20
+ return key.split(".").reduce((value, part) => (
21
+ value && Object.hasOwn(value, part) ? value[part] : undefined
22
+ ), config);
23
+ }
24
+
25
+ function table(value) {
26
+ return value !== null && typeof value === "object" && !Array.isArray(value) && !(value instanceof Date);
27
+ }
28
+
29
+ function parseConfig(text) {
30
+ // Keep integer and floating-point TOML values distinct, including large ints.
31
+ return parse(text, { integersAsBigInt: true });
32
+ }
33
+
34
+ function unmanagedSettings(config) {
35
+ const remaining = structuredClone(config);
36
+ for (const key of MANAGED_KEYS) {
37
+ const parts = key.split(".");
38
+ const leaf = parts.pop();
39
+ const parent = parts.length ? field(remaining, parts.join(".")) : remaining;
40
+ if (table(parent)) delete parent[leaf];
41
+ }
42
+ for (const key of ["shell_environment_policy.set", "shell_environment_policy", "features"]) {
43
+ const value = field(remaining, key);
44
+ if (!table(value) || Object.keys(value).length) continue;
45
+ const parts = key.split(".");
46
+ const leaf = parts.pop();
47
+ const parent = parts.length ? field(remaining, parts.join(".")) : remaining;
48
+ delete parent[leaf];
49
+ }
50
+ return remaining;
51
+ }
52
+
53
+ function startupPreview(content, config, host, port) {
54
+ const updated = content === null
55
+ ? { content: initialCodexConfig(port, host), changed: true }
56
+ : computeUpdatedCodexConfig(content, port, host);
57
+ if (!updated.changed) return { kind: "ok", message: "No startup configuration changes required" };
58
+ let next;
59
+ try {
60
+ next = parseConfig(updated.content);
61
+ } catch {
62
+ return { kind: "err", message: "Startup configuration preview is not valid TOML; review the file before starting ccdx" };
63
+ }
64
+ if (!isDeepStrictEqual(unmanagedSettings(config), unmanagedSettings(next))) {
65
+ return { kind: "err", message: "Startup preview would change settings outside CCDX's managed keys; review the file before starting ccdx" };
66
+ }
67
+ const changed = MANAGED_KEYS.filter((key) => !isDeepStrictEqual(field(config, key), field(next, key)));
68
+ return {
69
+ kind: "warn",
70
+ message: changed.length
71
+ ? `Next startup would ${content === null ? "create the file and set" : "set"}: ${changed.join(", ")}`
72
+ : "Next startup would rewrite the file; managed key values are unchanged",
73
+ };
74
+ }
75
+
76
+ function checkUrl(value, expected, label) {
77
+ if (value === undefined) return { kind: "warn", message: `${label} is missing` };
78
+ if (typeof value !== "string") return { kind: "err", message: `${label} must be a string` };
79
+ // Do not echo configured URLs: userinfo and query strings can contain secrets.
80
+ return value === expected
81
+ ? { kind: "ok", message: `${label} points to ${expected}` }
82
+ : { kind: "warn", message: `${label} differs from the CCDX address ${expected}` };
83
+ }
84
+
85
+ export function inspectCodexConfig({ home = os.homedir(), host = "127.0.0.1", port = 2026 } = {}) {
86
+ const filePath = path.join(home, ".codex", "config.toml");
87
+ let content;
88
+ try {
89
+ content = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(fs.readFileSync(filePath));
90
+ } catch (error) {
91
+ if (error?.code === "ENOENT") {
92
+ return [
93
+ { kind: "warn", message: `Codex config not found at ${filePath}` },
94
+ startupPreview(null, {}, host, port),
95
+ ];
96
+ }
97
+ return [{ kind: "err", message: `Cannot read UTF-8 Codex config at ${filePath}` }];
98
+ }
99
+
100
+ let config;
101
+ try {
102
+ config = parseConfig(content);
103
+ } catch (error) {
104
+ const location = Number.isInteger(error?.line) && Number.isInteger(error?.column)
105
+ ? ` at line ${error.line}, column ${error.column}`
106
+ : "";
107
+ // Parser messages include source snippets. Report only numeric locations.
108
+ return [{ kind: "err", message: `Invalid TOML in ${filePath}${location}` }];
109
+ }
110
+
111
+ const expectedBaseUrl = `${adapterBaseUrl(host, port)}/v1`;
112
+ const checks = [
113
+ { kind: "ok", message: `Codex config is readable TOML: ${filePath}` },
114
+ checkUrl(config.openai_base_url, expectedBaseUrl, "Codex base URL"),
115
+ ];
116
+ for (const key of ["model_context_window", "model_auto_compact_token_limit"]) {
117
+ const value = field(config, key);
118
+ checks.push(value === undefined
119
+ ? { kind: "warn", message: `${key} is missing` }
120
+ : typeof value === "bigint" && value > 0n
121
+ ? { kind: "ok", message: `${key} = ${value} (existing value retained)` }
122
+ : { kind: "err", message: `${key} must be a positive TOML integer` });
123
+ }
124
+ if (typeof config.model_context_window === "bigint" && config.model_context_window > 0n
125
+ && typeof config.model_auto_compact_token_limit === "bigint" && config.model_auto_compact_token_limit > 0n
126
+ && config.model_auto_compact_token_limit >= config.model_context_window) {
127
+ checks.push({ kind: "warn", message: "model_auto_compact_token_limit is not below model_context_window; review the custom limits" });
128
+ }
129
+
130
+ const policy = field(config, "shell_environment_policy");
131
+ const env = field(config, "shell_environment_policy.set");
132
+ if ((policy !== undefined && !table(policy)) || (env !== undefined && !table(env))) {
133
+ checks.push({ kind: "err", message: "shell_environment_policy and its set value must be TOML tables" });
134
+ } else if (env === undefined) {
135
+ checks.push({ kind: "ok", message: "Optional shell_environment_policy.set table is absent; existing files do not require it" });
136
+ } else {
137
+ checks.push(checkUrl(env.OPENAI_BASE_URL, expectedBaseUrl, "Codex shell OPENAI_BASE_URL"));
138
+ checks.push(env.OPENAI_API_KEY === undefined
139
+ ? { kind: "warn", message: "Codex shell OPENAI_API_KEY placeholder is missing" }
140
+ : typeof env.OPENAI_API_KEY !== "string"
141
+ ? { kind: "err", message: "Codex shell OPENAI_API_KEY must be a string (value hidden)" }
142
+ : env.OPENAI_API_KEY === "dummy"
143
+ ? { kind: "ok", message: "Codex shell OPENAI_API_KEY uses the dummy placeholder" }
144
+ : { kind: "warn", message: "Codex shell OPENAI_API_KEY differs from the dummy placeholder (value hidden)" });
145
+ }
146
+
147
+ const features = field(config, "features");
148
+ const management = field(config, "features.context_management");
149
+ if (features !== undefined && !table(features)) {
150
+ checks.push({ kind: "err", message: "features must be a TOML table" });
151
+ } else if (management === undefined) {
152
+ checks.push({ kind: "warn", message: "features.context_management is missing; see the startup preview for whether it will be added" });
153
+ } else if (typeof management === "boolean") {
154
+ checks.push({ kind: "ok", message: `features.context_management = ${management} (${management ? "enabled" : "explicitly disabled"}; retained)` });
155
+ } else if (table(management)) {
156
+ checks.push({ kind: "warn", message: "Structured context-management settings are retained; installed Codex support is not verified" });
157
+ } else {
158
+ checks.push({ kind: "err", message: "features.context_management must be a boolean for the basic feature flag" });
159
+ }
160
+ checks.push(startupPreview(content, config, host, port));
161
+ return checks;
162
+ }
package/src/config.mjs CHANGED
@@ -135,7 +135,7 @@ export function computeUpdatedCodexConfig(content, adapterPort = 2026, adapterHo
135
135
  return { content: lines.join("\n") + (hadTrailingNewline ? "\n" : ""), changed };
136
136
  }
137
137
 
138
- function initialCodexConfig(adapterPort, adapterHost) {
138
+ export function initialCodexConfig(adapterPort, adapterHost) {
139
139
  const baseUrl = `${adapterBaseUrl(adapterHost, adapterPort)}/v1`;
140
140
  return `openai_base_url = "${baseUrl}"
141
141
  model_context_window = ${MODEL_CONTEXT_WINDOW}
package/src/doctor.mjs CHANGED
@@ -11,6 +11,9 @@ import { status } from "./status.mjs";
11
11
  import { buildHeaders, FALLBACK_VSCODE_VERSION, parseApiBase } from "./copilot.mjs";
12
12
  import { adapterBaseUrl, checkRunningAdapter } from "./running-adapter.mjs";
13
13
  import { CODEX_AUTO_REVIEW_MODEL } from "./models.mjs";
14
+ import { inspectCodexConfig } from "./codex-config-doctor.mjs";
15
+
16
+ export { inspectCodexConfig };
14
17
 
15
18
  function localGatewayBaseUrl(host, port) {
16
19
  const safeHost = String(host || "127.0.0.1");
@@ -26,22 +29,12 @@ function readText(filePath) {
26
29
  }
27
30
  }
28
31
 
29
- function tomlString(content, key) {
30
- const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
31
- const match = new RegExp(`^\\s*${escaped}\\s*=\\s*"([^"]*)"`, "m").exec(content);
32
- return match?.[1] || "";
33
- }
34
-
35
32
  function displayPath(home, filePath) {
36
33
  const rel = path.relative(home, filePath);
37
34
  if (!rel.startsWith("..") && !path.isAbsolute(rel)) return `~/${rel}`;
38
35
  return filePath;
39
36
  }
40
37
 
41
- function valueLabel(value) {
42
- return value ? `"${value}"` : "missing";
43
- }
44
-
45
38
  function copilotModelData(models) {
46
39
  const data = Array.isArray(models) ? models : models?.data;
47
40
  return Array.isArray(data) ? data : [];
@@ -282,29 +275,6 @@ export async function inspectAuthProfilesOnline(options = {}) {
282
275
  return inspectGitHubTokenOnline(options);
283
276
  }
284
277
 
285
- export function inspectCodexConfig({ home = os.homedir(), host = "127.0.0.1", port = 2026 } = {}) {
286
- const filePath = path.join(home, ".codex", "config.toml");
287
- const expectedBaseUrl = `${adapterBaseUrl(host, port)}/v1`;
288
- const config = readText(filePath);
289
- if (!config.ok) {
290
- return [{ kind: "warn", message: `Codex config not found at ${displayPath(home, filePath)}` }];
291
- }
292
-
293
- const checks = [];
294
- const baseUrl = tomlString(config.text, "openai_base_url");
295
- checks.push(baseUrl === expectedBaseUrl
296
- ? { kind: "ok", message: `Codex base URL points to ${expectedBaseUrl}` }
297
- : { kind: "warn", message: `Codex base URL is ${valueLabel(baseUrl)}; expected "${expectedBaseUrl}"` });
298
-
299
- const missing = [];
300
- if (tomlString(config.text, "OPENAI_BASE_URL") !== expectedBaseUrl) missing.push("OPENAI_BASE_URL");
301
- if (tomlString(config.text, "OPENAI_API_KEY") !== "dummy") missing.push("OPENAI_API_KEY");
302
- checks.push(missing.length === 0
303
- ? { kind: "ok", message: "Codex shell env local API keys are configured" }
304
- : { kind: "warn", message: `Codex shell env local API keys need update: ${missing.join(", ")}` });
305
- return checks;
306
- }
307
-
308
278
  function connectHost(host) {
309
279
  const normalized = String(host || "127.0.0.1").replace(/^\[(.*)\]$/, "$1");
310
280
  if (normalized === "0.0.0.0") return "127.0.0.1";
@@ -332,6 +302,7 @@ export async function collectDoctorChecks({
332
302
  host = "127.0.0.1",
333
303
  port = 2026,
334
304
  checkAdapter = true,
305
+ configOnly = false,
335
306
  checkAdapterListeningFn = checkAdapterListening,
336
307
  checkRunningAdapterFn = checkRunningAdapter,
337
308
  online = false,
@@ -342,6 +313,7 @@ export async function collectDoctorChecks({
342
313
  compatTimeoutMs = 120000,
343
314
  inspectAdapterCompatibilityFn = inspectAdapterCompatibility,
344
315
  } = {}) {
316
+ if (configOnly) return inspectCodexConfig({ home, host, port });
345
317
  checkedDoctorProfile(profile);
346
318
  const checks = [
347
319
  ...inspectAuthProfiles({ home }),
@@ -406,7 +378,7 @@ export async function runDoctor(options = {}) {
406
378
  options.online ? "--online" : "",
407
379
  options.compat ? "--compat" : "",
408
380
  ].filter(Boolean);
409
- log(`${options.commandName || "ccdx"} doctor${flags.length ? ` ${flags.join(" ")}` : ""}`);
381
+ log(`${options.commandName || "ccdx"} doctor${options.configOnly ? " config" : flags.length ? ` ${flags.join(" ")}` : ""}`);
410
382
  const checks = await collectDoctorChecks(options);
411
383
  for (const check of checks) log(status(check.kind, check.message));
412
384
  const totals = { ok: 0, warn: 0, err: 0 };