From e254fbbb85c772101c3fa136de29013264d1efe4 Mon Sep 17 00:00:00 2001 From: Kingsword Date: Sun, 4 Oct 2026 11:44:46 +0800 Subject: [PATCH] fix(cli): expose MCP configuration diagnostics in doctor --- README.md | 19 +- docs/CONFIGURATION.md | 175 ++++++++++++++++++- docs/CONFIGURATION.zh-CN.md | 155 +++++++++++++++- docs/PROVIDER_CONFIG.md | 6 +- docs/PROVIDER_CONFIG.zh-CN.md | 5 +- scripts/check-runtime.ts | 2 + scripts/runtime-config-diagnostic-patches.ts | 77 ++++++++ scripts/sync-runtime.ts | 6 + src/config-diagnostics.ts | 129 ++++++++++++++ src/launcher.ts | 14 +- src/runtime-config-bridge.ts | 1 + test/config-diagnostics.test.ts | 88 ++++++++++ test/launcher.test.ts | 11 ++ test/runtime/config-diagnostics.test.ts | 116 ++++++++++++ 14 files changed, 785 insertions(+), 19 deletions(-) create mode 100644 scripts/runtime-config-diagnostic-patches.ts create mode 100644 src/config-diagnostics.ts create mode 100644 test/config-diagnostics.test.ts create mode 100644 test/runtime/config-diagnostics.test.ts diff --git a/README.md b/README.md index 5691ceec..db5a1df8 100644 --- a/README.md +++ b/README.md @@ -458,10 +458,21 @@ not available on `PATH`. ## Configuration -ZCode reads configuration from `~/.zcode/cli/setting.json` (or -`%USERPROFILE%\.zcode\cli\setting.json` on Windows), with project-level -overrides from `zcode.json` or `.zcode/config.json` in the working directory. -Existing files are never replaced. +ZCode reads MCP servers, hooks, plugins, permissions, network, storage and display +settings from `~/.zcode/cli/setting.json` (or +`%USERPROFILE%\.zcode\cli\setting.json` on Windows). Project settings use +`zcode.json` or `.zcode/config.json`. Existing files are never replaced. + +Custom MCP servers go in the top-level `mcp.servers` object in `setting.json`. +See [MCP configuration](./docs/CONFIGURATION.md#mcp-servers) for HTTP/stdio examples, +supported fields, loading diagnostics and the MCP-specific precedence rule +(user definitions win over project definitions with the same server name). +Run `zcode doctor --json` to inspect loaded configuration paths, MCP names and +sources, and validation errors without starting MCP servers or sending a model +request. Plugin and built-in MCP servers are added separately when a session starts. +The `setting.json` filename is this package's adaptation of the upstream CLI's +`config.json`. See [upstream source and file paths](./docs/CONFIGURATION.md#upstream-source-and-file-paths) +for the distinction from Desktop's MCP settings. Provider settings and the default model are stored in `~/.zcode/v2/provider_config.json`, shared with ZCode Desktop by default. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 42d554e9..8ad4f12f 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -2,14 +2,15 @@ English | [简体中文](CONFIGURATION.zh-CN.md) -The CLI follows the current ZCode runtime's provider registry schema. General -runtime settings and provider configuration live in separate files. +The CLI follows the current ZCode runtime's provider registry schema. Provider +and model settings live in `provider_config.json`; MCP servers, hooks, plugins, +permissions and other general runtime settings live in the CLI's `setting.json`. ## Configuration files | File | Purpose | | --- | --- | -| `~/.zcode/cli/setting.json` | CLI theme, notifications, tools, storage and other runtime settings | +| `~/.zcode/cli/setting.json` | MCP servers, hooks, plugins, permissions, network, storage and CLI display settings | | `~/.zcode/v2/setting.json` | Existing Desktop language and memory preferences, read without modification | | `~/.zcode/v2/provider_config.json` | Providers, model metadata overrides and the default model | | `~/.zcode/v2/credentials.json` | Credentials persisted by the native runtime | @@ -92,13 +93,179 @@ reported rather than replaced by old settings. | Resume/restart | Restores the session's model and reasoning options | | Language and memory | Reads Desktop `localePreference` / `memoryEnabled`; explicit CLI settings override these values | | Theme, terminal layout, copy-on-select and notifications | CLI `setting.json` | -| Tool permissions, retries, stream timeout, CLI plugin/MCP options and other runtime settings | CLI `setting.json`, with native project/environment precedence | +| MCP servers | CLI `setting.json` → `mcp.servers`; [MCP precedence](#project-settings-and-plugins) applies to project and plugin servers | +| Tool permissions, retries, stream timeout, hooks, plugins and other runtime settings | CLI `setting.json`, with native project/environment precedence | | Update cache, diagnostic logs and migration state | Operational files beneath the CLI directory | The CLI does not add keys to Desktop's `setting.json`. Notification and display changes write only the CLI file. Shared preferences are applied while loading runtime settings, not copied into CLI settings during unrelated updates. +## MCP servers + +Add servers under the top-level `mcp.servers` object in +`~/.zcode/cli/setting.json`. The keys are server names. `mcp`, `features`, +`permission`, `plugins` and `hooks` are siblings in this file; the +`schemaVersion` / `config` wrapper belongs to `provider_config.json`. + +Merge this example into your existing settings, replacing the endpoint and +executable with your own running HTTP server and installed stdio server: + +```json +{ + "features": { + "mcp": true + }, + "mcp": { + "servers": { + "basic-memory": { + "type": "http", + "url": "http://127.0.0.1:18796/mcp" + }, + "token-savior": { + "type": "stdio", + "command": "/absolute/path/to/token-savior-mcp", + "args": [], + "timeoutMs": 30000 + } + } + } +} +``` + +The runtime reads this file on startup. Restart ZCode after changing MCP +settings. The `settings-v1.json` marker only controls migration from the old +CLI `config.json`; it does not prevent later edits to `setting.json` from loading. +Changing the model/provider file or completing provider login does not configure +custom MCP servers. + +### Server fields + +| Field | Applies to | Meaning | +| --- | --- | --- | +| `type` | All | `"stdio"`, `"http"` or `"sse"`; use the transport supported by your server | +| `command` | stdio | Executable name or absolute path; put command arguments in `args` | +| `args` | stdio | Optional array of strings | +| `env` | stdio | Optional object of environment variable names to string values | +| `cwd` | stdio | Optional working directory for the server process | +| `url` | HTTP / SSE | Server endpoint, including its MCP or SSE path | +| `headers` | HTTP / SSE | Optional object of header names to string values, such as `Authorization` | +| `oauth` | HTTP / SSE | Optional OAuth configuration; see below | +| `enabled` | All | Set to `false` to disable a server while keeping its configuration | +| `timeoutMs` | All | Optional positive number in milliseconds; use this field instead of `timeout` or `startup_timeout_sec` | +| `protocolVersion` | All | Optional `"auto"`, `"legacy"` or `"2026-07-28"` in runtime 0.16.9; normally leave unset for automatic negotiation | + +For OAuth, `oauth.type` accepts `"authorization_code"` or `"client_credentials"`. +Both accept `clientId`, `clientSecret`, `clientName` and `scope`; client credentials +requires `clientId` and `clientSecret`, while authorization code also accepts +`redirectPath`. Configure these only when required by the MCP server. + +### Project settings and plugins + +Project `zcode.json` or `.zcode/config.json` files use the same `mcp.servers` +structure. In a Git worktree, the runtime discovers those files from the worktree +root through the working directory; outside a worktree, it checks the working +directory. For project stdio servers, relative `cwd` values resolve against the +project configuration's base directory (the parent of `.zcode` for +`.zcode/config.json`); an omitted `cwd` uses that directory. + +MCP servers merge by name. **User settings take precedence over project settings +for the same server name** in the current runtime; distinct names from both +sources remain available. This is an exception to the usual project-override +rule for other settings. An empty user `mcp.servers` object does not disable +project or plugin servers. Use `enabled: false` on a named server, or +`features.mcp: false` to disable MCP as a whole. + +Enabled plugins also contribute servers. Their definitions come from the +plugin's `.mcp.json` or manifest, and normally appear as +`plugin::`. Keep standalone user MCP definitions in +`setting.json`; this CLI does not automatically read `.agents/mcp.json`. +Plugin controls remain under `plugins`, hook declarations under +`hooks.events.`, and tool permissions under `permission` in `setting.json`. + +### Upstream source and file paths + +These paths distinguish the packaged CLI from the upstream application. The +source references below are pinned to upstream +[`29628c9` / `v3.14.3`](https://github.com/zai-org/ZCode/tree/29628c9acdb81b703bbd4080c207a0e7ce5e276e). +This package's runtime lock selects the separate Desktop 3.14.4 artifact; the +public source snapshot is not an assertion of that artifact's build commit. + +| Client / source | User MCP file | JSON path | +| --- | --- | --- | +| Unpatched upstream CLI | `~/.zcode/cli/config.json` | `mcp.servers` | +| This package, `zcode-app-cli` | `~/.zcode/cli/setting.json` | `mcp.servers` | +| Upstream Desktop's ZCode directory source | `~/.zcode/cli/config.json` | `mcp.servers` | +| Upstream Desktop's `.agents` fallback | `~/.agents/mcp.json` | `mcpServers` | + +The upstream [file loader](https://github.com/zai-org/ZCode/blob/29628c9acdb81b703bbd4080c207a0e7ce5e276e/apps/zcode-cli/packages/adapters/src/config/file-config.adapter.ts#L61-L118) +defaults to `config.json`. This package's +[`patchRuntimeSharedConfig`](https://github.com/kingsword09/zcode-cli/blob/e93caf41292314bd751180cf9b08f6d18895a5b5/scripts/sync-runtime.ts#L1026-L1046) +changes that filename to `setting.json`. The +[general-settings schema](https://github.com/zai-org/ZCode/blob/29628c9acdb81b703bbd4080c207a0e7ce5e276e/apps/zcode-cli/packages/adapters/src/config/schema.ts#L286-L306) +contains `mcp`, `plugins`, `permission` and `hooks`. The separate, strict +[provider-file schema](https://github.com/zai-org/ZCode/blob/29628c9acdb81b703bbd4080c207a0e7ce5e276e/packages/provider-node/src/provider-config-file-codec.ts#L18-L30) +accepts provider/model rules, provider order and default model selection; adding +MCP there fails its schema validation. The +[MCP merge function](https://github.com/zai-org/ZCode/blob/29628c9acdb81b703bbd4080c207a0e7ce5e276e/apps/zcode-cli/packages/adapters/src/config/config-factory.ts#L372-L397) +explicitly applies user definitions after project definitions. + +Desktop has a separate +[MCP directory reader and writer](https://github.com/zai-org/ZCode/blob/29628c9acdb81b703bbd4080c207a0e7ce5e276e/packages/desktop/src/main/mcpUserDirectory/index.ts#L37-L69). +For each user/project scope, it +[falls back to `.agents/mcp.json`](https://github.com/zai-org/ZCode/blob/29628c9acdb81b703bbd4080c207a0e7ce5e276e/packages/desktop/src/main/mcpUserDirectory/index.ts#L334-L375) +when that scope's ZCode file has no MCP entries. Project files are +`.zcode/config.json` and `.agents/mcp.json`, respectively. Sharing +`provider_config.json` with Desktop does not synchronize these user MCP files +with this package's `setting.json`; the old CLI file is only imported during the +one-time migration described above. + +### Checking whether settings loaded + +Run `zcode doctor` to inspect configuration paths, loading status, validated MCP +server names and their user/project sources. For a report suitable for debugging: + +```bash +zcode doctor --json +zcode --cwd /path/to/project doctor --json +``` + +The JSON output keeps the existing runtime metadata and adds `configuration`: +`user.path` / `user.status`, `project.paths` / `project.status`, `mcp.enabled`, +`mcp.servers` and `diagnostics`. Status is `loaded`, `missing` or `invalid`. +Diagnostics identify the file and rejected field. Server records include only +name, transport, enabled state and source; command arguments, URLs, environment +variables, headers and credentials are omitted. `provider.path` identifies the +provider file; this check does not validate provider credentials. + +Doctor works without login and even when `setting.json` contains invalid JSON. +It skips first-run settings creation and provider migration, and reports the old +`config.json` as a migration source only. It does not start MCP servers or send +model requests. A missing settings file is allowed; rejected settings or skipped +MCP entries produce exit code `1`. Valid configuration produces exit code `0`. + +The server list covers user/project configuration. Plugin and built-in MCP +servers are added separately at session startup, and this check does not test +connectivity. Use `/status` in the TUI to inspect connected MCP servers. The +default structured runtime logs are under `~/.zcode/cli/log/`. When a server is +missing, these events provide the session's loading and connection evidence: + +| Event | What to check | +| --- | --- | +| `bootstrap.app.startup.config.completed` | `context.configSourceUser` indicates whether the user settings file loaded | +| `config.file.invalid` | `context.configPath` and `context.diagnosticMessage` identify a file-level parsing or schema error | +| `config.mcp_server.skipped` | `context.diagnosticPath` and `context.diagnosticMessage` identify an invalid server entry | +| `mcp.configured_servers.connect.started` | `context.serverNames` lists the servers selected for connection | +| `mcp.configured_servers.connect.completed` | Connection status and tool counts distinguish loading from connection failures | + +Valid JSON must also satisfy the runtime schema. A schema error outside an +individual MCP server entry can cause the entire settings file to be ignored. +For example, `ui.theme: "system"` is invalid; supported values are `"auto"`, +`"dark"` and `"light"`. In that case the runtime can still start with default +settings and plugin servers. `features.mcp` defaults to `true`, so seeing MCP +enabled does not prove the user file loaded. A malformed individual server entry +is skipped with `config.mcp_server.skipped`; valid entries can still load. + ## Model catalogs and default selection The native registry loads the bundled catalog and manages upstream catalog diff --git a/docs/CONFIGURATION.zh-CN.md b/docs/CONFIGURATION.zh-CN.md index 663a60e2..89f70930 100644 --- a/docs/CONFIGURATION.zh-CN.md +++ b/docs/CONFIGURATION.zh-CN.md @@ -2,14 +2,15 @@ [English](CONFIGURATION.md) | 简体中文 -CLI 跟随当前 ZCode runtime 的 provider registry schema。通用运行设置与 provider 配置 -分别存储;模型配置中的“智能配置”对应英文界面的 **Smart configuration**。 +CLI 跟随当前 ZCode runtime 的 provider registry schema。Provider 和模型设置存放在 +`provider_config.json`;MCP、hooks、插件、权限等通用运行设置存放在 CLI 的 `setting.json`。 +模型配置中的“智能配置”对应英文界面的 **Smart configuration**。 ## 配置文件 | 文件 | 用途 | | --- | --- | -| `~/.zcode/cli/setting.json` | CLI 主题、通知、工具、存储和其他运行设置 | +| `~/.zcode/cli/setting.json` | MCP、hooks、插件、权限、网络、存储和 CLI 显示设置 | | `~/.zcode/v2/setting.json` | 桌面端已有的语言和记忆偏好,CLI 只读不修改 | | `~/.zcode/v2/provider_config.json` | Provider、模型元数据覆盖和默认模型 | | `~/.zcode/v2/credentials.json` | 原生 runtime 持久化的凭证 | @@ -78,12 +79,158 @@ CLI 专属字段迁移到 `~/.zcode/cli/setting.json`,省略 provider、main/l | 恢复会话/重启 | 恢复该会话保存的模型和推理选项 | | 语言与记忆 | 读取桌面端 `localePreference`/`memoryEnabled`,显式 CLI 设置优先 | | 主题、终端布局、选中复制、通知 | CLI `setting.json` | -| 工具权限、重试、流超时、CLI 插件/MCP 等运行设置 | CLI `setting.json`,保留原生项目和环境配置优先级 | +| MCP 服务器 | CLI `setting.json` → `mcp.servers`;项目与插件服务器的合并规则见 [项目配置与插件](#项目配置与插件) | +| 工具权限、重试、流超时、hooks、插件等运行设置 | CLI `setting.json`,保留原生项目和环境配置优先级 | | 更新缓存、诊断日志、迁移状态 | CLI 目录下的运行文件 | CLI 不向桌面端的 `setting.json` 增加字段。通知和显示设置只写 CLI 文件。 共享偏好在加载 runtime 设置时生效,不会因修改其他 CLI 选项而被复制进 CLI 设置。 +## MCP 服务器 + +在 `~/.zcode/cli/setting.json` 的顶层 `mcp.servers` 对象中添加服务器,键名就是服务器名。 +`mcp` 与 `features`、`permission`、`plugins`、`hooks` 同级; +`schemaVersion` / `config` 外层结构属于 `provider_config.json`。 + +将以下内容合并到已有设置中,并把地址和可执行文件路径替换为你实际运行的 HTTP 服务 +及已安装的 stdio 服务: + +```json +{ + "features": { + "mcp": true + }, + "mcp": { + "servers": { + "basic-memory": { + "type": "http", + "url": "http://127.0.0.1:18796/mcp" + }, + "token-savior": { + "type": "stdio", + "command": "/absolute/path/to/token-savior-mcp", + "args": [], + "timeoutMs": 30000 + } + } + } +} +``` + +Runtime 每次启动都会读取此文件,修改 MCP 设置后重启 ZCode 即可。 +`settings-v1.json` 标记只控制从旧 CLI `config.json` 的迁移,不会阻止后续修改 +`setting.json` 生效。修改模型/provider 文件或完成 provider 登录,不会配置自定义 MCP。 + +### 服务器字段 + +| 字段 | 适用类型 | 含义 | +| --- | --- | --- | +| `type` | 全部 | `"stdio"`、`"http"` 或 `"sse"`,按服务器支持的传输方式选择 | +| `command` | stdio | 可执行文件名或绝对路径;命令参数单独放在 `args` 中 | +| `args` | stdio | 可选,字符串数组 | +| `env` | stdio | 可选,环境变量名到字符串值的对象 | +| `cwd` | stdio | 可选,服务器进程的工作目录 | +| `url` | HTTP / SSE | 服务器地址,包含实际的 MCP 或 SSE 路径 | +| `headers` | HTTP / SSE | 可选,请求头名称到字符串值的对象,例如 `Authorization` | +| `oauth` | HTTP / SSE | 可选,OAuth 配置,见下文 | +| `enabled` | 全部 | 设为 `false` 可禁用服务器并保留配置 | +| `timeoutMs` | 全部 | 可选,正数,单位为毫秒;超时应使用此字段,`timeout` 和 `startup_timeout_sec` 不生效 | +| `protocolVersion` | 全部 | Runtime 0.16.9 支持可选值 `"auto"`、`"legacy"`、`"2026-07-28"`;通常省略,使用自动协商 | + +`oauth.type` 支持 `"authorization_code"` 和 `"client_credentials"`。 +两者都支持 `clientId`、`clientSecret`、`clientName`、`scope`;client credentials 模式 +必须填写 `clientId` 和 `clientSecret`,authorization code 模式还支持 `redirectPath`。 +按 MCP 服务器的鉴权要求配置即可。 + +### 项目配置与插件 + +项目的 `zcode.json` 或 `.zcode/config.json` 使用相同的 `mcp.servers` 结构。 +在 Git 工作树内,runtime 从工作树根目录到当前工作目录发现这些文件;工作树外只检查 +当前工作目录。项目 stdio 服务器的相对 `cwd` 以配置的基础目录解析: +对 `.zcode/config.json`,基础目录是 `.zcode` 的父目录;省略 `cwd` 时也使用该基础目录。 + +MCP 服务器按名称合并。当前 runtime 中,**同名服务器的用户配置优先于项目配置**; +不同名称的服务器都会保留。这与其他设置通常由项目覆盖用户配置的规则不同。 +用户配置中的空 `mcp.servers` 对象不会禁用项目或插件服务器。 +可对指定服务器设置 `enabled: false`,或用 `features.mcp: false` 关闭整个 MCP 功能。 + +已启用的插件也会提供 MCP,定义来自插件的 `.mcp.json` 或 manifest,通常显示为 +`plugin:<插件名>:<服务器名>`。独立的用户 MCP 定义放在 `setting.json`; +此 CLI 不会自动读取 `.agents/mcp.json`。 +插件控制仍在 `setting.json` 的 `plugins` 下,hook 声明在 `hooks.events.` 下, +工具权限在 `permission` 下。 + +### 上游源码与配置路径的区别 + +下面区分本发行包与上游应用。源码引用固定在上游 +[`29628c9` / `v3.14.3`](https://github.com/zai-org/ZCode/tree/29628c9acdb81b703bbd4080c207a0e7ce5e276e)。 +本项目 runtime lock 选择的是独立发布的 Desktop 3.14.4 产物,不能据此认定该产物 +由这个公开源码提交构建。 + +| 客户端/配置源 | 用户级 MCP 文件 | JSON 路径 | +| --- | --- | --- | +| 未打补丁的上游 CLI | `~/.zcode/cli/config.json` | `mcp.servers` | +| 本项目 `zcode-app-cli` | `~/.zcode/cli/setting.json` | `mcp.servers` | +| 上游 Desktop 的 ZCode 目录配置源 | `~/.zcode/cli/config.json` | `mcp.servers` | +| 上游 Desktop 的 `.agents` 回退配置源 | `~/.agents/mcp.json` | `mcpServers` | + +上游 [文件加载器](https://github.com/zai-org/ZCode/blob/29628c9acdb81b703bbd4080c207a0e7ce5e276e/apps/zcode-cli/packages/adapters/src/config/file-config.adapter.ts#L61-L118) +默认使用 `config.json`。本项目的 +[`patchRuntimeSharedConfig`](https://github.com/kingsword09/zcode-cli/blob/e93caf41292314bd751180cf9b08f6d18895a5b5/scripts/sync-runtime.ts#L1026-L1046) +将文件名改成 `setting.json`。 +[通用设置 schema](https://github.com/zai-org/ZCode/blob/29628c9acdb81b703bbd4080c207a0e7ce5e276e/apps/zcode-cli/packages/adapters/src/config/schema.ts#L286-L306) +包含 `mcp`、`plugins`、`permission` 和 `hooks`;独立且严格的 +[provider 文件 schema](https://github.com/zai-org/ZCode/blob/29628c9acdb81b703bbd4080c207a0e7ce5e276e/packages/provider-node/src/provider-config-file-codec.ts#L18-L30) +只接受 provider/模型规则、provider 排序和默认模型选择,向其中添加 MCP 会导致 schema +校验失败。[MCP 合并函数](https://github.com/zai-org/ZCode/blob/29628c9acdb81b703bbd4080c207a0e7ce5e276e/apps/zcode-cli/packages/adapters/src/config/config-factory.ts#L372-L397) +明确先应用项目配置,再应用用户配置。 + +Desktop 有独立的 +[MCP 目录读写模块](https://github.com/zai-org/ZCode/blob/29628c9acdb81b703bbd4080c207a0e7ce5e276e/packages/desktop/src/main/mcpUserDirectory/index.ts#L37-L69)。 +在每个用户/项目作用域内,ZCode 文件没有 MCP 条目时,它会 +[回退到 `.agents/mcp.json`](https://github.com/zai-org/ZCode/blob/29628c9acdb81b703bbd4080c207a0e7ce5e276e/packages/desktop/src/main/mcpUserDirectory/index.ts#L334-L375)。 +项目级文件分别是 `.zcode/config.json` 和 `.agents/mcp.json`。 +与 Desktop 共用 `provider_config.json` 不代表这些用户 MCP 文件会与本项目的 +`setting.json` 同步;旧 CLI 文件只在前述一次性迁移时导入。 + +### 确认配置是否加载 + +运行 `zcode doctor` 查看实际配置路径、加载状态、校验通过的 MCP 名称及用户/项目来源。 +需要排查问题时可生成 JSON 报告: + +```bash +zcode doctor --json +zcode --cwd /path/to/project doctor --json +``` + +JSON 保留原有 runtime 信息,新增 `configuration`:包含 `user.path` / `user.status`、 +`project.paths` / `project.status`、`mcp.enabled`、`mcp.servers` 和 `diagnostics`。 +状态为 `loaded`、`missing` 或 `invalid`;诊断指出对应文件与被拒绝的字段。 +服务器记录只包含名称、传输方式、启用状态和来源,不输出命令参数、URL、环境变量、 +请求头或凭据。`provider.path` 只标明 provider 文件位置,不验证 provider 凭据。 + +Doctor 无需登录,即使 `setting.json` 的 JSON 语法损坏也能运行。它跳过首次设置文件创建 +和 provider 迁移,并将旧 `config.json` 标为仅供迁移使用。它不会启动 MCP 或请求模型。 +设置文件缺失是允许的;配置无效或有 MCP 条目被跳过时退出码为 `1`,有效配置为 `0`。 + +服务器列表覆盖用户/项目配置。插件和内置 MCP 会在会话启动时另外加入,doctor 不测试 +连接是否成功。在 TUI 中使用 `/status` 查看连接状态。结构化 runtime 日志默认位于 +`~/.zcode/cli/log/`;服务器缺失时,以下事件可提供实际会话的加载和连接证据: + +| 事件 | 检查内容 | +| --- | --- | +| `bootstrap.app.startup.config.completed` | `context.configSourceUser` 表示是否成功加载用户设置文件 | +| `config.file.invalid` | `context.configPath` 和 `context.diagnosticMessage` 指出文件解析或 schema 校验错误 | +| `config.mcp_server.skipped` | `context.diagnosticPath` 和 `context.diagnosticMessage` 指出无效的服务器配置项 | +| `mcp.configured_servers.connect.started` | `context.serverNames` 列出准备连接的服务器 | +| `mcp.configured_servers.connect.completed` | 连接状态和工具数量,可区分配置加载问题与连接失败 | + +JSON 语法正确还不够,字段也必须符合 runtime 的 schema。单个 MCP 服务器条目以外的 +schema 错误可能导致整份设置文件被忽略。例如 `ui.theme: "system"` 无效,正确值是 +`"auto"`、`"dark"` 或 `"light"`。此时 runtime 仍可能使用默认设置及插件服务器启动。 +`features.mcp` 默认就是 `true`,因此 MCP 开关已开启不能证明用户文件加载成功。 +单个服务器条目无效时,会记录 `config.mcp_server.skipped` 并跳过该条目,其他有效条目仍可加载。 + ## 模型目录与默认选择 原生 registry 加载随包提供的目录,并管理上游目录刷新。`/model`、模型循环切换以及 diff --git a/docs/PROVIDER_CONFIG.md b/docs/PROVIDER_CONFIG.md index 84be7398..c06d4f4d 100644 --- a/docs/PROVIDER_CONFIG.md +++ b/docs/PROVIDER_CONFIG.md @@ -3,8 +3,10 @@ English | [简体中文](PROVIDER_CONFIG.zh-CN.md) ZCode Desktop and CLI share `~/.zcode/v2/provider_config.json` (underscore). -`ZCODE_PERSONAL_PROVIDER_CONFIG_FILE` can select another file. General terminal, -tool and network settings belong in `~/.zcode/cli/setting.json`. +`ZCODE_PERSONAL_PROVIDER_CONFIG_FILE` can select another file. MCP servers, hooks, +plugins, permissions, terminal and network settings belong in +`~/.zcode/cli/setting.json`. See [MCP servers](CONFIGURATION.md#mcp-servers) +for the `mcp.servers` structure and HTTP/stdio examples. [`provider.example.json`](../provider.example.json) covers the current personal provider schema. It is valid JSON without comments. This reference explains the diff --git a/docs/PROVIDER_CONFIG.zh-CN.md b/docs/PROVIDER_CONFIG.zh-CN.md index 3229b3e5..3ef45ec0 100644 --- a/docs/PROVIDER_CONFIG.zh-CN.md +++ b/docs/PROVIDER_CONFIG.zh-CN.md @@ -3,8 +3,9 @@ [English](PROVIDER_CONFIG.md) | 简体中文 ZCode 桌面端与 CLI 共用 `~/.zcode/v2/provider_config.json`,文件名使用下划线。 -可通过 `ZCODE_PERSONAL_PROVIDER_CONFIG_FILE` 指定其他文件。终端显示、工具和网络等 -CLI 设置放在 `~/.zcode/cli/setting.json`。 +可通过 `ZCODE_PERSONAL_PROVIDER_CONFIG_FILE` 指定其他文件。MCP、hooks、插件、权限、 +终端显示和网络等 CLI 设置放在 `~/.zcode/cli/setting.json`。 +`mcp.servers` 的结构及 HTTP/stdio 示例见 [MCP 服务器配置](CONFIGURATION.zh-CN.md#mcp-服务器)。 [`provider.example.json`](../provider.example.json) 展示当前个人 provider schema 的配置项。 它是可以解析的标准 JSON,不包含注释;各字段的含义、可选值及互斥关系由本文说明。 diff --git a/scripts/check-runtime.ts b/scripts/check-runtime.ts index d87ddda3..8292336b 100755 --- a/scripts/check-runtime.ts +++ b/scripts/check-runtime.ts @@ -15,6 +15,7 @@ import { runtimeTestEnv } from "./runtime-test-env.ts"; import { extractRuntimeCapabilities, hasRuntimeAppServerStandaloneAuth, + hasRuntimeConfigurationDiagnostics, hasRuntimeCliHelpContract, hasRuntimeHttpNoContentGuard, hasRuntimeNetworkRetryGuard, @@ -94,6 +95,7 @@ if (patchRuntimeLoginModelDefaults(runtimeSource) !== runtimeSource || !hasRuntimeStreamEofFinishGuard(runtimeSource) || (patchEnabled("cli-help-contract") && !hasRuntimeCliHelpContract(runtimeSource)) || !hasRuntimeAppServerStandaloneAuth(runtimeSource) + || !hasRuntimeConfigurationDiagnostics(runtimeSource) || !runtimeSource.includes(".readRuntimeProjection=async()=>{let $zRuntimeProjectionBridge=await ") || !runtimeSource.includes('"plugin://"') || !runtimeSource.includes('return await import("playwright-core")') diff --git a/scripts/runtime-config-diagnostic-patches.ts b/scripts/runtime-config-diagnostic-patches.ts new file mode 100644 index 00000000..a85e0268 --- /dev/null +++ b/scripts/runtime-config-diagnostic-patches.ts @@ -0,0 +1,77 @@ +import { parseExpressionAt, type ObjectExpression, type SequenceExpression } from "acorn"; + +const identifier = "[A-Za-z_$][\\w$]*"; +const bridge = 'require(require("node:path").join(__dirname,"cli-config.cjs"))'; + +function incompatible(detail: string): never { + throw new Error(`ZCode runtime is incompatible with configuration diagnostics (${detail}).`); +} + +function unique(items: T[], name: string): T { + if (items.length !== 1) incompatible(`${name} missing or ambiguous`); + return items[0]!; +} + +function inspect(runtime: string) { + const tag = unique([...runtime.matchAll(/,"runDoctor"\)/gu)], "doctor label").index!; + const prefixes = [...runtime.slice(0, tag).matchAll(new RegExp( + `${identifier}=${identifier}\\((\\((${identifier}),(${identifier}),(${identifier})\\)=>\\{)`, "gu" + ))]; + const prefix = prefixes.at(-1); + if (!prefix) incompatible("doctor function missing"); + const start = prefix.index! + prefix[0].indexOf(prefix[1]!); + // Stop before the label argument so Acorn cannot consume it as a comma expression. + const fn = parseExpressionAt(runtime.slice(0, tag), start, { ecmaVersion: "latest" }); + if (fn.type !== "ArrowFunctionExpression" || fn.body.type !== "BlockStatement" || fn.end !== tag) incompatible("doctor function changed"); + const report = unique(fn.body.body.flatMap(statement => statement.type === "VariableDeclaration" + ? statement.declarations.filter(binding => binding.init?.type === "ObjectExpression" + && ["cli", "runtime", "packaging"].every(key => (binding.init as ObjectExpression).properties.some(property => + property.type === "Property" && property.key.type === "Identifier" && property.key.name === key + ))) : []), "doctor report"); + if (report.id.type !== "Identifier" || report.init?.type !== "ObjectExpression") incompatible("doctor report changed"); + const jsonBranch = unique(fn.body.body.filter(statement => statement.type === "IfStatement" + && statement.test.type === "MemberExpression" && statement.test.property.type === "Identifier" + && statement.test.property.name === "json"), "doctor JSON branch"); + if (jsonBranch.type !== "IfStatement" || jsonBranch.consequent.type !== "ReturnStatement" + || jsonBranch.consequent.argument?.type !== "SequenceExpression") incompatible("doctor JSON return changed"); + const textReturn = unique(fn.body.body.filter(statement => statement.type === "ReturnStatement"), "doctor text return"); + if (textReturn.type !== "ReturnStatement" || textReturn.argument?.type !== "SequenceExpression") incompatible("doctor text return changed"); + const result = report.id.name; + const parse = unique([...runtime.matchAll(new RegExp(`${identifier}\\((${identifier}),"createConfig"\\)`, "gu"))], "native config factory"); + const init = [...runtime.slice(0, parse.index).matchAll(new RegExp(`(${identifier})=${identifier}\\(\\(\\)=>\\{`, "gu"))].at(-1)?.[1]; + if (!init) incompatible("native config initializer missing"); + return { + report: report.init, result, streams: prefix[2]!, cwd: prefix[4]!, factory: parse[1]!, init, + jsonReturn: jsonBranch.consequent.argument, textReturn: textReturn.argument + }; +} + +export function hasRuntimeConfigurationDiagnostics(runtime: string): boolean { + if (!runtime.includes(".inspectRuntimeConfiguration(")) return false; + try { + const inspected = inspect(runtime); + const source = runtime.slice(inspected.report.start, inspected.report.end); + return source.includes(`${bridge}.inspectRuntimeConfiguration(${inspected.cwd},$zOptions=>{${inspected.init}();return ${inspected.factory}($zOptions)})`) + && runtime.slice(inspected.jsonReturn.start, inspected.jsonReturn.end).endsWith(`${inspected.result}.configuration.ok?0:1`) + && runtime.slice(inspected.textReturn.start, inspected.textReturn.end).endsWith(`${bridge}.writeConfigurationDoctor(${inspected.streams}.stdout,${inspected.result}.configuration)`); + } catch { return false; } +} + +export function patchRuntimeConfigurationDiagnostics(runtime: string): string { + if (hasRuntimeConfigurationDiagnostics(runtime)) return runtime; + if (runtime.includes(".inspectRuntimeConfiguration(")) incompatible("partial patch"); + const { report, result, streams, cwd, factory, init, jsonReturn, textReturn } = inspect(runtime); + const endCode = (expression: SequenceExpression) => { + const last = expression.expressions.at(-1)!; + if (last.type !== "Literal" || last.value !== 0) incompatible("doctor exit code changed"); + return last; + }; + const jsonCode = endCode(jsonReturn), textCode = endCode(textReturn); + const edits = [ + { start: report.end - 1, end: report.end - 1, text: `,configuration:${bridge}.inspectRuntimeConfiguration(${cwd},$zOptions=>{${init}();return ${factory}($zOptions)})` }, + { start: jsonCode.start, end: jsonCode.end, text: `${result}.configuration.ok?0:1` }, + { start: textCode.start, end: textCode.end, text: `${bridge}.writeConfigurationDoctor(${streams}.stdout,${result}.configuration)` } + ]; + for (const edit of edits.sort((left, right) => right.start - left.start)) runtime = runtime.slice(0, edit.start) + edit.text + runtime.slice(edit.end); + return runtime; +} diff --git a/scripts/sync-runtime.ts b/scripts/sync-runtime.ts index 1c1dae6d..41fb03e4 100755 --- a/scripts/sync-runtime.ts +++ b/scripts/sync-runtime.ts @@ -16,6 +16,8 @@ import { parseReleaseVersion, syncedReleaseVersion } from "./release-version.ts" import { markRuntimeModified } from "./runtime-attribution.ts"; import { hasRuntimeAppServerStandaloneAuth, patchRuntimeAppServerStandaloneAuth } from "./runtime-app-server-patches.ts"; export { hasRuntimeAppServerStandaloneAuth, patchRuntimeAppServerStandaloneAuth } from "./runtime-app-server-patches.ts"; +import { hasRuntimeConfigurationDiagnostics, patchRuntimeConfigurationDiagnostics } from "./runtime-config-diagnostic-patches.ts"; +export { hasRuntimeConfigurationDiagnostics, patchRuntimeConfigurationDiagnostics } from "./runtime-config-diagnostic-patches.ts"; import { hasRuntimeSqliteBusyTimeout, hasRuntimeSqliteWriteRecovery, patchRuntimeSqliteBusyTimeout, patchRuntimeSqliteWriteRecovery @@ -1550,6 +1552,10 @@ export const runtimePatchPlan: readonly RuntimePatchDefinition[] = [ apply: patchRuntimeSharedConfig, verify: runtime => runtime.includes('ZCODE_CLI_MIGRATE_CONFIG==="1"') && runtime.includes('="setting.json",') }, + { + id: "configuration-diagnostics", requirement: "required", apply: patchRuntimeConfigurationDiagnostics, + verify: hasRuntimeConfigurationDiagnostics + }, { id: "session-model-recovery", requirement: "required", apply: patchRuntimeSessionModelRecovery, verify: runtime => runtime.includes("$zRestoredSessionModel") && runtime.includes('"readSessionModelState"') diff --git a/src/config-diagnostics.ts b/src/config-diagnostics.ts new file mode 100644 index 00000000..82ff0774 --- /dev/null +++ b/src/config-diagnostics.ts @@ -0,0 +1,129 @@ +import { existsSync } from "node:fs"; +import { desktopSettingsPath, legacyCliConfigPath, providerConfigPath } from "./config-paths.ts"; + +interface NativeDiagnostic { + code: string; + message: string; + filePath?: string; + path?: string; + severity: string; +} + +interface NativeSource { + path?: string; + paths?: string[]; + loaded: boolean; + diagnostics: NativeDiagnostic[]; + mcpServerNames: string[]; +} + +interface NativeConfigResult { + config: { + features: { mcp: boolean }; + mcp: { servers: Record }; + }; + sources: { + user: NativeSource; + project: NativeSource; + mcp: { serverSources: Record }; + }; +} + +export interface ConfigurationDiagnostic { + code: string; + scope: "user" | "project" | "desktop"; + severity: string; + filePath?: string; + path?: string; + message: string; +} + +export interface ConfigurationReport { + ok: boolean; + user: { path?: string; status: "loaded" | "missing" | "invalid"; mcpServerNames: string[] }; + project: { paths: string[]; status: "loaded" | "missing" | "invalid"; mcpServerNames: string[] }; + provider: { path?: string }; + legacy: { path: string; exists: boolean; usage: "first-run-migration-only" }; + mcp: { enabled: boolean; servers: { name: string; type: string; enabled: boolean; source: string }[] }; + diagnostics: ConfigurationDiagnostic[]; +} + +const schemaDiagnosticPrefix = /^(?:|modelStream|permission|storage|network|features|memory|mcp|plugins|skills|skill|command|logging|ui|toolConcurrency|modelAnomalyGuard|hooks)(?:[.:[\]]|$)/u; + +function publicDiagnostic(diagnostic: NativeDiagnostic, scope: "user" | "project"): ConfigurationDiagnostic { + // JSON.parse 的错误消息可能回显带密钥的原文片段;只保留 schema 生成的字段错误。 + const message = diagnostic.code === "config_file_invalid" && !schemaDiagnosticPrefix.test(diagnostic.message) + ? "Unable to read or parse configuration JSON. Check the file syntax and permissions." + : diagnostic.message; + return { + code: diagnostic.code, scope, severity: diagnostic.severity, + filePath: diagnostic.filePath, path: diagnostic.path, message + }; +} + +function sourceStatus(source: NativeSource): "loaded" | "missing" | "invalid" { + if (source.diagnostics.some(diagnostic => diagnostic.code === "config_file_invalid")) return "invalid"; + return source.loaded ? "loaded" : "missing"; +} + +/** Inspect the runtime's resolved configuration without starting providers, plugins or MCP connections. */ +export function inspectRuntimeConfiguration( + workingDirectory: string, + createConfig: (options: { workingDirectory: string; env: NodeJS.ProcessEnv; loggerFactory: unknown }) => NativeConfigResult, + env: NodeJS.ProcessEnv = process.env +): ConfigurationReport { + // doctor 展示经过筛选的诊断,不让原生 logger 另行输出原始配置错误片段。 + const logger = { child: () => logger, warn() {} }; + const result = createConfig({ workingDirectory, env, loggerFactory: { createLogger: () => logger } }); + const diagnostics = [ + ...result.sources.user.diagnostics.map(diagnostic => publicDiagnostic(diagnostic, "user")), + ...result.sources.project.diagnostics.map(diagnostic => publicDiagnostic(diagnostic, "project")) + ]; + let providerPath: string | undefined; + try { providerPath = providerConfigPath(env); } catch { + diagnostics.push({ code: "desktop_settings_invalid", scope: "desktop", severity: "error", + filePath: desktopSettingsPath(env), message: "Desktop settings could not be read to resolve the provider configuration path." }); + } + const legacyPath = legacyCliConfigPath(env); + return { + ok: !diagnostics.some(diagnostic => diagnostic.severity === "error" || diagnostic.code === "config_mcp_server_invalid"), + user: { path: result.sources.user.path, status: sourceStatus(result.sources.user), mcpServerNames: result.sources.user.mcpServerNames }, + project: { + paths: [...new Set([ + ...result.sources.project.paths ?? [], + ...result.sources.project.diagnostics.flatMap(diagnostic => diagnostic.filePath ? [diagnostic.filePath] : []) + ])], + status: sourceStatus(result.sources.project), mcpServerNames: result.sources.project.mcpServerNames + }, + provider: { path: providerPath }, + legacy: { path: legacyPath, exists: existsSync(legacyPath), usage: "first-run-migration-only" }, + mcp: { + enabled: result.config.features.mcp, + servers: Object.entries(result.config.mcp.servers).map(([name, server]) => ({ + name, type: server.type, enabled: server.enabled !== false, + source: result.sources.mcp.serverSources[name] ?? "unknown" + })).sort((left, right) => left.name.localeCompare(right.name)) + }, + diagnostics + }; +} + +function printable(value: string): string { + return value.replace(/[\u0000-\u001f\u007f-\u009f]/gu, character => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`); +} + +export function writeConfigurationDoctor(output: { write(text: string): unknown }, report: ConfigurationReport): number { + const lines = [ + "", "Configuration:", + ` user: ${report.user.path ?? "unknown"} (${report.user.status})`, + ` project: ${report.project.paths.join(", ") || "none"} (${report.project.status})`, + ` providers: ${report.provider.path ?? "unavailable"}`, + ` legacy: ${report.legacy.path} (${report.legacy.exists ? "present; migration source only" : "absent"})`, + ` MCP: ${report.mcp.enabled ? "enabled" : "disabled"}; ${report.mcp.servers.length} configured server(s)`, + ...report.mcp.servers.map(server => ` ${JSON.stringify(server.name)}: ${server.type}, ${server.source}, ${server.enabled ? "enabled" : "disabled"}`), + " Plugin and built-in MCP servers are added at session startup; connections were not tested.", + ...report.diagnostics.map(diagnostic => ` ${diagnostic.severity}: ${diagnostic.filePath ?? diagnostic.scope}${diagnostic.path ? ` [${diagnostic.path}]` : ""}: ${diagnostic.message}`) + ]; + output.write(lines.map(printable).join("\n") + "\n"); + return report.ok ? 0 : 1; +} diff --git a/src/launcher.ts b/src/launcher.ts index 00bd97f8..b29f9e16 100644 --- a/src/launcher.ts +++ b/src/launcher.ts @@ -273,12 +273,17 @@ export function isTuiRuntimeInvocation( && (invocation.command === undefined || invocation.command === "tui"); } +export function isDoctorRuntimeInvocation(args: string[], runtimeOptionTypes = readRuntimeCliOptionTypes()): boolean { + const invocation = inspectRuntimeInvocation(args, runtimeOptionTypes); + return !invocation.agentInvocation && !invocation.invalid && invocation.command === "doctor"; +} + export function firstRunSetupEnv(setupPending: boolean, args: string[]): NodeJS.ProcessEnv | undefined { if (!setupPending || !isTuiRuntimeInvocation(args)) return undefined; return { ZCODE_CLI_FIRST_RUN: "1" }; } -function runtimeEnvironment(extra: NodeJS.ProcessEnv = {}): Record { +function runtimeEnvironment(extra: NodeJS.ProcessEnv = {}, inspectConfiguration = false): Record { const env: NodeJS.ProcessEnv = { ...process.env }; delete env.ZCODE_CLI_OAUTH_CALLBACK_STDIN; delete env.ZCODE_CLI_MIGRATE_CONFIG; @@ -289,7 +294,7 @@ function runtimeEnvironment(extra: NodeJS.ProcessEnv = {}): Record { let setupPending = false; try { + // doctor 必须能检查损坏的配置,不能先被普通启动校验或 provider 迁移拦住。 + if (isDoctorRuntimeInvocation(args)) return await runRuntime(resolveNodeExecutable(), args); const bootstrap = await ensureCliSettings(); await readCliSettings(); sharedDataBaseDir(); @@ -481,6 +488,7 @@ export async function main(args: string[]): Promise { } } catch (error) { console.error(`Error: ${error instanceof Error ? error.message : String(error)}`); + console.error("Run `zcode doctor --json` for configuration diagnostics."); return 1; } diff --git a/src/runtime-config-bridge.ts b/src/runtime-config-bridge.ts index 34d66512..01ebcf92 100644 --- a/src/runtime-config-bridge.ts +++ b/src/runtime-config-bridge.ts @@ -8,6 +8,7 @@ export { readTuiRuntimeProjection, sendTuiBackgroundTaskMessage } from "./runtim export { restoreTuiBackgroundTasks } from "./runtime-background-restore.ts"; export { installSqliteWriteRecovery, pruneSqliteUsage, sqliteRecoveryStats } from "./runtime-sqlite-recovery.ts"; export { appServerRegistryOptions } from "./app-server-auth.ts"; +export { inspectRuntimeConfiguration, writeConfigurationDoctor } from "./config-diagnostics.ts"; function record(value: unknown): Record | undefined { return value && typeof value === "object" && !Array.isArray(value) ? value as Record : undefined; diff --git a/test/config-diagnostics.test.ts b/test/config-diagnostics.test.ts new file mode 100644 index 00000000..8bd7db51 --- /dev/null +++ b/test/config-diagnostics.test.ts @@ -0,0 +1,88 @@ +import { expect, test } from "bun:test"; +import { inspectRuntimeConfiguration, writeConfigurationDoctor } from "../src/config-diagnostics.ts"; +import { hasRuntimeConfigurationDiagnostics, patchRuntimeConfigurationDiagnostics } from "../scripts/runtime-config-diagnostic-patches.ts"; + +const env = { HOME: "/fixture-home", USERPROFILE: "/fixture-home" }; + +test("doctor projects names and sources without exposing MCP configuration values", () => { + const source = { path: "/fixture-home/.zcode/cli/setting.json", loaded: true, diagnostics: [], mcpServerNames: ["test"] }; + const report = inspectRuntimeConfiguration("/workspace", options => { + expect(options.workingDirectory).toBe("/workspace"); + return { + config: { features: { mcp: true }, mcp: { servers: { + test: { type: "http", url: "https://private.test/?token=secret", headers: { Authorization: "secret" } } + } } }, + sources: { user: source, project: { ...source, path: undefined, loaded: false, mcpServerNames: [] }, mcp: { serverSources: { test: "user" } } } + }; + }, env); + expect(report.ok).toBe(true); + expect(report.mcp.servers).toEqual([{ name: "test", type: "http", enabled: true, source: "user" }]); + expect(JSON.stringify(report)).not.toContain("secret"); + expect(JSON.stringify(report)).not.toContain("private.test"); +}); + +test("doctor keeps schema errors useful while suppressing JSON input fragments and terminal controls", () => { + const report = inspectRuntimeConfiguration("/workspace", () => ({ + config: { features: { mcp: true }, mcp: { servers: {} } }, + sources: { + user: { path: "/config", loaded: false, mcpServerNames: [], diagnostics: [ + { code: "config_file_invalid", severity: "error", message: 'Unexpected token in JSON: "SECRET_VALUE"', filePath: "/config" } + ] }, + project: { paths: [], loaded: false, mcpServerNames: [], diagnostics: [ + { code: "config_file_invalid", severity: "error", message: 'ui.theme: Invalid option: expected one of "auto"|"dark"|"light"', filePath: "/project/config.json" }, + { code: "config_mcp_server_invalid", severity: "warning", message: "command: expected string", path: "mcp.servers.bad\u001b[2J", filePath: "/project/config.json" } + ] }, + mcp: { serverSources: {} } + } + }), env); + expect(report.ok).toBe(false); + expect(report.user.status).toBe("invalid"); + expect(report.project.paths).toEqual(["/project/config.json"]); + expect(JSON.stringify(report)).not.toContain("SECRET_VALUE"); + expect(report.diagnostics[1]?.message).toContain("ui.theme"); + let text = ""; + expect(writeConfigurationDoctor({ write(value) { text += value; } }, report)).toBe(1); + expect(text).toContain("\\u001b"); + expect(text).not.toContain("\u001b"); +}); + +const source = ` +var init=once(()=>{label(factory,"createConfig")}); +var doctor=label((io,options,cwd)=>{let report={cli:{version:"fixture"},runtime:{cwd},packaging:{default:"node-bundle"}}; +if(options.json)return io.stdout.write(JSON.stringify(report)),0; +return io.stdout.write("doctor\\n"),0},"runDoctor"); +`; + +test("doctor bundle patch preserves metadata, emits one JSON document and returns configuration status", () => { + const patched = patchRuntimeConfigurationDiagnostics(source); + expect(hasRuntimeConfigurationDiagnostics(patched)).toBe(true); + expect(patchRuntimeConfigurationDiagnostics(patched)).toBe(patched); + let text = ""; + const report = { ok: false }; + const doctor = new Function("require", "__dirname", "once", "label", "factory", `${patched};return doctor;`)( + (name: string) => name === "node:path" ? { join: (...parts: string[]) => parts.join("/") } : { + inspectRuntimeConfiguration(cwd: string, createConfig: (options: unknown) => unknown) { + expect(cwd).toBe("/workspace"); + expect(createConfig({})).toBe("native-config"); + return report; + }, + writeConfigurationDoctor(output: { write(value: string): void }, value: unknown) { + expect(value).toBe(report); output.write("config-invalid\n"); return 1; + } + }, "/fixture", (fn: () => void) => fn, (fn: unknown) => fn, () => "native-config" + ); + const io = { stdout: { write(value: string) { text += value; } } }; + expect(doctor(io, { json: true }, "/workspace")).toBe(1); + expect(JSON.parse(text)).toEqual({ cli: { version: "fixture" }, runtime: { cwd: "/workspace" }, packaging: { default: "node-bundle" }, configuration: report }); + text = ""; + expect(doctor(io, { json: false }, "/workspace")).toBe(1); + expect(text).toBe("doctor\nconfig-invalid\n"); +}); + +test("doctor patch rejects missing, ambiguous and incomplete upstream boundaries", () => { + expect(() => patchRuntimeConfigurationDiagnostics(source + source)).toThrow("ambiguous"); + expect(() => patchRuntimeConfigurationDiagnostics(source.replace('"createConfig"', '"changed"'))).toThrow("missing"); + const partial = patchRuntimeConfigurationDiagnostics(source).replace(".writeConfigurationDoctor(", ".changed("); + expect(hasRuntimeConfigurationDiagnostics(partial)).toBe(false); + expect(() => patchRuntimeConfigurationDiagnostics(partial)).toThrow("partial"); +}); diff --git a/test/launcher.test.ts b/test/launcher.test.ts index 243c5019..6b863eed 100644 --- a/test/launcher.test.ts +++ b/test/launcher.test.ts @@ -17,6 +17,7 @@ import { firstRunSetupEnv, formatVersionOutput, isTuiRuntimeInvocation, + isDoctorRuntimeInvocation, isVersionInvocation, normalizeLoginArgs, readRuntimeCliOptionTypes, @@ -226,6 +227,16 @@ describe("launcher routing", () => { expect(isTuiRuntimeInvocation(["--unknown"])).toBe(false); }); + test("routes doctor around settings bootstrap without mistaking prompt text for a command", () => { + expect(isDoctorRuntimeInvocation(["doctor", "--json"])).toBe(true); + expect(isDoctorRuntimeInvocation(["--cwd", "/project", "doctor"])).toBe(true); + expect(isDoctorRuntimeInvocation(["doctor", "--help"])).toBe(true); + expect(isDoctorRuntimeInvocation(["--prompt", "doctor"])).toBe(false); + expect(isDoctorRuntimeInvocation(["--prompt", "hello", "doctor"])).toBe(false); + expect(isDoctorRuntimeInvocation(["--cwd", "doctor"])).toBe(false); + expect(isDoctorRuntimeInvocation(["doctor", "--unknown"])).toBe(false); + }); + test("routes only the plain Z.AI login command through the Desktop OAuth bridge", () => { expect(classifyZaiOAuthInvocation(["login"])).toEqual({ json: false, diff --git a/test/runtime/config-diagnostics.test.ts b/test/runtime/config-diagnostics.test.ts new file mode 100644 index 00000000..211624e1 --- /dev/null +++ b/test/runtime/config-diagnostics.test.ts @@ -0,0 +1,116 @@ +import { expect, test } from "bun:test"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { runtimeTestEnv } from "../fixtures/runtime-env.ts"; + +const root = join(import.meta.dir, "../.."); + +async function fixture() { + const testHome = await mkdtemp(join(tmpdir(), "zcode-config-doctor-")); + const workspace = join(testHome, "workspace"); + await mkdir(workspace, { recursive: true }); + const settings = join(testHome, ".zcode", "cli", "setting.json"); + const put = async (file: string, value: unknown) => { + await mkdir(dirname(file), { recursive: true }); + await writeFile(file, JSON.stringify(value)); + }; + const run = async (args = ["doctor", "--json"]) => { + const child = Bun.spawn([Bun.which("node")!, join(root, "bin/zcode.js"), "--cwd", workspace, ...args], { + cwd: testHome, env: runtimeTestEnv(testHome), stdin: "ignore", stdout: "pipe", stderr: "pipe" + }); + const [code, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); + return { code, stdout, stderr }; + }; + return { testHome, workspace, settings, put, run, close: () => rm(testHome, { recursive: true, force: true }) }; +} + +test("doctor checks a fresh profile without requiring login or creating settings and migration markers", async () => { + const f = await fixture(); + try { + const result = await f.run(); + expect(result.code, result.stderr).toBe(0); + const report = JSON.parse(result.stdout); + expect(report.cli.version).toBeString(); + expect(report.configuration.user).toMatchObject({ path: f.settings, status: "missing", mcpServerNames: [] }); + expect(report.configuration.mcp.servers).toEqual([]); + expect(await Bun.file(f.settings).exists()).toBe(false); + expect(await Bun.file(join(dirname(f.settings), "migrations", "settings-v1.json")).exists()).toBe(false); + } finally { await f.close(); } +}, 15000); + +test("doctor reads settings after migration, preserves native MCP precedence and never starts a server", async () => { + const f = await fixture(); + try { + const marker = join(f.testHome, "server-started"); + const http = { type: "http", url: "http://127.0.0.1:1/mcp?token=SECRET_VALUE", headers: { Authorization: "SECRET_VALUE" } }; + await f.put(f.settings, { mcp: { servers: { shared: http, local: { + type: "stdio", command: Bun.which("node")!, args: ["-e", `require('node:fs').writeFileSync(${JSON.stringify(marker)},'started')`], env: { TOKEN: "SECRET_VALUE" } + }, disabled: { ...http, enabled: false } } } }); + await f.put(join(dirname(f.settings), "migrations", "settings-v1.json"), { schemaVersion: 1 }); + await f.put(join(dirname(f.settings), "config.json"), { mcp: { servers: { legacy: http } } }); + await f.put(join(f.workspace, ".zcode", "config.json"), { mcp: { servers: { shared: { type: "stdio", command: "unused" }, project: http } } }); + await f.put(join(f.testHome, ".agents", "mcp.json"), { mcpServers: { userFallback: http } }); + await f.put(join(f.workspace, ".agents", "mcp.json"), { mcpServers: { projectFallback: http } }); + const before = await readFile(f.settings, "utf8"); + const result = await f.run(); + expect(result.code, result.stderr).toBe(0); + const report = JSON.parse(result.stdout).configuration; + expect(report.user.status).toBe("loaded"); + expect(report.project.status).toBe("loaded"); + expect(report.mcp.servers).toEqual([ + { name: "disabled", type: "http", enabled: false, source: "user" }, + { name: "local", type: "stdio", enabled: true, source: "user" }, + { name: "project", type: "http", enabled: true, source: "project" }, + { name: "shared", type: "http", enabled: true, source: "user" } + ]); + expect(report.legacy).toMatchObject({ exists: true, usage: "first-run-migration-only" }); + expect(result.stdout + result.stderr).not.toContain("SECRET_VALUE"); + expect(await Bun.file(marker).exists()).toBe(false); + expect(await readFile(f.settings, "utf8")).toBe(before); + const text = await f.run(["doctor"]); + expect(text.code).toBe(0); + expect(text.stdout).toContain("Configuration:"); + expect(text.stdout).toContain('"shared": http, user, enabled'); + expect(text.stdout).toContain("connections were not tested"); + } finally { await f.close(); } +}, 15000); + +test("doctor surfaces rejected settings and individual MCP entries without falling back to other files", async () => { + const f = await fixture(); + try { + const server = { type: "http", url: "http://127.0.0.1:1/mcp" }; + await f.put(join(f.testHome, ".agents", "mcp.json"), { mcpServers: { fallback: server } }); + await f.put(join(dirname(f.settings), "config.json"), { mcp: { servers: { legacy: server } } }); + await f.put(f.settings, { ui: { theme: "system" }, mcp: { servers: { valid: server } } }); + let result = await f.run(); + expect(result.code).toBe(1); + let report = JSON.parse(result.stdout).configuration; + expect(report.user.status).toBe("invalid"); + expect(report.mcp.servers).toEqual([]); + expect(report.diagnostics).toEqual(expect.arrayContaining([expect.objectContaining({ + code: "config_file_invalid", filePath: f.settings, message: expect.stringContaining("ui.theme") + })])); + await f.put(f.settings, { mcp: { servers: { valid: server, bad: { type: "stdio" } } } }); + result = await f.run(); + expect(result.code).toBe(1); + report = JSON.parse(result.stdout).configuration; + expect(report.user.status).toBe("loaded"); + expect(report.mcp.servers.map((s: { name: string }) => s.name)).toEqual(["valid"]); + expect(report.diagnostics).toEqual(expect.arrayContaining([expect.objectContaining({ + code: "config_mcp_server_invalid", path: "mcp.servers.bad", message: expect.stringContaining("command") + })])); + await writeFile(f.settings, '{"secret":"SECRET_VALUE", BROKEN'); + result = await f.run(); + expect(result.code).toBe(1); + report = JSON.parse(result.stdout).configuration; + expect(report.user.status).toBe("invalid"); + expect(result.stdout + result.stderr).not.toContain("SECRET_VALUE"); + expect(await readFile(f.settings, "utf8")).toContain("SECRET_VALUE"); + expect(await Bun.file(join(dirname(f.settings), "migrations", "settings-v1.json")).exists()).toBe(false); + await f.put(f.settings, { mcp: { servers: {} } }); + result = await f.run(); + expect(result.code).toBe(0); + expect(JSON.parse(result.stdout).configuration.mcp.servers).toEqual([]); + } finally { await f.close(); } +}, 15000);