Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2fd5e369b |
@@ -1,5 +1,5 @@
|
||||
# ============================================================
|
||||
# AgentDock 适配器 · Anthropic Claude Code(Wave 2 全字段)
|
||||
# AgentDock 适配器 · Anthropic Claude Code(Wave 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.json(JSON)
|
||||
# 官方确认: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_TOKEN,Bearer 头);一般用户无需填写
|
||||
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
@@ -1,5 +1,5 @@
|
||||
# ============================================================
|
||||
# AgentDock 适配器 · OpenAI Codex CLI(Wave 2 全字段)
|
||||
# AgentDock 适配器 · OpenAI Codex CLI(Wave 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.toml(TOML)
|
||||
# 官方确认可配: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
@@ -1,5 +1,5 @@
|
||||
# ============================================================
|
||||
# AgentDock 适配器 · Google Gemini CLI(Wave 2 全字段)
|
||||
# AgentDock 适配器 · Google Gemini CLI(Wave 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.json(JSON,官方发布 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.name,settings.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_KEY,AI 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 AI(ADC/服务账号)与 security.auth.selectedType 高级配置本表单不覆盖,以官方文档为准
|
||||
|
||||
+44
-10
@@ -1,5 +1,5 @@
|
||||
# ============================================================
|
||||
# AgentDock 适配器 · Kimi CLI(月之暗面 Moonshot)(Wave 2 全字段)
|
||||
# AgentDock 适配器 · Kimi CLI(月之暗面 Moonshot)(Wave 2.1)
|
||||
# 数据依据:调研底稿 agent-cli-survey-2026-08-24.md(Kimi 章节)+ 架构 §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.toml(TOML,兼容 JSON)
|
||||
# 可配 default_model、providers(type/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_model,Kimi 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_KEY,Moonshot 开放平台获取)
|
||||
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.sh(install.ps1)。脚本会自动装 uv 并从 PyPI kimi-cli 安装。严禁 npm(npm 上的 kimi-code 是第三方仿名包)。
|
||||
auth_zh: ① Kimi Code 浏览器 OAuth(推荐,`kimi login`);② Moonshot 开放平台 API Key(platform 侧获取后粘贴,经 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 集成 stdio(ACP 协议)
|
||||
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:
|
||||
- 严禁 npm:npm 上的 kimi-code 是第三方仿名包,与月之暗面无关
|
||||
- 官方脚本会自动安装 uv 并从 PyPI kimi-cli 安装
|
||||
- 官方脚本会自动安装 uv 并从 PyPI kimi-cli 安装(Python 3.12–3.14)
|
||||
- providers 表(type/base_url/api_key 多 provider)等高级配置本表单不覆盖,以官方 config-files 文档为准
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# ============================================================
|
||||
# AgentDock 适配器 · OpenCode(opencode.ai)(Wave 2 全字段)
|
||||
# AgentDock 适配器 · OpenCode(opencode.ai)(Wave 2.1)
|
||||
# 数据依据:调研底稿 agent-cli-survey-2026-08-24.md(OpenCode 章节)+ 架构 §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.json(JSON,带注释 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(各家环境变量);② 浏览器 OAuth(Claude Pro/Max、ChatGPT 订阅);③ 设备码(GitHub Copilot,github.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 文档为准
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
/// 批量检测全部 CLI(detectCliAll),供总览/目录/我的 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> {
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
CliAction,
|
||||
CliActionEvent,
|
||||
ConfigFormState,
|
||||
ConfigVerifyResult,
|
||||
DetectResult,
|
||||
DiagnosticReport,
|
||||
DryRunPlan,
|
||||
@@ -33,6 +34,7 @@ export async function listCatalog(): Promise<CatalogEntry[]> {
|
||||
// ---- Wave 2:CLI 全链路 ----
|
||||
|
||||
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" ? "可执行卸载命令回退;配置文件默认保留。" : "卸载默认保留配置,可重新安装恢复。",
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 不看 apt,Linux 不看 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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user