1 Commits
Author SHA1 Message Date
总工 a2fd5e369b Wave 2.1: 返工黑屏/安装进度/官方配置三层/文档深度/真机列表
老板实测 5 条修复:DiagTab Hooks 黑屏、安装三阶段进度、配置按官方字段三层分组、FR-09 中文文档、detectCliAll 接入列表页。
2026-08-25 11:57:10 +08:00
23 changed files with 1821 additions and 198 deletions
+51 -17
View File
@@ -1,5 +1,5 @@
# ============================================================
# AgentDock 适配器 · Anthropic Claude CodeWave 2 全字段
# AgentDock 适配器 · Anthropic Claude CodeWave 2.1
# 数据依据:调研底稿 agent-cli-survey-2026-08-24.md §2 + 架构 §3.3
# ============================================================
id: claude-code
@@ -7,7 +7,7 @@ name: Claude Code
name_zh: Claude Code
vendor: Anthropic
status: available
adapter_version: 1.0.0
adapter_version: 1.1.0
license: 专有(仅官方渠道安装、不重打包、不修改二进制)
platforms:
@@ -72,6 +72,9 @@ authorization:
env_keys: [ANTHROPIC_API_KEY]
notes_zh: 设置 ANTHROPIC_API_KEY 后 CLI 优先使用 API Key
# 配置机制(调研底稿 §2「配置机制」):用户级 ~/.claude/settings.jsonJSON
# 官方确认:settings.json 内 env 块可注入环境变量;ANTHROPIC_API_KEY / ANTHROPIC_BASE_URL /
# ANTHROPIC_AUTH_TOKEN / ANTHROPIC_CUSTOM_HEADERS;默认模型经 /model 命令调整(非 settings.json 字段)。
configuration:
files:
- path: "~/.claude/settings.json"
@@ -81,31 +84,37 @@ configuration:
- key: ANTHROPIC_API_KEY
sensitive: true
maps_to_field: api_key
- key: ANTHROPIC_BASE_URL
sensitive: false
maps_to_field: base_url
- key: ANTHROPIC_AUTH_TOKEN
sensitive: true
maps_to_field: auth_token
fields:
- id: model
label_zh: 默认模型
help_zh: Claude Code 使用的默认模型
required: false
sensitive: false
type: string
storage: file
- id: env.ANTHROPIC_BASE_URL
label_zh: Base URL
help_zh: 自定义 API 端点 / 网关 / 代理(写入 settings.json 的 env 块)
group: advanced
help_zh: 官方环境变量 ANTHROPIC_BASE_URL,写入 settings.json 的 env 块(自定义 API 端点/网关/代理)
required: false
sensitive: false
type: url
storage: file
docs_url: https://code.claude.com/docs/en/env-vars
- id: api_key
label_zh: API Key
help_zh: 存入系统密钥库(对应 ANTHROPIC_API_KEY
group: auth
help_zh: 存入系统密钥库(对应 ANTHROPIC_API_KEY),设置后优先于订阅登录
required: false
sensitive: true
type: string
storage: keyring
docs_url: https://code.claude.com/docs/en/env-vars
- id: auth_token
label_zh: Bearer 令牌
group: auth
help_zh: 可选,存入系统密钥库(对应 ANTHROPIC_AUTH_TOKENBearer 头);一般用户无需填写
required: false
sensitive: true
type: string
storage: keyring
docs_url: https://code.claude.com/docs/en/env-vars
diagnostics:
- rule_id: path.not_installed
@@ -115,13 +124,38 @@ diagnostics:
documentation:
quickstart_zh: 安装后运行 `claude` 登录,或用 `claude -p "提示词"` 无头执行。
install_zh: 推荐 winget`winget install Anthropic.ClaudeCode`);也可走官方 PowerShell 脚本(irm https://claude.ai/install.ps1)或 npm`@anthropic-ai/claude-code`Node 22+)。
auth_zh: ① 浏览器 OAuth:运行 `claude` 按提示登录(Pro/Max/Team/Enterprise/Console 账号;免费版不含 Claude Code);② API Key:设置 ANTHROPIC_API_KEY 后 CLI 优先使用(非交互 -p 模式下有 key 即直接使用)。
commands:
- cmd: claude
desc_zh: 启动交互式会话
- cmd: claude -p "提示词"
desc_zh: 无头单次执行
desc_zh: 无头单次执行--print
- cmd: claude -p "提示词" --output-format json
desc_zh: 结构化 JSON 输出
- cmd: claude --allowedTools "Bash(git:*)"
desc_zh: 预授权指定工具
- cmd: claude --continue
desc_zh: 续接最近的会话
- cmd: claude --resume <会话ID>
desc_zh: 恢复指定会话
- cmd: claude doctor
desc_zh: 环境诊断
updated_at: "2026-08-24"
- cmd: claude --version
desc_zh: 显示版本号
params:
- param: -p / --print
desc_zh: 非交互模式,执行后退出
- param: --output-format
desc_zh: 输出格式(text / json / stream-json
- param: --json-schema
desc_zh: 约束输出为指定 JSON Schema
- param: --allowedTools
desc_zh: 预授权工具列表
- param: --bare
desc_zh: 极简启动模式
updated_at: "2026-08-25"
risks_zh:
- 专有许可:只引导官方渠道安装,不重打包、不修改二进制
- 专有许可:只引导官方渠道安装,不重打包、不修改二进制、不代付费
- 原生 Windows 无沙箱功能,需要沙箱请使用 WSL2(以官方文档为准)
- 默认模型经 /model 命令调整,非 settings.json 字段,故本表单未提供模型字段
+67 -12
View File
@@ -1,5 +1,5 @@
# ============================================================
# AgentDock 适配器 · OpenAI Codex CLIWave 2 全字段
# AgentDock 适配器 · OpenAI Codex CLIWave 2.1
# 数据依据:调研底稿 agent-cli-survey-2026-08-24.md §1 + 架构 §3.3
# ============================================================
id: codex
@@ -7,7 +7,7 @@ name: Codex CLI
name_zh: Codex CLI
vendor: OpenAI
status: available
adapter_version: 1.0.0
adapter_version: 1.1.0
license: Apache-2.0
platforms:
@@ -76,6 +76,10 @@ authorization:
env_keys: [OPENAI_API_KEY]
notes_zh: API Key 从系统密钥库读取,经 stdin 注入,不进 argv
# 配置机制(调研底稿 §1「配置机制」):用户级 ~/.codex/config.tomlTOML
# 官方确认可配:model / approval_policy / sandbox_mode / model_reasoning_effort /
# web_search / log_dir / [model_providers.<id>]base_url / env_key / wire_api/
# 简化键 openai_base_url。表单字段名与官方文档一致。
configuration:
files:
- path: "~/.codex/config.toml"
@@ -85,31 +89,52 @@ configuration:
- key: OPENAI_API_KEY
sensitive: true
maps_to_field: api_key
- key: OPENAI_BASE_URL
sensitive: false
maps_to_field: openai_base_url
fields:
- id: model
label_zh: 默认模型
help_zh: Codex 使用的默认模型(如 gpt-5.6-terra
group: common
help_zh: 官方键 model,如 gpt-5.6-terra(对应官方 config-basic 文档)
required: false
sensitive: false
type: string
storage: file
docs_url: https://learn.chatgpt.com/docs/config-file/config-basic
- id: approval_policy
label_zh: 审批策略
group: advanced
help_zh: 官方键 approval_policy,控制执行命令前是否需人工确认
required: false
sensitive: false
type: enum
storage: file
docs_url: https://learn.chatgpt.com/docs/config-file/config-basic
options:
- value: untrusted
label_zh: 不信任(全部确认)
- value: on-failure
label_zh: 失败时确认
- value: on-request
label_zh: 每次请求确认
- value: never
label_zh: 永不确认
- id: openai_base_url
label_zh: Base URL
help_zh: 自定义 OpenAI 兼容端点(简化键)
group: advanced
help_zh: 官方简化键 openai_base_url,自定义 OpenAI 兼容端点/网关/代理
required: false
sensitive: false
type: url
storage: file
docs_url: https://learn.chatgpt.com/docs/config-file/config-advanced
- id: api_key
label_zh: API Key
help_zh: 存入系统密钥库,写入后仅显示「已保存」
group: auth
help_zh: 存入系统密钥库(对应环境变量 OPENAI_API_KEY),写入后仅显示「已保存」;也可用 codex login --with-api-key 注入
required: false
sensitive: true
type: string
storage: keyring
docs_url: https://learn.chatgpt.com/docs/config-file/config-advanced
diagnostics:
- rule_id: path.not_installed
@@ -118,14 +143,44 @@ diagnostics:
- rule_id: config.corrupt
documentation:
quickstart_zh: 安装后运行 `codex` 登录账号,或用 `codex exec "任务"` 无头执行。
quickstart_zh: 安装后运行 `codex` 登录 ChatGPT 账号,或用 `codex exec "任务"` 无头执行。
install_zh: 推荐 `npm install -g @openai/codex`(本适配器默认渠道);也可走官方 PowerShell 脚本(irm https://chatgpt.com/codex/install.ps1)或 GitHub Releases 二进制。
auth_zh: 三种授权:① 浏览器登录 ChatGPT 账号(`codex login`,需订阅计划);② 设备码(`codex login --device-auth`);③ API Key`codex login --with-api-key`,从 stdin 读取,或经 OPENAI_API_KEY 环境变量)。
commands:
- cmd: codex
desc_zh: 启动交互式会话
- cmd: codex exec "提示词"
desc_zh: 无头单次执行
desc_zh: 无头单次执行(别名 codex e
- cmd: codex exec --json "提示词"
desc_zh: 以 JSONL 事件流输出
- cmd: codex exec --output-schema <schema>
desc_zh: 约束输出为指定 JSON Schema
- cmd: codex exec -o 文件
desc_zh: 将输出写入文件
- cmd: codex exec --sandbox
desc_zh: 在沙箱中执行
- cmd: codex login
desc_zh: 浏览器登录 ChatGPT 账号
- cmd: codex login --device-auth
desc_zh: 设备码流程登录
- cmd: codex login --with-api-key
desc_zh: 从 stdin 读取 API Key 登录
- cmd: codex login status
desc_zh: 查询登录状态
updated_at: "2026-08-24"
desc_zh: 查询当前登录状态
params:
- param: --json
desc_zh: 以 JSONL 事件流输出(exec 子命令)
- param: --output-schema
desc_zh: 约束输出为指定 JSON Schema
- param: -o / --output
desc_zh: 输出写入文件
- param: --ephemeral
desc_zh: 使用不落盘的临时会话
- param: --sandbox
desc_zh: 在沙箱中执行
- param: --device-auth
desc_zh: 使用设备码流程登录(login 子命令)
updated_at: "2026-08-25"
risks_zh:
- --version 官方文档未确认,适配器已标 version_unconfirmed 并实测兜底
- 表单覆盖常用配置键;model_providers 表等高级自定义 provider 请以官方文档为准(见上方链接)
+43 -11
View File
@@ -1,5 +1,5 @@
# ============================================================
# AgentDock 适配器 · Google Gemini CLIWave 2 全字段
# AgentDock 适配器 · Google Gemini CLIWave 2.1
# 数据依据:调研底稿 agent-cli-survey-2026-08-24.md §3 + 架构 §3.3
# ============================================================
id: gemini
@@ -7,7 +7,7 @@ name: Gemini CLI
name_zh: Gemini CLI
vendor: Google
status: available
adapter_version: 1.0.0
adapter_version: 1.1.0
license: Apache-2.0
platforms:
@@ -61,6 +61,9 @@ authorization:
env_keys: [GEMINI_API_KEY]
notes_zh: 设置 GEMINI_API_KEY 环境变量(AI Studio 申请)
# 配置机制(调研底稿 §3「配置机制」):用户级 ~/.gemini/settings.jsonJSON,官方发布 JSON Schema
# 关键键:model.name(默认模型)、security.auth.selectedType(认证方式);
# 环境变量:GEMINI_API_KEY / GOOGLE_GEMINI_BASE_URL(须 HTTPS/ GEMINI_MODEL。
configuration:
files:
- path: "~/.gemini/settings.json"
@@ -70,31 +73,34 @@ configuration:
- key: GEMINI_API_KEY
sensitive: true
maps_to_field: api_key
- key: GOOGLE_GEMINI_BASE_URL
sensitive: false
maps_to_field: base_url
fields:
- id: model.name
label_zh: 默认模型
help_zh: Gemini CLI 使用的默认模型
group: common
help_zh: 官方键 model.namesettings.json 中的默认模型
required: false
sensitive: false
type: string
storage: file
docs_url: https://geminicli.com/docs/reference/configuration/
- id: env.GOOGLE_GEMINI_BASE_URL
label_zh: Base URL
help_zh: 覆盖 Gemini API 默认地址(须 HTTPS,代理/网关场景)
group: advanced
help_zh: 官方环境变量 GOOGLE_GEMINI_BASE_URL(覆盖 Gemini API 默认地址,须 HTTPS,代理/网关场景);写入 settings.json 的 env 块
required: false
sensitive: false
type: url
storage: file
docs_url: https://geminicli.com/docs/reference/configuration/
- id: api_key
label_zh: API Key
help_zh: 存入系统密钥库(对应 GEMINI_API_KEY
group: auth
help_zh: 存入系统密钥库(对应环境变量 GEMINI_API_KEYAI Studio 申请)
required: false
sensitive: true
type: string
storage: keyring
docs_url: https://geminicli.com/docs/get-started/authentication/
diagnostics:
- rule_id: path.not_installed
@@ -104,13 +110,39 @@ diagnostics:
- rule_id: config.corrupt
documentation:
quickstart_zh: 安装后运行 `gemini` 登录,或用 `gemini -p "提示词"` 无头执行。
quickstart_zh: 安装后运行 `gemini` 选「Sign in with Google」登录,或用 `gemini -p "提示词"` 无头执行。
install_zh: 推荐 `npm install -g @google/gemini-cli`(需 Node 20+);免安装可用 `npx @google/gemini-cli` 临时试用。
auth_zh: ① Google 账号 OAuth:运行 `gemini` 选「Sign in with Google」浏览器登录(免费档 60 请求/分钟);② API Key:设置 GEMINI_API_KEY 环境变量(AI Studio 申请,免费档 1000 请求/天);③ Vertex AI 走 ADC/服务账号(本表单不覆盖)。
commands:
- cmd: gemini
desc_zh: 启动交互式会话
- cmd: gemini -p "提示词"
desc_zh: 强制非交互单次执行
updated_at: "2026-08-24"
desc_zh: 强制非交互模式,单次执行
- cmd: gemini -p "提示词" --output-format json
desc_zh: 以 JSON 输出结果
- cmd: gemini -m <模型>
desc_zh: 指定模型
- cmd: gemini -i
desc_zh: 执行后进入交互模式
- cmd: gemini --version
desc_zh: 显示版本号
- cmd: npx @google/gemini-cli
desc_zh: 免安装临时运行
- cmd: GEMINI_API_KEY=<key> gemini
desc_zh: 用 API Key 环境变量启动
params:
- param: -p / --prompt
desc_zh: 强制非交互模式
- param: --output-format
desc_zh: 输出格式(text / json / stream-json
- param: -m / --model
desc_zh: 指定模型
- param: -i
desc_zh: 执行后转交互
- param: -v / --version
desc_zh: 显示版本号并退出
updated_at: "2026-08-25"
risks_zh:
- 官方发行形态为 npm 包,依赖 Node 20+,适配器已声明运行时门槛
- Windows 官方要求 11 24H2+,低版本 Win10 显示「官方未支持」
- Vertex AIADC/服务账号)与 security.auth.selectedType 高级配置本表单不覆盖,以官方文档为准
+44 -10
View File
@@ -1,5 +1,5 @@
# ============================================================
# AgentDock 适配器 · Kimi CLI(月之暗面 Moonshot)(Wave 2 全字段
# AgentDock 适配器 · Kimi CLI(月之暗面 Moonshot)(Wave 2.1
# 数据依据:调研底稿 agent-cli-survey-2026-08-24.mdKimi 章节)+ 架构 §3.3
# 铁律:只走官方 code.kimi.com 脚本 / PyPI kimi-cli,严禁 npm(仿名包)。
# ============================================================
@@ -8,7 +8,7 @@ name: Kimi CLI
name_zh: Kimi CLI
vendor: 月之暗面
status: available
adapter_version: 1.0.0
adapter_version: 1.1.0
license: Apache-2.0
platforms:
@@ -78,6 +78,9 @@ authorization:
env_keys: [KIMI_API_KEY]
notes_zh: Moonshot 开放平台 API Key
# 配置机制(调研底稿 Kimi 章节):~/.kimi/config.tomlTOML,兼容 JSON
# 可配 default_model、providerstype/base_url/api_key)、models 等;
# 环境变量:KIMI_API_KEY / KIMI_BASE_URL / KIMI_MODEL_NAME / KIMI_MODEL_TEMPERATURE。
configuration:
files:
- path: "~/.kimi/config.toml"
@@ -87,31 +90,34 @@ configuration:
- key: KIMI_API_KEY
sensitive: true
maps_to_field: api_key
- key: KIMI_BASE_URL
sensitive: false
maps_to_field: base_url
fields:
- id: default_model
label_zh: 默认模型
help_zh: Kimi CLI 使用的默认模型
group: common
help_zh: 官方键 default_modelKimi CLI 使用的默认模型
required: false
sensitive: false
type: string
storage: file
docs_url: https://moonshotai.github.io/kimi-cli/en/configuration/config-files.html
- id: env.KIMI_BASE_URL
label_zh: Base URL
help_zh: 自定义 OpenAI 兼容端点
group: advanced
help_zh: 官方环境变量 KIMI_BASE_URL(自定义 OpenAI 兼容端点);写入 config.toml 的 env 表,若需进程级生效请以官方 env-vars 文档为准
required: false
sensitive: false
type: url
storage: file
docs_url: https://moonshotai.github.io/kimi-cli/en/configuration/env-vars.html
- id: api_key
label_zh: API Key
help_zh: 存入系统密钥库(对应 KIMI_API_KEY
group: auth
help_zh: 存入系统密钥库(对应环境变量 KIMI_API_KEYMoonshot 开放平台获取)
required: false
sensitive: true
type: string
storage: keyring
docs_url: https://moonshotai.github.io/kimi-cli/en/configuration/env-vars.html
diagnostics:
- rule_id: path.not_installed
@@ -123,14 +129,42 @@ diagnostics:
documentation:
quickstart_zh: 安装后运行 `kimi login` 登录,或用 `kimi -p "提示词"` 无头执行。
install_zh: 只走官方渠道:`uv tool install kimi-cli`(本适配器默认)或官方脚本 code.kimi.com/install.shinstall.ps1)。脚本会自动装 uv 并从 PyPI kimi-cli 安装。严禁 npmnpm 上的 kimi-code 是第三方仿名包)。
auth_zh: ① Kimi Code 浏览器 OAuth(推荐,`kimi login`);② Moonshot 开放平台 API Keyplatform 侧获取后粘贴,经 KIMI_API_KEY 注入)。`kimi logout` 清除 OAuth 凭据。
commands:
- cmd: kimi
desc_zh: 启动交互式会话
- cmd: kimi login
desc_zh: 登录(浏览器 OAuth 或 API Key
- cmd: kimi logout
desc_zh: 清除 OAuth 凭据
- cmd: kimi -p "提示词"
desc_zh: 无头单次执行
updated_at: "2026-08-24"
- cmd: kimi --output-format stream-json -p "提示词"
desc_zh: print 模式 JSON 流输出
- cmd: kimi --quiet
desc_zh: 静默模式
- cmd: kimi --version
desc_zh: 显示版本号
- cmd: kimi info
desc_zh: 显示版本与协议信息
- cmd: kimi acp
desc_zh: IDE 集成 stdioACP 协议)
params:
- param: -p / --prompt
desc_zh: 处理后退出(无头模式)
- param: --print
desc_zh: 打印模式
- param: --output-format
desc_zh: 输出格式(如 stream-json
- param: --quiet
desc_zh: 静默模式
- param: --afk
desc_zh: 离键模式
- param: --config-file
desc_zh: 指定配置文件路径
updated_at: "2026-08-25"
risks_zh:
- 严禁 npmnpm 上的 kimi-code 是第三方仿名包,与月之暗面无关
- 官方脚本会自动安装 uv 并从 PyPI kimi-cli 安装
- 官方脚本会自动安装 uv 并从 PyPI kimi-cli 安装Python 3.123.14
- providers 表(type/base_url/api_key 多 provider)等高级配置本表单不覆盖,以官方 config-files 文档为准
+46 -6
View File
@@ -1,5 +1,5 @@
# ============================================================
# AgentDock 适配器 · OpenCodeopencode.ai)(Wave 2 全字段
# AgentDock 适配器 · OpenCodeopencode.ai)(Wave 2.1
# 数据依据:调研底稿 agent-cli-survey-2026-08-24.mdOpenCode 章节)+ 架构 §3.3
# ============================================================
id: opencode
@@ -7,7 +7,7 @@ name: OpenCode
name_zh: OpenCode
vendor: opencode.ai
status: available
adapter_version: 1.0.0
adapter_version: 1.1.0
license: MIT
platforms:
@@ -80,6 +80,9 @@ authorization:
- mode: device_code
notes_zh: GitHub Copilot 设备码登录(github.com/login/device 输码)
# 配置机制(调研底稿 OpenCode 章节):全局 ~/.config/opencode/opencode.jsonJSON,带注释 jsonc 亦可)
# model 字段设默认模型;每个 provider 可单独改 baseURL(含本地 Ollama/LM Studio 等 OpenAI 兼容端点);
# API Key 用环境变量引用接入,也可在 TUI 用 /connect 交互保存(凭据落 ~/.local/share/opencode/auth.json)。
configuration:
files:
- path: "~/.config/opencode/opencode.json"
@@ -95,18 +98,31 @@ configuration:
fields:
- id: model
label_zh: 默认模型
help_zh: OpenCode 使用的默认模型
group: common
help_zh: 官方键 model,全局默认模型(如 anthropic/claude-sonnet-4
required: false
sensitive: false
type: string
storage: file
docs_url: https://opencode.ai/docs/config/
- id: provider
label_zh: 默认 Provider
group: common
help_zh: 官方键 provider,指定默认模型提供方(可选;留空则用模型名推断)
required: false
sensitive: false
type: string
storage: file
docs_url: https://opencode.ai/docs/providers/
- id: api_key
label_zh: API Key
help_zh: 存入系统密钥库(对应 OPENAI_API_KEY / ANTHROPIC_API_KEY
group: auth
help_zh: 存入系统密钥库(对应 OPENAI_API_KEY / ANTHROPIC_API_KEY 等环境变量);也可在 TUI 用 /connect 交互保存
required: false
sensitive: true
type: string
storage: keyring
docs_url: https://opencode.ai/docs/providers/
diagnostics:
- rule_id: path.not_installed
@@ -115,14 +131,38 @@ diagnostics:
- rule_id: config.corrupt
documentation:
quickstart_zh: 安装后运行 `opencode` `opencode run "提示词"` 无头执行。
quickstart_zh: 安装后运行 `opencode`,或用 `opencode run "提示词"` 无头执行。
install_zh: 多渠道:npm `opencode-ai`(本适配器默认)、Windows choco/scoop、Linux 官方脚本、Homebrew。桌面版另有原生 .exe 安装器。
auth_zh: 授权面最全:① 纯 API Key(各家环境变量);② 浏览器 OAuthClaude Pro/Max、ChatGPT 订阅);③ 设备码(GitHub Copilotgithub.com/login/device 输码);④ 企业级 OAuth。不绑定特定厂商。
commands:
- cmd: opencode
desc_zh: 启动交互式会话
- cmd: opencode run "提示词"
desc_zh: 无头单次执行
- cmd: opencode run "提示词" --model <模型>
desc_zh: 指定模型无头执行
- cmd: opencode auth login
desc_zh: 浏览器 OAuth 登录
- cmd: opencode auth list
desc_zh: 查看已配置凭据
updated_at: "2026-08-24"
- cmd: opencode upgrade
desc_zh: 自升级
- cmd: opencode serve
desc_zh: 无头 HTTP 服务器模式
- cmd: opencode --version
desc_zh: 显示版本号
params:
- param: run "提示词"
desc_zh: 一次性执行后退出
- param: --model
desc_zh: 指定模型
- param: --format json
desc_zh: JSON 事件流输出
- param: --auto-approve
desc_zh: 自动批准权限请求
- param: -v / --version
desc_zh: 显示版本号
updated_at: "2026-08-25"
risks_zh:
- 官方未文档化安装包校验(SHA256/签名),适配器不强制校验
- 每个 provider 的 baseURL 高级配置(本地 Ollama/LM Studio 等)本表单不覆盖,以官方 providers 文档为准
+13 -1
View File
@@ -7,7 +7,7 @@
use std::collections::BTreeMap;
use agentdock_adapter::{AdapterAction, DryRunPlan};
use agentdock_core::{ActionEvent, ActionOpts, AuthStatus, ConfigFormState, DetectResult, Engine, WriteResult};
use agentdock_core::{ActionEvent, ActionOpts, AuthStatus, ConfigFormState, ConfigVerifyResult, DetectResult, Engine, WriteResult};
use agentdock_diag::DiagnosticReport;
use serde_json::json;
use tauri::Emitter;
@@ -37,6 +37,12 @@ pub fn detect_cli(id: String, state: tauri::State<'_, Engine>) -> Result<DetectR
state.detect(&id).map_err(|e| e.to_string())
}
/// 批量检测全部 CLIdetectCliAll),供总览/目录/我的 CLI 接真机状态。
#[tauri::command(rename = "detectCliAll")]
pub fn detect_cli_all(state: tauri::State<'_, Engine>) -> Result<Vec<DetectResult>, String> {
state.detect_all().map_err(|e| e.to_string())
}
/// 干燥运行(previewAction):返回将执行的命令与影响面,不真正执行。
#[tauri::command(rename = "previewAction")]
pub fn preview_action(
@@ -107,6 +113,12 @@ pub fn write_config(
state.write_config(&id, &patch).map_err(|e| e.to_string())
}
/// 配置写入后的「生效检查」(verifyConfig):重读配置文件 + CLI 版本探测。
#[tauri::command(rename = "verifyConfig")]
pub fn verify_config(id: String, state: tauri::State<'_, Engine>) -> Result<ConfigVerifyResult, String> {
state.verify_config(&id).map_err(|e| e.to_string())
}
/// 授权状态(authStatus)。
#[tauri::command(rename = "authStatus")]
pub fn auth_status(id: String, state: tauri::State<'_, Engine>) -> Result<AuthStatus, String> {
+2
View File
@@ -48,11 +48,13 @@ pub fn run() {
commands::env::detect_env,
commands::catalog::list_catalog,
commands::cli::detect_cli,
commands::cli::detect_cli_all,
commands::cli::get_adapter,
commands::cli::preview_action,
commands::cli::run_action,
commands::cli::read_config,
commands::cli::write_config,
commands::cli::verify_config,
commands::cli::auth_status,
commands::cli::diagnose,
])
+2 -2
View File
@@ -43,11 +43,11 @@ export default function App() {
}
switch (page) {
case "overview":
return <OverviewPage onNavigate={navigate} />;
return <OverviewPage onNavigate={navigate} onOpenDetail={setDetailCliId} />;
case "catalog":
return <CatalogPage onOpenDetail={setDetailCliId} />;
case "my-cli":
return <MyCliPage onNavigate={navigate} />;
return <MyCliPage onNavigate={navigate} onOpenDetail={setDetailCliId} />;
case "config":
return <ConfigCenterPage onNavigate={navigate} />;
case "backup":
+151 -26
View File
@@ -1,10 +1,32 @@
import { useEffect, useMemo, useState } from "react";
import { Check, Eye, EyeOff, Lock } from "lucide-react";
import { readConfig, writeConfig } from "../ipc";
import type { ConfigFieldState, ConfigFormState, WriteResult } from "../ipc/types";
import { Check, ChevronDown, CircleAlert, ExternalLink, Eye, EyeOff, Lock, ShieldCheck } from "lucide-react";
import { readConfig, verifyConfig, writeConfig } from "../ipc";
import type {
ConfigFieldState,
ConfigFormState,
ConfigVerifyResult,
WriteResult,
} from "../ipc/types";
import { MonoChip } from "./MonoChip";
/** 配置表单(视觉规范 §3.4:单列 ≤640px、敏感字段密码框 + 密钥库说明、吸底保存条) */
/** 官方授权方式中文名 */
function authModeLabel(mode: string): string {
switch (mode) {
case "browser_oauth":
return "账号授权(浏览器)";
case "device_code":
return "设备码";
case "api_key":
return "API Key";
case "local_tui":
return "本机终端授权";
default:
return mode;
}
}
/** 配置表单(视觉规范 §3.4:单列 ≤640px、敏感字段密码框 + 密钥库说明、吸底保存条)
* Wave 2.1:字段按官方配置方法渲染 + 保存后「生效检查」 */
export function ConfigForm({ id }: { id: string }) {
const [state, setState] = useState<ConfigFormState | null>(null);
const [values, setValues] = useState<Record<string, string>>({});
@@ -13,7 +35,9 @@ export function ConfigForm({ id }: { id: string }) {
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [result, setResult] = useState<WriteResult | null>(null);
const [verify, setVerify] = useState<ConfigVerifyResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [advancedOpen, setAdvancedOpen] = useState(false);
useEffect(() => {
let cancelled = false;
@@ -52,11 +76,29 @@ export function ConfigForm({ id }: { id: string }) {
if (!state) return null;
const hasFields = state.fields.length > 0;
const authFields = state.fields.filter((f) => f.group === "auth");
const commonFields = state.fields.filter((f) => f.group === "common");
const advancedFields = state.fields.filter((f) => f.group === "advanced");
function renderField(field: ConfigFieldState) {
return (
<Field
key={field.id}
field={field}
value={values[field.id] ?? ""}
saved={sensitiveSaved[field.id] ?? false}
revealed={!!revealed[field.id]}
onToggleReveal={() => setRevealed((r) => ({ ...r, [field.id]: !r[field.id] }))}
onChange={(v) => setValues((x) => ({ ...x, [field.id]: v }))}
/>
);
}
async function onSave() {
setSaving(true);
setSaved(false);
setResult(null);
setVerify(null);
setError(null);
const patch: Record<string, string> = {};
for (const f of state!.fields) {
@@ -78,6 +120,13 @@ export function ConfigForm({ id }: { id: string }) {
// 刷新回显
const s = await readConfig(id);
setState(s);
// 生效检查:写回的配置能否被 CLI 接受(配置文件解析 + CLI 版本探测)
try {
const v = await verifyConfig(id);
setVerify(v);
} catch {
setVerify(null);
}
} catch (e) {
setError(String(e));
} finally {
@@ -89,17 +138,59 @@ export function ConfigForm({ id }: { id: string }) {
<div className="config-form">
{!hasFields && <p className="panel-empty"> CLI </p>}
{state.fields.map((field) => (
<Field
key={field.id}
field={field}
value={values[field.id] ?? ""}
saved={sensitiveSaved[field.id] ?? false}
revealed={!!revealed[field.id]}
onToggleReveal={() => setRevealed((r) => ({ ...r, [field.id]: !r[field.id] }))}
onChange={(v) => setValues((x) => ({ ...x, [field.id]: v }))}
/>
))}
{/* 第一层:授权 / 登录(官方授权方式引导 + API Key 类字段) */}
{(state.auth_modes.length > 0 || authFields.length > 0) && (
<section className="config-section">
<h4 className="config-section-title"> / </h4>
{state.auth_modes.length > 0 && (
<ul className="auth-modes">
{state.auth_modes.map((m) => (
<li key={m.mode} className="auth-mode">
<span className="auth-mode-name">{authModeLabel(m.mode)}</span>
{m.notes_zh && <span className="auth-mode-note">{m.notes_zh}</span>}
{m.command.length > 0 && (
<span className="auth-mode-cmd">
<MonoChip>{m.command.join(" ")}</MonoChip>
</span>
)}
</li>
))}
</ul>
)}
{authFields.map(renderField)}
</section>
)}
{/* 第二层:常用配置 */}
{commonFields.length > 0 && (
<section className="config-section">
<h4 className="config-section-title"></h4>
{commonFields.map(renderField)}
</section>
)}
{/* 第三层:高级配置(默认折叠) */}
{advancedFields.length > 0 && (
<section className="config-section">
<button
type="button"
className="config-advanced-toggle"
onClick={() => setAdvancedOpen((v) => !v)}
aria-expanded={advancedOpen}
>
<span className="config-section-title"></span>
<span className="advanced-badge"></span>
<ChevronDown
size={16}
strokeWidth={1.5}
className={advancedOpen ? "advanced-chevron open" : "advanced-chevron"}
aria-hidden="true"
/>
</button>
<p className="advanced-hint">使</p>
{advancedOpen && <div className="advanced-fields">{advancedFields.map(renderField)}</div>}
</section>
)}
{state.files.length > 0 && (
<div className="config-file-info">
@@ -121,6 +212,16 @@ export function ConfigForm({ id }: { id: string }) {
<Check size={14} strokeWidth={1.5} aria-hidden="true" />
</span>
)}
{verify && (
<span className={verify.ok ? "verify-ok" : "verify-fail"}>
{verify.ok ? (
<ShieldCheck size={14} strokeWidth={1.5} aria-hidden="true" />
) : (
<CircleAlert size={14} strokeWidth={1.5} aria-hidden="true" />
)}
{verify.message_zh}
</span>
)}
{result?.backup_path && (
<span className="save-detail">
· {result.backup_path.split(/[\\/]/).pop()}
@@ -158,7 +259,8 @@ function Field({
onToggleReveal: () => void;
}) {
const isPassword = field.sensitive;
const inputType = isPassword ? (revealed ? "text" : "password") : field.field_type === "url" ? "text" : "text";
const isEnum = field.field_type === "enum";
const inputType = isPassword ? (revealed ? "text" : "password") : "text";
return (
<div className="field">
<label className="field-label" htmlFor={`field-${field.id}`}>
@@ -171,15 +273,31 @@ function Field({
<Lock size={12} strokeWidth={1.5} aria-hidden="true" />
</span>
)}
<input
id={`field-${field.id}`}
type={inputType}
value={value}
placeholder={isPassword ? (saved ? "已保存 · 留空则不变" : "输入 API Key") : ""}
autoComplete="off"
spellCheck={false}
onChange={(e) => onChange(e.target.value)}
/>
{isEnum ? (
<select
id={`field-${field.id}`}
className="field-select"
value={value}
onChange={(e) => onChange(e.target.value)}
>
<option value="">使 CLI </option>
{field.options.map((o) => (
<option key={o.value} value={o.value}>
{o.label_zh}{o.value}
</option>
))}
</select>
) : (
<input
id={`field-${field.id}`}
type={inputType}
value={value}
placeholder={isPassword ? (saved ? "已保存 · 留空则不变" : "输入 API Key") : ""}
autoComplete="off"
spellCheck={false}
onChange={(e) => onChange(e.target.value)}
/>
)}
{isPassword && (
<button
type="button"
@@ -196,9 +314,16 @@ function Field({
{field.help_zh}
{isPassword && (
<span className="field-keyring-note">
{saved ? " · 已加密保存于系统密钥库" : " · 保存后写入系统密钥库,绝不明文落盘"}
{saved
? ` · 已加密保存于系统密钥库${field.env_key ? `${field.env_key}` : ""}`
: ` · 保存后写入系统密钥库,绝不明文落盘${field.env_key ? `${field.env_key}` : ""}`}
</span>
)}
{field.docs_url && (
<a className="field-docs-link" href={field.docs_url} target="_blank" rel="noreferrer">
<ExternalLink size={11} strokeWidth={1.5} aria-hidden="true" />
</a>
)}
</div>
</div>
);
@@ -0,0 +1,43 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
interface Props {
children: ReactNode;
}
interface State {
error: Error | null;
}
/** 顶层错误边界:任何渲染崩溃都不再整屏黑屏,而是给出可恢复提示 */
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
// 记录到控制台便于排查,不含敏感信息
console.error("[agentdock] render error:", error, info.componentStack);
}
render() {
if (this.state.error) {
return (
<div className="crash-screen">
<h1 className="crash-title"></h1>
<p className="crash-desc"></p>
<p className="crash-detail">{this.state.error.message}</p>
<button
type="button"
className="btn btn-primary"
onClick={() => this.setState({ error: null })}
>
</button>
</div>
);
}
return this.props.children;
}
}
+37
View File
@@ -0,0 +1,37 @@
import { useCallback, useEffect, useState } from "react";
import { detectCliAll } from "../ipc";
import type { DetectResult } from "../ipc/types";
export interface UseDetectAllResult {
detectMap: Record<string, DetectResult>;
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
}
/** 批量检测全部 CLI(总览/目录/我的 CLI 接真机状态) */
export function useDetectAll(): UseDetectAllResult {
const [detectMap, setDetectMap] = useState<Record<string, DetectResult>>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
try {
const list = await detectCliAll();
const map: Record<string, DetectResult> = {};
for (const d of list) map[d.cli_id] = d;
setDetectMap(map);
setError(null);
} catch (err) {
setError(String(err));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
return { detectMap, loading, error, refresh };
}
+120
View File
@@ -8,6 +8,7 @@ import type {
CliAction,
CliActionEvent,
ConfigFormState,
ConfigVerifyResult,
DetectResult,
DiagnosticReport,
DryRunPlan,
@@ -33,6 +34,7 @@ export async function listCatalog(): Promise<CatalogEntry[]> {
// ---- Wave 2CLI 全链路 ----
export async function getAdapter(id: string): Promise<Adapter> {
if (!isTauri()) return mockAdapter(id);
return invoke<Adapter>("getAdapter", { id });
}
@@ -41,19 +43,27 @@ export async function detectCli(id: string): Promise<DetectResult> {
return invoke<DetectResult>("detectCli", { id });
}
export async function detectCliAll(): Promise<DetectResult[]> {
if (!isTauri()) return mockCatalog().map((c) => mockDetect(c.id));
return invoke<DetectResult[]>("detectCliAll");
}
export async function previewAction(
id: string,
action: CliAction,
channel?: string,
): Promise<DryRunPlan> {
if (!isTauri()) return mockDryRun(id, action);
return invoke<DryRunPlan>("previewAction", { id, action, channel });
}
export async function runAction(id: string, action: CliAction, channel?: string): Promise<void> {
if (!isTauri()) return;
return invoke<void>("runAction", { id, action, channel });
}
export async function readConfig(id: string): Promise<ConfigFormState> {
if (!isTauri()) return mockConfig(id);
return invoke<ConfigFormState>("readConfig", { id });
}
@@ -61,15 +71,30 @@ export async function writeConfig(
id: string,
patch: Record<string, string>,
): Promise<WriteResult> {
if (!isTauri()) return { backup_path: null, written_file: "~/.codex/config.toml", written_fields: Object.keys(patch), errors: [] };
return invoke<WriteResult>("writeConfig", { id, patch });
}
export async function verifyConfig(id: string): Promise<ConfigVerifyResult> {
if (!isTauri()) {
return {
cli_id: id,
ok: true,
level: "config_parsed",
message_zh: "配置文件已写入且可解析;CLI 未安装,未做 CLI 实际校验(以官方文档为准)。",
detail: null,
};
}
return invoke<ConfigVerifyResult>("verifyConfig", { id });
}
export async function authStatus(id: string): Promise<AuthStatus> {
if (!isTauri()) return mockAuth(id);
return invoke<AuthStatus>("authStatus", { id });
}
export async function diagnose(id: string): Promise<DiagnosticReport> {
if (!isTauri()) return { cli_id: id, findings: [] };
return invoke<DiagnosticReport>("diagnose", { id });
}
@@ -136,3 +161,98 @@ const mockPlatformEnv = (): PlatformEnv => ({
path_entries: ["C:\\Windows\\system32", "C:\\Windows", "C:\\Program Files\\nodejs"],
capabilities: { keyring: "ok", can_elevate: false },
});
const mockAdapter = (id: string): Adapter => ({
id,
name: "Codex CLI",
name_zh: "Codex CLI",
vendor: "OpenAI",
status: "available",
adapter_version: "1.1.0",
license: "Apache-2.0",
platforms: {
windows: { architectures: ["x64"], notes: "原生支持(PowerShell 脚本安装)" },
linux: { distributions: ["ubuntu"], architectures: ["x64"] },
},
official: {
homepage: "https://github.com/openai/codex",
docs: "https://learn.chatgpt.com/docs/codex/cli",
allowed_hosts: ["chatgpt.com"],
},
runtime_deps: [],
install: {
preferred: "npm",
channels: [
{ id: "npm", platforms: ["windows", "linux"], command: ["npm", "install", "-g", "@openai/codex"], package: "@openai/codex", elevate: "never", post_checks: ["detect"] },
],
},
detect: { executable: "codex", version_args: ["--version"], version_regex: "^codex-cli (\\d+\\.\\d+\\.\\d+)", version_unconfirmed: true },
update: { method: "npm_update", command: ["npm", "update", "-g", "@openai/codex"] },
uninstall: { method: "npm_uninstall", command: ["npm", "uninstall", "-g", "@openai/codex"], keep_config_default: true },
authorization: {
modes: [
{ mode: "browser_oauth", command: ["codex", "login"], status_command: ["codex", "login", "status"], notes_zh: "浏览器登录 ChatGPT 账号" },
{ mode: "device_code", command: ["codex", "login", "--device-auth"], notes_zh: "设备码登录" },
{ mode: "api_key", command: ["codex", "login", "--with-api-key"], env_keys: ["OPENAI_API_KEY"], notes_zh: "API Key 经 stdin 注入" },
],
},
configuration: {
files: [{ path: "~/.codex/config.toml", format: "toml", scope: "user" }],
environment: [{ key: "OPENAI_API_KEY", sensitive: true, maps_to_field: "api_key" }],
fields: [
{ id: "model", label_zh: "默认模型", help_zh: "官方键 model,如 gpt-5.6-terra", type: "string", storage: "file", docs_url: "https://learn.chatgpt.com/docs/config-file/config-basic" },
{ id: "approval_policy", label_zh: "审批策略", help_zh: "官方键 approval_policy", type: "enum", storage: "file", docs_url: "https://learn.chatgpt.com/docs/config-file/config-basic", options: [{ value: "untrusted", label_zh: "不信任(全部确认)" }, { value: "on-failure", label_zh: "失败时确认" }, { value: "never", label_zh: "永不确认" }] },
{ id: "openai_base_url", label_zh: "Base URL", help_zh: "官方简化键 openai_base_url", type: "url", storage: "file", docs_url: "https://learn.chatgpt.com/docs/config-file/config-advanced" },
{ id: "api_key", label_zh: "API Key", help_zh: "存入系统密钥库(对应 OPENAI_API_KEY", sensitive: true, type: "string", storage: "keyring", docs_url: "https://learn.chatgpt.com/docs/config-file/config-advanced" },
],
},
documentation: {
quickstart_zh: "安装后运行 `codex` 登录,或用 `codex exec \"任务\"` 无头执行。",
install_zh: "推荐 `npm install -g @openai/codex`;也可走官方 PowerShell 脚本或 GitHub Releases 二进制。",
auth_zh: "三种授权:浏览器登录、设备码、API Key。",
commands: [
{ cmd: "codex", desc_zh: "启动交互式会话" },
{ cmd: "codex exec \"提示词\"", desc_zh: "无头单次执行" },
{ cmd: "codex exec --json \"提示词\"", desc_zh: "JSONL 事件流输出" },
{ cmd: "codex exec --output-schema <schema>", desc_zh: "约束输出 JSON Schema" },
{ cmd: "codex exec -o 文件", desc_zh: "输出写入文件" },
{ cmd: "codex exec --sandbox", desc_zh: "沙箱执行" },
{ cmd: "codex login", desc_zh: "浏览器登录" },
{ cmd: "codex login status", desc_zh: "查询登录状态" },
],
params: [
{ param: "--json", desc_zh: "JSONL 事件流输出" },
{ param: "--output-schema", desc_zh: "约束输出 JSON Schema" },
{ param: "-o / --output", desc_zh: "输出写入文件" },
{ param: "--ephemeral", desc_zh: "临时会话" },
{ param: "--sandbox", desc_zh: "沙箱执行" },
],
updated_at: "2026-08-25",
risks_zh: ["--version 官方文档未确认,适配器已标 version_unconfirmed 并实测兜底"],
},
});
const mockConfig = (id: string): ConfigFormState => ({
cli_id: id,
files: [{ path: "~/.codex/config.toml", format: "toml", scope: "user", exists: false, parse_ok: true, error: null }],
fields: [
{ id: "model", label_zh: "默认模型", help_zh: "官方键 model,如 gpt-5.6-terra", required: false, sensitive: false, field_type: "string", storage: "file", value: null, has_value: false, docs_url: "https://learn.chatgpt.com/docs/config-file/config-basic", options: [], env_key: null, group: "common" },
{ id: "approval_policy", label_zh: "审批策略", help_zh: "官方键 approval_policy", required: false, sensitive: false, field_type: "enum", storage: "file", value: null, has_value: false, docs_url: "https://learn.chatgpt.com/docs/config-file/config-basic", options: [{ value: "untrusted", label_zh: "不信任(全部确认)" }, { value: "on-failure", label_zh: "失败时确认" }, { value: "never", label_zh: "永不确认" }], env_key: null, group: "advanced" },
{ id: "openai_base_url", label_zh: "Base URL", help_zh: "官方简化键 openai_base_url", required: false, sensitive: false, field_type: "url", storage: "file", value: null, has_value: false, docs_url: "https://learn.chatgpt.com/docs/config-file/config-advanced", options: [], env_key: null, group: "advanced" },
{ id: "api_key", label_zh: "API Key", help_zh: "存入系统密钥库(对应 OPENAI_API_KEY", required: false, sensitive: true, field_type: "string", storage: "keyring", value: null, has_value: false, docs_url: "https://learn.chatgpt.com/docs/config-file/config-advanced", options: [], env_key: "OPENAI_API_KEY", group: "auth" },
],
environment: [{ key: "OPENAI_API_KEY", sensitive: true, maps_to_field: "api_key" }],
auth_modes: [
{ mode: "browser_oauth", notes_zh: "浏览器登录 ChatGPT 账号(需订阅计划)", command: ["codex", "login"] },
{ mode: "device_code", notes_zh: "设备码登录,不弹浏览器", command: ["codex", "login", "--device-auth"] },
{ mode: "api_key", notes_zh: "API Key 从系统密钥库读取,经 stdin 注入", command: [] },
],
});
const mockDryRun = (_id: string, action: CliAction): DryRunPlan => ({
commands: action === "install" ? [["npm", "install", "-g", "@openai/codex"]] : [["npm", "uninstall", "-g", "@openai/codex"]],
elevate: false,
elevate_reason_zh: null,
affected_files: ["~/.codex/config.toml"],
rollback_zh: action === "install" ? "可执行卸载命令回退;配置文件默认保留。" : "卸载默认保留配置,可重新安装恢复。",
});
+30
View File
@@ -97,6 +97,15 @@ export interface ConfigFieldState {
storage: string;
value: string | null;
has_value: boolean;
docs_url: string | null;
options: ConfigFieldOption[];
env_key: string | null;
group: "auth" | "common" | "advanced" | string;
}
export interface ConfigFieldOption {
value: string;
label_zh: string;
}
export interface ConfigFileState {
@@ -119,6 +128,13 @@ export interface ConfigFormState {
files: ConfigFileState[];
fields: ConfigFieldState[];
environment: EnvFieldState[];
auth_modes: AuthModeInfo[];
}
export interface AuthModeInfo {
mode: string;
notes_zh: string | null;
command: string[];
}
export interface WriteResult {
@@ -128,6 +144,14 @@ export interface WriteResult {
errors: string[];
}
export interface ConfigVerifyResult {
cli_id: string;
ok: boolean;
level: "cli_accepted" | "config_parsed" | "config_parse_failed" | "not_installed";
message_zh: string;
detail: string | null;
}
export interface DryRunPlan {
commands: string[][];
elevate: boolean;
@@ -141,6 +165,7 @@ export type ActionEventKind = "step" | "stdout" | "stderr" | "done" | "error";
export interface ActionEvent {
kind: ActionEventKind;
message: string;
phase: string | null;
data: unknown;
}
@@ -220,11 +245,16 @@ export interface Adapter {
storage: string;
platforms?: string[];
docs_url?: string | null;
options?: { value: string; label_zh: string }[];
group?: string | null;
}[];
} | null;
documentation?: {
quickstart_zh?: string | null;
install_zh?: string | null;
auth_zh?: string | null;
commands?: { cmd: string; desc_zh?: string | null }[];
params?: { param: string; desc_zh?: string | null }[];
updated_at?: string | null;
risks_zh?: string[];
} | null;
+4 -1
View File
@@ -1,6 +1,7 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { ErrorBoundary } from "./components/ErrorBoundary";
// 设计 Token(视觉规范 v1.2,唯一品味依据)
import "./tokens/color.css";
@@ -25,6 +26,8 @@ applyFxTier();
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
<ErrorBoundary>
<App />
</ErrorBoundary>
</React.StrictMode>,
);
+48 -31
View File
@@ -1,8 +1,10 @@
import { useMemo, useState } from "react";
import { Search } from "lucide-react";
import { useCatalog } from "../hooks/useCatalog";
import { useDetectAll } from "../hooks/useDetectAll";
import { StatusBadge } from "../components/StatusBadge";
import { CliMonogram } from "../components/CliMonogram";
import { MonoChip } from "../components/MonoChip";
type StatusFilter = "all" | "installed" | "uninstalled" | "update";
type PlatformFilter = "all" | "windows" | "linux";
@@ -20,9 +22,10 @@ const PLATFORM_FILTERS: { key: PlatformFilter; label: string }[] = [
{ key: "linux", label: "Linux" },
];
/** CLI 目录页(视觉规范 §3.2 + PRD §7):搜索 + 筛选 + 14 张卡片(数据来自 catalog.yaml */
/** CLI 目录页(视觉规范 §3.2 + PRD §7):搜索 + 筛选 + 14 张卡片(数据来自 catalog.yaml + 真机 detect */
export function CatalogPage({ onOpenDetail }: { onOpenDetail: (id: string) => void }) {
const { entries, loading, error } = useCatalog();
const { detectMap, loading: detecting } = useDetectAll();
const [query, setQuery] = useState("");
const [status, setStatus] = useState<StatusFilter>("all");
const [platform, setPlatform] = useState<PlatformFilter>("all");
@@ -34,14 +37,17 @@ export function CatalogPage({ onOpenDetail }: { onOpenDetail: (id: string) => vo
const haystack = `${e.name_zh} ${e.name} ${e.vendor} ${e.id}`.toLowerCase();
if (!haystack.includes(q)) return false;
}
// Wave 0:14 个均为未安装;已安装/可更新筛选结果为空(诚实占位)
if (status === "installed") return false;
const installed = detectMap[e.id]?.status === "installed";
// 状态筛选:接真机 detect 结果
if (status === "installed" && !installed) return false;
if (status === "uninstalled" && installed) return false;
// 可更新:Wave 2.1 不检测可更新,恒为空(诚实)
if (status === "update") return false;
// 平台:全部 14 个均支持 Windows + Ubuntu(调研底稿结论),筛选不改变集合
if (platform === "windows" || platform === "linux") return true;
return true;
});
}, [entries, query, status, platform]);
}, [entries, query, status, platform, detectMap]);
return (
<div className="catalog">
@@ -86,34 +92,45 @@ export function CatalogPage({ onOpenDetail }: { onOpenDetail: (id: string) => vo
{loading && !error && <p className="panel-empty"></p>}
<div className="catalog-grid">
{filtered.map((entry) => (
<article
className="catalog-card"
key={entry.id}
role="button"
tabIndex={0}
onClick={() => onOpenDetail(entry.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpenDetail(entry.id);
}
}}
>
<div className="catalog-card-top">
<CliMonogram id={entry.id} name={entry.name} size={40} />
<div className="catalog-card-head">
<div className="catalog-card-name">{entry.name_zh}</div>
<div className="catalog-card-vendor">{entry.vendor}</div>
{filtered.map((entry) => {
const detect = detectMap[entry.id];
const installed = detect?.status === "installed";
return (
<article
className="catalog-card"
key={entry.id}
role="button"
tabIndex={0}
onClick={() => onOpenDetail(entry.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpenDetail(entry.id);
}
}}
>
<div className="catalog-card-top">
<CliMonogram id={entry.id} name={entry.name} size={40} />
<div className="catalog-card-head">
<div className="catalog-card-name">{entry.name_zh}</div>
<div className="catalog-card-vendor">{entry.vendor}</div>
</div>
</div>
</div>
<p className="catalog-card-desc"> · </p>
<div className="catalog-card-bottom">
<StatusBadge kind="uninstalled" label="未安装" />
<span className="catalog-platforms">Windows · Linux</span>
</div>
</article>
))}
<p className="catalog-card-desc"> · </p>
<div className="catalog-card-bottom">
{installed ? (
<span className="catalog-version">
<StatusBadge kind="installed" label="已安装" />
{detect?.version && <MonoChip>{detect.version}</MonoChip>}
</span>
) : (
<StatusBadge kind="uninstalled" label={detecting ? "检测中…" : "未安装"} />
)}
<span className="catalog-platforms">Windows · Linux</span>
</div>
</article>
);
})}
{!loading && filtered.length === 0 && (
<p className="panel-empty catalog-empty"> CLI</p>
)}
+238 -35
View File
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import {
ArrowLeft,
BookOpen,
Check,
ChevronDown,
CircleHelp,
ClipboardList,
@@ -35,6 +36,12 @@ const TABS: { key: Tab; label: string }[] = [
{ key: "diag", label: "诊断" },
];
type RunPhase = "prepare" | "exec" | "verify";
type RunOutcome =
| { status: "success"; action: CliAction; detect: DetectResult | null }
| { status: "failed"; action: CliAction; message: string };
/** 授权状态灯(§3.3:已授权=绿、未授权=紫、可能过期=黄呼吸、未知=灰) */
function AuthLight({ auth }: { auth: AuthStatus | null }) {
if (!auth) return <span className="status-dot unknown" aria-hidden="true" />;
@@ -90,7 +97,9 @@ export function CliDetailPage({ id, onBack }: { id: string; onBack: () => void }
const [running, setRunning] = useState(false);
const [runTitle, setRunTitle] = useState("");
const [log, setLog] = useState<ActionEvent[]>([]);
const logEndRef = useRef<HTMLDivElement | null>(null);
const [phase, setPhase] = useState<RunPhase>("prepare");
const [outcome, setOutcome] = useState<RunOutcome | null>(null);
const currentActionRef = useRef<CliAction>("install");
const installed = detect?.status === "installed";
@@ -102,14 +111,18 @@ export function CliDetailPage({ id, onBack }: { id: string; onBack: () => void }
const ev = payload.event;
if (ev.kind === "done") {
setRunning(false);
setPhase("verify");
const d = (ev.data as DetectResult | null) ?? null;
setOutcome({ status: "success", action: currentActionRef.current, detect: d });
void refresh();
return;
}
if (ev.kind === "error") {
setRunning(false);
setLog((l) => [...l, { kind: "error", message: ev.message, data: null }]);
setOutcome({ status: "failed", action: currentActionRef.current, message: ev.message });
return;
}
if (ev.phase) setPhase(ev.phase as RunPhase);
setLog((l) => [...l, ev]);
}).then((fn) => {
unlisten = fn;
@@ -117,10 +130,6 @@ export function CliDetailPage({ id, onBack }: { id: string; onBack: () => void }
return () => unlisten?.();
}, [id, refresh]);
useEffect(() => {
logEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [log]);
async function openConfirm(action: CliAction) {
const plan = await previewAction(id, action);
setConfirm({ action, plan });
@@ -129,13 +138,22 @@ export function CliDetailPage({ id, onBack }: { id: string; onBack: () => void }
async function confirmRun() {
if (!confirm) return;
const action = confirm.action;
currentActionRef.current = action;
setConfirm(null);
setRunning(true);
setOutcome(null);
setPhase("prepare");
setLog([]);
setRunTitle(action === "install" ? "正在安装" : action === "uninstall" ? "正在卸载" : "正在执行");
await runAction(id, action);
}
function closeRun() {
setRunning(false);
setOutcome(null);
setLog([]);
}
if (loading) {
return <p className="panel-empty"></p>;
}
@@ -220,7 +238,7 @@ export function CliDetailPage({ id, onBack }: { id: string; onBack: () => void }
</nav>
<section className="cli-detail-body">
{tab === "overview" && <OverviewTab adapter={adapter} detect={detect} />}
{tab === "overview" && <OverviewTab adapter={adapter} detect={detect} auth={auth} />}
{tab === "config" && <ConfigForm id={id} />}
{tab === "docs" && <DocsTab adapter={adapter} />}
{tab === "diag" && <DiagTab id={id} />}
@@ -246,32 +264,109 @@ export function CliDetailPage({ id, onBack }: { id: string; onBack: () => void }
</Modal>
)}
{/* 流式执行日志弹窗 */}
{running && (
<Modal title={`${runTitle} ${adapter.name_zh}`} footer={null}>
<div className="run-log" aria-live="polite">
{log.length === 0 && (
<div className="run-log-wait">
<Loader2 size={16} strokeWidth={1.5} className="spin" aria-hidden="true" />
</div>
)}
{log.map((l, i) => (
<div key={i} className={`run-log-line ${l.kind}`}>
{l.kind === "step" ? <ClipboardList size={13} strokeWidth={1.5} aria-hidden="true" /> : null}
{l.kind === "stdout" ? <Terminal size={13} strokeWidth={1.5} aria-hidden="true" /> : null}
{l.kind === "stderr" ? <ShieldAlert size={13} strokeWidth={1.5} aria-hidden="true" /> : null}
<span>{l.message}</span>
</div>
))}
<div ref={logEndRef} />
</div>
{/* 流式执行日志弹窗(安装/卸载全程可见:步骤进度 + stdout/stderr + 终态) */}
{(running || outcome) && (
<Modal
title={outcome ? `${runTitle} ${adapter.name_zh} · ${outcome.status === "success" ? "完成" : "失败"}` : `${runTitle} ${adapter.name_zh}`}
onClose={running ? undefined : closeRun}
footer={
running ? null : (
<button type="button" className="btn btn-primary" onClick={closeRun}>
</button>
)
}
>
<RunBody phase={phase} log={log} outcome={outcome} />
</Modal>
)}
</div>
);
}
/** 执行进度三阶段(准备 / 执行 / 复检) */
function PhaseSteps({ phase }: { phase: RunPhase }) {
const steps: { key: RunPhase; label: string }[] = [
{ key: "prepare", label: "准备" },
{ key: "exec", label: "执行" },
{ key: "verify", label: "复检" },
];
const order: Record<RunPhase, number> = { prepare: 0, exec: 1, verify: 2 };
const cur = order[phase];
return (
<div className="run-phases" aria-label="安装进度">
{steps.map((s, i) => (
<div key={s.key} className={`run-phase ${i < cur ? "done" : i === cur ? "active" : ""}`}>
<span className="run-phase-dot">{i < cur ? "✓" : i + 1}</span>
<span className="run-phase-label">{s.label}</span>
</div>
))}
</div>
);
}
function RunBody({
phase,
log,
outcome,
}: {
phase: RunPhase;
log: ActionEvent[];
outcome: RunOutcome | null;
}) {
return (
<div className="run-body">
<PhaseSteps phase={phase} />
<div className="run-log" aria-live="polite">
{log.length === 0 && !outcome && (
<div className="run-log-wait">
<Loader2 size={16} strokeWidth={1.5} className="spin" aria-hidden="true" />
</div>
)}
{log.map((l, i) => (
<div key={i} className={`run-log-line ${l.kind}`}>
{l.kind === "step" ? <ClipboardList size={13} strokeWidth={1.5} aria-hidden="true" /> : null}
{l.kind === "stdout" ? <Terminal size={13} strokeWidth={1.5} aria-hidden="true" /> : null}
{l.kind === "stderr" ? <ShieldAlert size={13} strokeWidth={1.5} aria-hidden="true" /> : null}
<span>{l.message}</span>
</div>
))}
<div ref={useAutoScroll(log)} />
</div>
{outcome?.status === "success" && (
<div className="run-result success">
<Check size={14} strokeWidth={1.5} aria-hidden="true" />
<span>
{outcome.action === "install" ? "安装成功" : outcome.action === "uninstall" ? "卸载完成" : "执行完成"}
</span>
{outcome.detect?.status === "installed" && (
<span className="run-result-version">
{outcome.detect.version ?? ""}
{outcome.detect.executable ? ` · ${outcome.detect.executable}` : ""}
</span>
)}
</div>
)}
{outcome?.status === "failed" && (
<div className="run-result failed">
<ShieldAlert size={14} strokeWidth={1.5} aria-hidden="true" />
<span>{outcome.message}</span>
</div>
)}
</div>
);
}
/** 自动滚动到底部(把 ref 挂在日志末尾) */
function useAutoScroll(dep: unknown) {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
ref.current?.scrollIntoView({ behavior: "smooth" });
}, [dep]);
return ref;
}
function ConfirmBody({ plan, action }: { plan: DryRunPlan; action: CliAction }) {
return (
<div className="confirm-body">
@@ -320,8 +415,34 @@ function ConfirmBody({ plan, action }: { plan: DryRunPlan; action: CliAction })
);
}
function OverviewTab({ adapter, detect }: { adapter: Adapter; detect: DetectResult | null }) {
function OverviewTab({
adapter,
detect,
auth,
}: {
adapter: Adapter;
detect: DetectResult | null;
auth: AuthStatus | null;
}) {
const channels = adapter.install?.channels ?? [];
const authModes = adapter.authorization?.modes ?? [];
const platforms: string[] = [];
if (adapter.platforms?.windows) platforms.push("Windows");
if (adapter.platforms?.linux) platforms.push("Ubuntu");
const rows: { label: string; value: ReactNode }[] = [
{ label: "厂商", value: adapter.vendor },
{ label: "版本", value: detect?.version ? <MonoChip>{detect.version}</MonoChip> : detectLabel(detect) },
{ label: "路径", value: detect?.executable ? <MonoChip>{detect.executable}</MonoChip> : "—" },
{ label: "授权状态", value: auth ? auth.detail_zh : "—" },
{ label: "支持平台", value: platforms.length > 0 ? platforms.join(" · ") : "—" },
{
label: "授权方式",
value: authModes.length > 0 ? authModes.map((m) => authModeLabel(m.mode)).join(" / ") : "—",
},
{ label: "适配器版本", value: adapter.adapter_version ?? "—" },
];
return (
<div className="detail-overview">
<div className="detail-block">
@@ -330,6 +451,19 @@ function OverviewTab({ adapter, detect }: { adapter: Adapter; detect: DetectResu
{adapter.documentation?.quickstart_zh ?? "参见官方文档。"}
</p>
</div>
<div className="detail-block">
<h3 className="detail-block-title"></h3>
<dl className="detail-meta">
{rows.map((r) => (
<div key={r.label} className="detail-meta-row">
<dt className="detail-meta-label">{r.label}</dt>
<dd className="detail-meta-value">{r.value}</dd>
</div>
))}
</dl>
</div>
<div className="detail-block">
<h3 className="detail-block-title"></h3>
{channels.length === 0 ? (
@@ -346,6 +480,23 @@ function OverviewTab({ adapter, detect }: { adapter: Adapter; detect: DetectResu
</ul>
)}
</div>
<div className="detail-block">
<h3 className="detail-block-title"></h3>
<div className="detail-links">
{adapter.official?.homepage && (
<a className="detail-link" href={adapter.official.homepage} target="_blank" rel="noreferrer">
</a>
)}
{adapter.official?.docs && (
<a className="detail-link" href={adapter.official.docs} target="_blank" rel="noreferrer">
</a>
)}
</div>
</div>
{detect && detect.status !== "installed" && detect.status !== "not_installed" && (
<div className="detail-block detail-note">
<CircleHelp size={14} strokeWidth={1.5} aria-hidden="true" />
@@ -356,10 +507,44 @@ function OverviewTab({ adapter, detect }: { adapter: Adapter; detect: DetectResu
);
}
function authModeLabel(mode: string): string {
switch (mode) {
case "browser_oauth":
return "浏览器授权";
case "device_code":
return "设备码";
case "api_key":
return "API Key";
case "local_tui":
return "本机终端授权";
default:
return mode;
}
}
function DocsTab({ adapter }: { adapter: Adapter }) {
const doc = adapter.documentation;
const docUrl = adapter.official?.docs;
return (
<div className="detail-docs">
{doc?.quickstart_zh && (
<div className="detail-block">
<h3 className="detail-block-title"></h3>
<p className="detail-block-text">{doc.quickstart_zh}</p>
</div>
)}
{doc?.install_zh && (
<div className="detail-block">
<h3 className="detail-block-title"></h3>
<p className="detail-block-text">{doc.install_zh}</p>
</div>
)}
{doc?.auth_zh && (
<div className="detail-block">
<h3 className="detail-block-title"></h3>
<p className="detail-block-text">{doc.auth_zh}</p>
</div>
)}
<div className="detail-block">
<h3 className="detail-block-title">
<BookOpen size={14} strokeWidth={1.5} aria-hidden="true" />
@@ -377,6 +562,19 @@ function DocsTab({ adapter }: { adapter: Adapter }) {
</ul>
)}
</div>
{(doc?.params ?? []).length > 0 && (
<div className="detail-block">
<h3 className="detail-block-title"></h3>
<ul className="detail-commands">
{doc!.params!.map((p) => (
<li key={p.param} className="detail-command">
<MonoChip>{p.param}</MonoChip>
{p.desc_zh && <span className="detail-command-desc">{p.desc_zh}</span>}
</li>
))}
</ul>
</div>
)}
{(doc?.risks_zh ?? []).length > 0 && (
<div className="detail-block detail-note">
<ShieldAlert size={14} strokeWidth={1.5} aria-hidden="true" />
@@ -387,11 +585,16 @@ function DocsTab({ adapter }: { adapter: Adapter }) {
</div>
</div>
)}
{adapter.official?.homepage && (
<div className="detail-block detail-block-text">
<MonoChip>{adapter.official.homepage}</MonoChip>
</div>
)}
<div className="detail-block detail-doc-footer">
{docUrl && (
<a className="detail-link" href={docUrl} target="_blank" rel="noreferrer">
</a>
)}
<span className="detail-doc-meta">
{adapter.adapter_version ?? "—"} · {doc?.updated_at ?? "—"}
</span>
</div>
</div>
);
}
@@ -437,7 +640,7 @@ function DiagTab({ id }: { id: string }) {
if (error) return <p className="panel-empty">{error}</p>;
if (!report) return null;
const findings = useMemo(() => report.findings, [report]);
const findings = report.findings;
return (
<div className="diag-result">
+100 -13
View File
@@ -1,20 +1,107 @@
import { SquareTerminal } from "lucide-react";
import { useEffect, useState } from "react";
import { ChevronRight, SquareTerminal } from "lucide-react";
import { useCatalog } from "../hooks/useCatalog";
import { useDetectAll } from "../hooks/useDetectAll";
import { authStatus } from "../ipc";
import { CliMonogram } from "../components/CliMonogram";
import { MonoChip } from "../components/MonoChip";
import type { AuthStatus } from "../ipc/types";
import type { PageKey } from "../components/Sidebar";
/** 我的 CLI(PRD §7):版本 / 路径 / 授权状态。空态按 v1.3 §3.7。 */
export function MyCliPage({ onNavigate }: { onNavigate: (p: PageKey) => void }) {
/** 单个已安装 CLI 的授权状态(懒加载) */
function AuthBadge({ id }: { id: string }) {
const [auth, setAuth] = useState<AuthStatus | null>(null);
useEffect(() => {
let cancelled = false;
authStatus(id)
.then((a) => {
if (!cancelled) setAuth(a);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [id]);
if (!auth) return <span className="mycli-auth"></span>;
const ok = auth.status === "authorized";
return (
<div className="placeholder-page">
<div className="placeholder-icon">
<SquareTerminal size={40} strokeWidth={1.5} aria-hidden="true" />
<span className="mycli-auth">
<span className={ok ? "status-dot ok" : "status-dot unknown"} aria-hidden="true" />
{ok ? "已授权" : "未授权"}
</span>
);
}
/** 我的 CLI(PRD §7):展示已安装列表(版本 / 路径 / 授权状态)。空态按 v1.3 §3.7。 */
export function MyCliPage({
onNavigate,
onOpenDetail,
}: {
onNavigate: (p: PageKey) => void;
onOpenDetail: (id: string) => void;
}) {
const { entries } = useCatalog();
const { detectMap, loading } = useDetectAll();
const installed = entries.filter((e) => detectMap[e.id]?.status === "installed");
if (loading) {
return <p className="panel-empty"> CLI</p>;
}
if (installed.length === 0) {
return (
<div className="placeholder-page">
<div className="placeholder-icon">
<SquareTerminal size={40} strokeWidth={1.5} aria-hidden="true" />
</div>
<h2 className="placeholder-title"> CLI</h2>
<p className="placeholder-desc">
CLI
</p>
<button type="button" className="btn btn-primary" onClick={() => onNavigate("catalog")}>
CLI
</button>
</div>
);
}
return (
<div className="mycli">
<div className="panel mycli-list">
<div className="panel-head">
<h2 className="panel-title"> CLI{installed.length}</h2>
</div>
{installed.map((entry) => {
const d = detectMap[entry.id];
return (
<button
key={entry.id}
type="button"
className="mycli-row"
onClick={() => onOpenDetail(entry.id)}
>
<CliMonogram id={entry.id} name={entry.name} size={36} />
<div className="mycli-row-main">
<span className="mycli-row-name">{entry.name_zh}</span>
{d?.version && (
<span className="mycli-row-version">
<MonoChip>{d.version}</MonoChip>
</span>
)}
</div>
{d?.executable && (
<span className="mycli-row-path">
<MonoChip>{d.executable}</MonoChip>
</span>
)}
<AuthBadge id={entry.id} />
<ChevronRight size={16} strokeWidth={1.5} className="mycli-row-arrow" aria-hidden="true" />
</button>
);
})}
</div>
<h2 className="placeholder-title"> CLI</h2>
<p className="placeholder-desc">
CLI
</p>
<button type="button" className="btn btn-primary" onClick={() => onNavigate("catalog")}>
CLI
</button>
</div>
);
}
+27 -9
View File
@@ -2,11 +2,12 @@ import { useState } from "react";
import { Activity, Archive, Lock, Plus, ScanSearch } from "lucide-react";
import { useEnv } from "../hooks/useEnv";
import { useCatalog } from "../hooks/useCatalog";
import { useDetectAll } from "../hooks/useDetectAll";
import { KpiCard } from "../components/KpiCard";
import { MonoChip } from "../components/MonoChip";
import { StatusBadge } from "../components/StatusBadge";
import { CliMonogram } from "../components/CliMonogram";
import type { PlatformEnv, RuntimeInfo, RuntimeStatus, CatalogEntry } from "../ipc/types";
import type { CatalogEntry, DetectResult, PlatformEnv, RuntimeInfo, RuntimeStatus } from "../ipc/types";
import type { PageKey } from "../components/Sidebar";
/** 平台相关运行时集合(Windows 不看 aptLinux 不看 winget */
@@ -292,30 +293,38 @@ function QuickActions({
/** CLI 状态网格块(v1.3 §3.1.4;首跑精简为两行 + 安装入口,§3.1.5) */
function CliBlock({
entry,
detect,
compact,
onNavigate,
onOpenDetail,
}: {
entry: CatalogEntry;
detect: DetectResult | undefined;
compact: boolean;
onNavigate: (p: PageKey) => void;
onOpenDetail: (id: string) => void;
}) {
const installed = entry.status === "installed";
const installed = detect?.status === "installed";
return (
<div className="cli-block">
<div className="cli-block" role="button" tabIndex={0} onClick={() => onOpenDetail(entry.id)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onOpenDetail(entry.id); } }}>
<div className="cli-block-top">
<CliMonogram id={entry.id} name={entry.name} size={32} />
<span className="cli-block-name">{entry.name_zh}</span>
<StatusBadge kind={installed ? "installed" : "uninstalled"} label={installed ? "已安装" : "未安装"} />
</div>
<div className="cli-block-version">
{installed ? null : <span className="version-missing"></span>}
{installed ? (
detect?.version ? <MonoChip>{detect.version}</MonoChip> : null
) : (
<span className="version-missing"></span>
)}
</div>
{!compact && (
<div className="cli-block-auth">
{installed ? (
<>
<span className="status-dot ok" aria-hidden="true" />
<span></span>
<span>{detect?.version ? `v${detect.version}` : "已安装"}</span>
</>
) : (
<>
@@ -327,7 +336,7 @@ function CliBlock({
)}
{compact && (
<div className="cli-block-install">
<button type="button" className="link-btn" onClick={() => onNavigate("catalog")}>
<button type="button" className="link-btn" onClick={(e) => { e.stopPropagation(); onNavigate("catalog"); }}>
</button>
</div>
@@ -338,14 +347,16 @@ function CliBlock({
interface OverviewProps {
onNavigate: (p: PageKey) => void;
onOpenDetail: (id: string) => void;
}
/** 总览页(视觉规范 §3.1 + PRD §7):首跑空态 / KPI + 诊断/快速操作 + CLI 状态网格 + 本机环境 */
export function OverviewPage({ onNavigate }: OverviewProps) {
export function OverviewPage({ onNavigate, onOpenDetail }: OverviewProps) {
const { env } = useEnv();
const { entries } = useCatalog();
const { detectMap } = useDetectAll();
const installedCount = entries.filter((e) => e.status === "installed").length;
const installedCount = entries.filter((e) => detectMap[e.id]?.status === "installed").length;
const firstRun = installedCount === 0; // 无安装且无诊断记录(当前无诊断引擎)
const warningCount = env ? runtimeProblems(env).length : 0;
@@ -384,7 +395,14 @@ export function OverviewPage({ onNavigate }: OverviewProps) {
</div>
<div className="cli-status-grid">
{entries.map((entry) => (
<CliBlock key={entry.id} entry={entry} compact={firstRun} onNavigate={onNavigate} />
<CliBlock
key={entry.id}
entry={entry}
detect={detectMap[entry.id]}
compact={firstRun}
onNavigate={onNavigate}
onOpenDetail={onOpenDetail}
/>
))}
</div>
</section>
+390
View File
@@ -1144,6 +1144,7 @@ button {
.overview .overview-mid {
grid-column: 1 / 3;
grid-row: 2;
margin-bottom: 0;
}
@@ -1153,6 +1154,7 @@ button {
.overview .env-panel {
grid-column: 3;
grid-row: 2;
margin-top: 0;
}
}
@@ -1815,3 +1817,391 @@ button {
.save-error {
color: var(--ad-danger);
}
/* ============================================================
* Wave 2.1:安装进度 / 配置校验 / 详情信息 / 目录真机状态 / 我的 CLI
* 全部颜色来自 tokens/
* ============================================================ */
/* ---------- 执行进度三阶段(安装弹窗) ---------- */
.run-body {
display: flex;
flex-direction: column;
gap: var(--ad-space-4);
}
.run-phases {
display: flex;
align-items: center;
gap: var(--ad-space-2);
}
.run-phase {
display: flex;
align-items: center;
gap: var(--ad-space-2);
color: var(--ad-text-3);
font-size: var(--ad-text-s-size);
}
.run-phase-dot {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
border: 1px solid var(--ad-border);
font-size: var(--ad-text-xs-size);
}
.run-phase.done {
color: var(--ad-success);
}
.run-phase.done .run-phase-dot {
border-color: var(--ad-success);
color: var(--ad-success);
}
.run-phase.active {
color: var(--ad-primary);
}
.run-phase.active .run-phase-dot {
border-color: var(--ad-primary);
color: var(--ad-primary);
box-shadow: var(--ad-glow-primary);
}
.run-result {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--ad-space-2);
padding: var(--ad-space-3) var(--ad-space-4);
border-radius: var(--ad-radius-m);
font-size: var(--ad-text-s-size);
}
.run-result.success {
color: var(--ad-success);
border: 1px solid var(--ad-border);
background: var(--ad-bg-1);
}
.run-result.failed {
color: var(--ad-danger);
border: 1px solid var(--ad-border);
background: var(--ad-bg-1);
}
.run-result-version {
color: var(--ad-text-2);
font-family: var(--ad-font-mono);
font-size: var(--ad-text-xs-size);
word-break: break-all;
}
/* ---------- 详情概览:基本信息表 + 官方链接 ---------- */
.detail-meta {
display: flex;
flex-direction: column;
gap: var(--ad-space-2);
}
.detail-meta-row {
display: flex;
align-items: center;
gap: var(--ad-space-3);
}
.detail-meta-label {
width: 88px;
flex-shrink: 0;
color: var(--ad-text-2);
font-size: var(--ad-text-s-size);
}
.detail-meta-value {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--ad-space-2);
min-width: 0;
color: var(--ad-text-1);
font-size: var(--ad-text-m-size);
}
.detail-links {
display: flex;
gap: var(--ad-space-4);
}
.detail-link {
color: var(--ad-primary);
font-size: var(--ad-text-m-size);
text-decoration: none;
}
.detail-link:hover {
text-decoration: underline;
}
.detail-doc-footer {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--ad-space-3);
padding-top: var(--ad-space-4);
border-top: 1px solid var(--ad-border);
}
.detail-doc-meta {
color: var(--ad-text-3);
font-size: var(--ad-text-xs-size);
}
/* ---------- 配置表单:下拉 + 官方链接 + 校验结果 ---------- */
.field-select {
width: 100%;
height: 36px;
padding: 0 var(--ad-space-3);
border: 1px solid var(--ad-border);
border-radius: var(--ad-radius-m);
background: var(--ad-bg-0);
color: var(--ad-text-1);
font-size: var(--ad-text-m-size);
outline: none;
}
.field-select:focus {
border-color: var(--ad-primary);
box-shadow: var(--ad-glow-primary);
}
.field-docs-link {
display: inline-flex;
align-items: center;
gap: 2px;
margin-left: var(--ad-space-2);
color: var(--ad-primary);
text-decoration: none;
}
.field-docs-link:hover {
text-decoration: underline;
}
.verify-ok {
display: inline-flex;
align-items: flex-start;
gap: var(--ad-space-1);
color: var(--ad-success);
font-size: var(--ad-text-xs-size);
}
.verify-fail {
display: inline-flex;
align-items: flex-start;
gap: var(--ad-space-1);
color: var(--ad-danger);
font-size: var(--ad-text-xs-size);
}
/* ---------- 目录真机版本位 ---------- */
.catalog-version {
display: inline-flex;
align-items: center;
gap: var(--ad-space-2);
}
/* ---------- 我的 CLI 列表 ---------- */
.mycli {
max-width: var(--ad-frame-max);
margin: 0 auto;
}
.mycli-list {
padding: var(--ad-space-4);
}
.mycli-row {
display: flex;
align-items: center;
gap: var(--ad-space-3);
width: 100%;
padding: var(--ad-space-3) var(--ad-space-2);
border: none;
border-bottom: 1px solid var(--ad-border);
background: none;
cursor: pointer;
text-align: left;
}
.mycli-row:last-child {
border-bottom: none;
}
.mycli-row:hover {
background: var(--ad-bg-3);
}
.mycli-row-main {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.mycli-row-name {
color: var(--ad-text-1);
font-size: var(--ad-text-m-size);
}
.mycli-row-version {
display: inline-flex;
align-items: center;
gap: var(--ad-space-2);
color: var(--ad-text-2);
font-size: var(--ad-text-xs-size);
}
.mycli-row-path {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mycli-auth {
display: inline-flex;
align-items: center;
gap: var(--ad-space-2);
color: var(--ad-text-2);
font-size: var(--ad-text-xs-size);
white-space: nowrap;
}
.mycli-row-arrow {
color: var(--ad-text-3);
}
/* ---------- 错误边界(防黑屏) ---------- */
.crash-screen {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--ad-space-4);
padding: var(--ad-space-8);
}
.crash-title {
font-size: var(--ad-text-xl-size);
font-weight: var(--ad-text-xl-weight);
}
.crash-desc {
color: var(--ad-text-2);
font-size: var(--ad-text-m-size);
}
.crash-detail {
color: var(--ad-text-3);
font-family: var(--ad-font-mono);
font-size: var(--ad-text-xs-size);
word-break: break-all;
}
/* ---------- 配置表单三层分组(授权/常用/高级) ---------- */
.config-section {
display: flex;
flex-direction: column;
gap: var(--ad-space-3);
}
.config-section-title {
font-size: var(--ad-text-m-size);
font-weight: var(--ad-text-m-weight);
color: var(--ad-text-1);
}
.auth-modes {
list-style: none;
display: flex;
flex-direction: column;
gap: var(--ad-space-2);
padding: var(--ad-space-3);
margin: 0;
border: 1px solid var(--ad-border);
border-radius: var(--ad-radius-m);
background: var(--ad-bg-1);
}
.auth-mode {
display: flex;
align-items: flex-start;
flex-wrap: wrap;
gap: var(--ad-space-2);
}
.auth-mode-name {
color: var(--ad-primary);
font-size: var(--ad-text-s-size);
white-space: nowrap;
}
.auth-mode-note {
flex: 1;
min-width: 0;
color: var(--ad-text-2);
font-size: var(--ad-text-s-size);
}
.auth-mode-cmd {
color: var(--ad-text-3);
}
.config-advanced-toggle {
display: flex;
align-items: center;
gap: var(--ad-space-2);
padding: 0;
border: none;
background: none;
cursor: pointer;
text-align: left;
}
.advanced-badge {
display: inline-flex;
align-items: center;
height: 18px;
padding: 0 var(--ad-space-2);
border-radius: var(--ad-radius-s);
background: var(--ad-attention);
color: var(--ad-bg-0);
font-size: var(--ad-text-xs-size);
}
.advanced-chevron {
color: var(--ad-text-2);
transition: transform var(--ad-dur-fast) var(--ad-ease-out);
}
.advanced-chevron.open {
transform: rotate(180deg);
}
.advanced-hint {
color: var(--ad-text-3);
font-size: var(--ad-text-xs-size);
}
.advanced-fields {
display: flex;
flex-direction: column;
gap: var(--ad-space-5);
}
+29
View File
@@ -252,6 +252,21 @@ pub struct ConfigField {
pub platforms: Vec<String>,
#[serde(rename = "docs_url", default)]
pub docs_url: Option<String>,
/// enum 类型的可选值(value + 中文名)
#[serde(default)]
pub options: Vec<FieldOption>,
/// 表单分组:auth(授权/登录)| common(常用配置)| advanced(高级配置,默认折叠)
/// 未声明时按 common 处理
#[serde(default)]
pub group: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct FieldOption {
pub value: String,
#[serde(rename = "label_zh")]
pub label_zh: String,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
@@ -266,8 +281,14 @@ pub struct Diagnostic {
pub struct Documentation {
#[serde(rename = "quickstart_zh", default)]
pub quickstart_zh: Option<String>,
#[serde(rename = "install_zh", default)]
pub install_zh: Option<String>,
#[serde(rename = "auth_zh", default)]
pub auth_zh: Option<String>,
#[serde(default)]
pub commands: Vec<DocCommand>,
#[serde(default)]
pub params: Vec<DocParam>,
#[serde(rename = "updated_at", default)]
pub updated_at: Option<String>,
#[serde(rename = "risks_zh", default)]
@@ -282,6 +303,14 @@ pub struct DocCommand {
pub desc_zh: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct DocParam {
pub param: String,
#[serde(rename = "desc_zh", default)]
pub desc_zh: Option<String>,
}
fn default_true() -> bool {
true
}
+283 -16
View File
@@ -56,6 +56,12 @@ impl Engine {
Ok(detect_one(&adapter))
}
/// 批量检测全部适配器(总览/目录/我的 CLI 接入真机状态用)。
pub fn detect_all(&self) -> Result<Vec<DetectResult>, EngineError> {
let adapters = self.adapters()?;
Ok(adapters.iter().map(detect_one).collect())
}
// ---- dry-run ----
pub fn preview(&self, id: &str, action: AdapterAction) -> Result<DryRunPlan, EngineError> {
@@ -75,6 +81,12 @@ impl Engine {
write_config(&adapter, patch, self.secrets.as_ref())
}
/// 配置写入后的「生效检查」:重读配置文件 + 解析 +(若已安装)CLI 版本探测。
pub fn verify_config(&self, id: &str) -> Result<ConfigVerifyResult, EngineError> {
let adapter = self.adapter(id)?;
Ok(verify_config(&adapter))
}
// ---- 授权 ----
pub fn auth_status(&self, id: &str) -> Result<AuthStatus, EngineError> {
@@ -262,14 +274,24 @@ pub fn read_config(adapter: &Adapter, secrets: &dyn SecretStore) -> Result<Confi
// 字段当前值
for field in &cfg.fields {
let env_key = cfg
.environment
.iter()
.find(|e| e.maps_to_field.as_deref() == Some(field.id.as_str()))
.map(|e| e.key.clone());
let (value, has_value) = match field.storage.as_str() {
"keyring" | "env" => (None, secrets.has(&service, &field.id)),
"file" => {
"keyring" => (None, secrets.has(&service, &field.id)),
"file" | "env" => {
let val = read_file_field(adapter, &field.id)?;
(val, false)
}
_ => (None, false),
};
let options: Vec<ConfigFieldOption> = field
.options
.iter()
.map(|o| ConfigFieldOption { value: o.value.clone(), label_zh: o.label_zh.clone() })
.collect();
fields.push(ConfigFieldState {
id: field.id.clone(),
label_zh: field.label_zh.clone(),
@@ -280,6 +302,10 @@ pub fn read_config(adapter: &Adapter, secrets: &dyn SecretStore) -> Result<Confi
storage: field.storage.clone(),
value,
has_value,
docs_url: field.docs_url.clone(),
options,
env_key,
group: field.group.clone().unwrap_or_else(|| "common".into()),
});
}
@@ -293,7 +319,19 @@ pub fn read_config(adapter: &Adapter, secrets: &dyn SecretStore) -> Result<Confi
}
}
Ok(ConfigFormState { cli_id, files, fields, environment })
// 官方授权方式(授权/登录层引导)
let mut auth_modes = Vec::new();
if let Some(auth) = adapter.authorization.as_ref() {
for m in &auth.modes {
auth_modes.push(AuthModeInfo {
mode: m.mode.clone(),
notes_zh: m.notes_zh.clone(),
command: m.command.clone(),
});
}
}
Ok(ConfigFormState { cli_id, files, fields, environment, auth_modes })
}
/// 从配置文件读取字段值(首个声明了该字段可写的文件)。
@@ -353,7 +391,7 @@ pub fn write_config(
continue;
};
match field.storage.as_str() {
"keyring" | "env" => {
"keyring" => {
match secrets.set(&service, &field.id, value) {
Ok(()) => {
written_fields.push(field.id.clone());
@@ -364,7 +402,7 @@ pub fn write_config(
}
}
}
"file" => {
"file" | "env" => {
if let Some(idx) = cfg.files.iter().position(|f| matches!(f.scope.as_deref(), Some("user") | None)) {
file_patches.entry(idx).or_default().push((field.id.clone(), value.clone()));
} else {
@@ -406,6 +444,96 @@ pub fn write_config(
Ok(WriteResult { backup_path, written_file, written_fields, errors })
}
// =====================================================================
// 配置「生效检查」(writeConfig 后验证,架构 §C「配置后验证」)
// =====================================================================
/// 写配置后验证:配置文件能否被解析、CLI 本身是否认可(--version 探测)。
/// 不真调付费 API——验证到「CLI 认可配置」为止,并如实标注验证层级。
pub fn verify_config(adapter: &Adapter) -> ConfigVerifyResult {
let cli_id = adapter.id.clone();
// 1. 重读配置文件并解析(有配置文件声明时)
if let Some(cfg) = adapter.configuration.as_ref() {
for f in &cfg.files {
let path = cfg::resolve_path(&f.path);
if !path.exists() {
continue;
}
let text = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(e) => {
return ConfigVerifyResult {
cli_id,
ok: false,
level: "config_parse_failed".into(),
message_zh: format!("配置文件读取失败:{e}"),
detail: Some(path.to_string_lossy().to_string()),
};
}
};
if let Err(e) = cfg::parse(cfg::ConfigFormat::from_str(&f.format), &text) {
return ConfigVerifyResult {
cli_id,
ok: false,
level: "config_parse_failed".into(),
message_zh: format!("配置文件解析失败:{}", f.path),
detail: Some(e.to_string()),
};
}
}
}
// 2. 若 CLI 已安装,运行版本探测,确认 CLI 本身可正常启动(不真调 API)
let detect = detect_one(adapter);
if detect.status == "installed" {
let exe = detect.executable.as_deref().unwrap_or_default();
let version_args: Vec<String> = adapter
.detect
.as_ref()
.map(|d| d.version_args.clone())
.unwrap_or_default();
let args: Vec<String> = if version_args.is_empty() {
vec!["--version".to_string()]
} else {
version_args
};
match run_capture(std::path::Path::new(exe), &args) {
Ok((true, text)) => {
let v = detect.version.clone();
return ConfigVerifyResult {
cli_id,
ok: true,
level: "cli_accepted".into(),
message_zh: format!(
"配置文件已写入且可解析;{} 版本探测正常{}。未实际调用付费 API,验证到「CLI 认可配置」为止。",
adapter.name_zh,
v.as_deref().map(|v| format!("{v}")).unwrap_or_default()
),
detail: Some(text.trim().to_string()),
};
}
Ok((false, _)) | Err(_) => {
return ConfigVerifyResult {
cli_id,
ok: true,
level: "config_parsed".into(),
message_zh: "配置文件已写入且可解析;CLI 版本探测未成功,仅验证到「配置可解析」一层。".into(),
detail: None,
};
}
}
}
ConfigVerifyResult {
cli_id,
ok: true,
level: "not_installed".into(),
message_zh: "配置文件已写入且可解析;该 CLI 尚未安装,未做 CLI 实际校验(以官方文档为准)。".into(),
detail: None,
}
}
// =====================================================================
// 授权
// =====================================================================
@@ -660,7 +788,26 @@ where
return Err(EngineError::NotSupported(format!("{} 未声明可执行的 {label} 命令", adapter.id)));
}
emit(ActionEvent::step(format!("开始{label} {}{}", adapter.name_zh, adapter.id)));
// 渠道信息(安装时展示;更新/卸载无渠道概念)
let channel_hint = if action == AdapterAction::Install {
adapter
.install
.as_ref()
.and_then(|i| pick_channel(i, opts.channel.as_deref()))
.map(|c| c.id.clone())
} else {
None
};
emit(ActionEvent::step_phase(
"prepare",
format!(
"准备{label} {}{}{}",
adapter.name_zh,
adapter.id,
channel_hint.as_deref().map(|c| format!("· 渠道 {c}")).unwrap_or_default()
),
));
for cmd in &commands {
let prog = &cmd[0];
@@ -671,30 +818,43 @@ where
.first()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| prog.clone());
emit(ActionEvent::step(format!(
"执行:{} {}",
prog,
args.join(" ")
)));
emit(ActionEvent::step_phase(
"exec",
format!("执行命令:{} {}", prog, args.join(" ")),
));
let ok = run_streaming(&exe, &args, |is_err, line| {
let result = run_streaming(&exe, &args, |is_err, line| {
let r = redact(line);
if is_err {
emit(ActionEvent::stderr(r));
} else {
emit(ActionEvent::stdout(r));
}
})?;
});
let ok = match result {
Ok(ok) => ok,
Err(e) => {
let hint = diag_hint_for_install_failure(adapter, &e.to_string());
emit(ActionEvent::error(format!(
"{label}失败:无法启动命令({e})。{hint}"
)));
return Err(EngineError::Exec(format!("{label} 命令启动失败: {e}")));
}
};
if !ok {
emit(ActionEvent::error(format!("{label} 命令退出码非 0")));
let hint = diag_hint_for_install_failure(adapter, "");
emit(ActionEvent::error(format!(
"{label}失败:命令退出码非 0(原始错误见上方 stderr 输出)。{hint}"
)));
return Err(EngineError::Exec(format!("{label} 命令退出码非 0")));
}
}
// 安装后自动复检
// 安装后自动复检(当场复检并展示版本/路径,不只看事后 detect)
if action == AdapterAction::Install {
emit(ActionEvent::step("安装完成,自动复检"));
emit(ActionEvent::step_phase("verify", "安装命令执行完成,正在复检安装结果"));
let detect = detect_one(adapter);
emit(ActionEvent::done(Some(serde_json::to_value(&detect).unwrap_or(json!({})))));
} else {
@@ -704,6 +864,25 @@ where
Ok(())
}
/// 安装失败时的中文诊断建议(按适配器声明的依赖/渠道给针对性提示,不吞原始错误)。
fn diag_hint_for_install_failure(adapter: &Adapter, spawn_err: &str) -> String {
let mut parts: Vec<String> = Vec::new();
if !spawn_err.is_empty() {
parts.push("请确认所需命令已安装并加入 PATH(可到「诊断」页排查)".into());
}
let deps: Vec<&str> = adapter
.runtime_deps
.iter()
.filter(|d| d.required_for.iter().any(|r| r == "install" || r == "run"))
.map(|d| d.id.as_str())
.collect();
if !deps.is_empty() {
parts.push(format!("依赖检查:{}", deps.join("")));
}
parts.push("常见原因:网络不通 / 依赖版本不符 / 需要管理员权限,可在「诊断」页查看详细结论。".into());
format!("{}", parts.join(""))
}
// =====================================================================
// 测试
// =====================================================================
@@ -845,6 +1024,40 @@ configuration:
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn write_official_codex_fields_to_file_and_keyring() {
// 用真实 adapters/tools/codex.yaml 验证官方字段(含 enum)写入 + 密钥铁律
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../adapters");
let adapters = load_adapters(&dir).expect("真实适配器目录应可加载");
let mut adapter = adapters.into_iter().find(|a| a.id == "codex").unwrap();
let tmp = std::env::temp_dir().join(format!("agentdock-core-official-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap();
let file = tmp.join("config.toml");
if let Some(c) = adapter.configuration.as_mut() {
c.files[0].path = file.to_string_lossy().to_string();
}
let secrets = MockSecretStore::new();
let mut patch = std::collections::BTreeMap::new();
patch.insert("model".to_string(), "gpt-5.6-terra".to_string());
patch.insert("approval_policy".to_string(), "on-failure".to_string());
patch.insert("openai_base_url".to_string(), "http://proxy.example.com".to_string());
patch.insert("api_key".to_string(), "sk-test-xxx".to_string());
let res = write_config(&adapter, &patch, &secrets).unwrap();
assert_eq!(res.written_fields.len(), 4);
let text = std::fs::read_to_string(&file).unwrap();
assert!(text.contains("gpt-5.6-terra"), "默认模型应写入配置: {text}");
assert!(text.contains("on-failure"), "审批策略(enum)应写入配置: {text}");
assert!(text.contains("http://proxy.example.com"), "Base URL 应写入配置: {text}");
assert!(!text.contains("sk-test-xxx"), "API Key 不得写入配置文件");
assert!(secrets.has(&service_name("codex"), "api_key"));
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn write_config_creates_backup_on_second_write() {
let adapter = codex_adapter();
@@ -966,6 +1179,60 @@ configuration:
.contains(&r.status.as_str()));
}
#[test]
fn verify_config_reports_parse_failure() {
let adapter = codex_adapter();
let dir = std::env::temp_dir().join(format!("agentdock-core-verify-bad-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("config.toml");
std::fs::write(&file, "model = [unclosed").unwrap();
let mut adapter = adapter;
if let Some(c) = adapter.configuration.as_mut() {
c.files[0].path = file.to_string_lossy().to_string();
}
let r = verify_config(&adapter);
assert_eq!(r.level, "config_parse_failed");
assert!(!r.ok);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn verify_config_valid_file_passes_without_cli() {
// CLI 未安装时应返回 ok=true(配置可解析),level 为 not_installed 或 cli_accepted
let adapter = codex_adapter();
let dir = std::env::temp_dir().join(format!("agentdock-core-verify-ok-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("config.toml");
std::fs::write(&file, "model = \"gpt-5\"\n").unwrap();
let mut adapter = adapter;
if let Some(c) = adapter.configuration.as_mut() {
c.files[0].path = file.to_string_lossy().to_string();
}
let r = verify_config(&adapter);
assert!(r.ok);
assert!(["not_installed", "cli_accepted", "config_parsed"].contains(&r.level.as_str()));
let _ = std::fs::remove_dir_all(&dir);
}
/// 真机配置「生效检查」:对已安装的 codex 跑 verify_config,打印验证层级。
/// 运行:cargo test -p agentdock-core real_machine_verify_codex -- --ignored --nocapture
#[test]
#[ignore]
fn real_machine_verify_codex() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../adapters");
let adapters = load_adapters(&dir).expect("适配器目录应可加载");
let codex = adapters.iter().find(|a| a.id == "codex").expect("缺少 codex");
let r = verify_config(codex);
println!("codex verify: level={} ok={}", r.level, r.ok);
println!(" message={}", r.message_zh);
if let Some(d) = &r.detail {
println!(" detail={}", d);
}
assert!(r.ok, "codex 已安装且配置可解析时应 ok=true");
}
/// 真机检测记录:对首批五件套逐项 detect 并打印结果(自测记录用)。
/// 运行:cargo test -p agentdock-core real_machine_detect_five -- --ignored --nocapture
#[test]
+3 -3
View File
@@ -9,9 +9,9 @@ pub mod error;
pub mod process;
pub mod types;
pub use engine::{ActionOpts, Engine, auth_status, authorize, detect_one, diagnose, parse_version, read_config, run_action, write_config};
pub use engine::{ActionOpts, Engine, auth_status, authorize, detect_one, diagnose, parse_version, read_config, run_action, verify_config, write_config};
pub use error::EngineError;
pub use types::{
ActionEvent, AuthStatus, ConfigFieldState, ConfigFileState, ConfigFormState, DetectResult,
EnvFieldState, WriteResult, now_secs,
ActionEvent, AuthStatus, AuthModeInfo, ConfigFieldState, ConfigFileState, ConfigFormState, DetectResult,
EnvFieldState, WriteResult, ConfigVerifyResult, now_secs,
};
+50 -5
View File
@@ -48,6 +48,20 @@ pub struct ConfigFieldState {
pub value: Option<String>,
/// 密钥库类字段:是否已有值
pub has_value: bool,
/// 官方文档链接(配置项依据)
pub docs_url: Option<String>,
/// enum 类型的可选值(value + 中文名)
pub options: Vec<ConfigFieldOption>,
/// 该字段(keyring 类)对应的环境变量名(如 OPENAI_API_KEY),供界面说明
pub env_key: Option<String>,
/// 表单分组:auth | common | advanced(未声明按 common
pub group: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigFieldOption {
pub value: String,
pub label_zh: String,
}
/// 配置文件解析状态。
@@ -77,6 +91,18 @@ pub struct ConfigFormState {
pub files: Vec<ConfigFileState>,
pub fields: Vec<ConfigFieldState>,
pub environment: Vec<EnvFieldState>,
/// 官方授权方式(授权/登录层引导用)
pub auth_modes: Vec<AuthModeInfo>,
}
/// 官方授权方式(授权/登录层展示)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthModeInfo {
/// browser_oauth | device_code | api_key | local_tui
pub mode: String,
pub notes_zh: Option<String>,
/// 交互式登录命令(浏览器/设备码等;api_key 类常为空)
pub command: Vec<String>,
}
/// 写配置结果(writeConfig 返回)。
@@ -92,6 +118,20 @@ pub struct WriteResult {
pub errors: Vec<String>,
}
/// 配置写入后的「生效检查」结果(verifyConfig 返回)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigVerifyResult {
pub cli_id: String,
/// 整体是否通过(配置文件可解析即视为「已写入成功」)
pub ok: bool,
/// 验证层级:cli_accepted | config_parsed | config_parse_failed | not_installed
pub level: String,
/// 中文结论(如实标注验证到哪一层)
pub message_zh: String,
/// 脱敏后的补充信息(CLI 版本输出 / 解析错误)
pub detail: Option<String>,
}
/// 动作流事件(runAction 流式输出)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActionEvent {
@@ -99,24 +139,29 @@ pub struct ActionEvent {
pub kind: String,
/// 已脱敏的消息文本
pub message: String,
/// 当前阶段:prepare | exec | verify(供界面渲染步骤进度)
pub phase: Option<String>,
pub data: Option<serde_json::Value>,
}
impl ActionEvent {
pub fn step(msg: impl Into<String>) -> Self {
ActionEvent { kind: "step".into(), message: msg.into(), data: None }
ActionEvent { kind: "step".into(), message: msg.into(), phase: None, data: None }
}
pub fn step_phase(phase: &str, msg: impl Into<String>) -> Self {
ActionEvent { kind: "step".into(), message: msg.into(), phase: Some(phase.into()), data: None }
}
pub fn stdout(line: impl Into<String>) -> Self {
ActionEvent { kind: "stdout".into(), message: line.into(), data: None }
ActionEvent { kind: "stdout".into(), message: line.into(), phase: Some("exec".into()), data: None }
}
pub fn stderr(line: impl Into<String>) -> Self {
ActionEvent { kind: "stderr".into(), message: line.into(), data: None }
ActionEvent { kind: "stderr".into(), message: line.into(), phase: Some("exec".into()), data: None }
}
pub fn done(data: Option<serde_json::Value>) -> Self {
ActionEvent { kind: "done".into(), message: String::new(), data }
ActionEvent { kind: "done".into(), message: String::new(), phase: Some("verify".into()), data }
}
pub fn error(msg: impl Into<String>) -> Self {
ActionEvent { kind: "error".into(), message: msg.into(), data: None }
ActionEvent { kind: "error".into(), message: msg.into(), phase: None, data: None }
}
}