2 Commits
Author SHA1 Message Date
leefer 01996a80fe Wave 2: 五件套打通全链路(安装/检测/配置/授权/诊断)
- 五个适配器 YAML 全字段补齐(codex/claude-code/gemini/kimi/opencode)
- agentdock-config:TOML/JSON 编解码 + 原子写 + 自动备份
- agentdock-diag:PATH/依赖版本/版本冲突/配置损坏四类规则
- agentdock-core:detectCli/previewAction/runAction 流式/readConfig/writeConfig/authStatus/diagnose
- Tauri IPC 命令 + CLI 详情页/安装确认弹窗/配置表单/诊断页
- cargo test 75 项通过(含 4 项真机 ignored 自测),前端 build 通过
2026-08-25 08:58:38 +08:00
AgentDock 施工员 c47494180b Wave 0.5: 4K 布局与空态修复(老板验收 15 条,视觉规范 v1.3) 2026-08-25 01:04:14 +08:00
45 changed files with 9961 additions and 355 deletions
Generated
+4880
View File
File diff suppressed because it is too large Load Diff
+120 -21
View File
@@ -1,28 +1,127 @@
# ============================================================
# AgentDock 适配器占位(Wave 1,对齐架构 §3.1 完整 schema
# 本波仅填五字段(id/name/name_zh/vendor/status);其余字段留空,
# Wave 2 起按调研底稿 agent-cli-survey-2026-08-24.md 逐项填充,禁止编造。
#
# 完整字段(全部留空占位,Wave 2 填写):
# adapter_version # semver,如 1.2.0
# license # 展示用;专有许可注明「仅官方渠道安装、不重打包」
# platforms # windows/linuxarchitectures、notes、min_ubuntu
# official # homepage / docs / allowed_hosts(网络白名单)
# runtime_deps # [ { id, semver_range, required_for: [install|run] } ]
# install # preferred + channels[ {id, platforms, command[], script, package, elevate, elevate_reason_zh, post_checks} ]
# detect # executable / version_args / version_regex / version_unconfirmed / path_hints
# update # method + command[]
# uninstall # method + command[] + keep_config_default
# authorization # modes[ {mode, command[], env_keys[], status_command[], notes_zh} ]
# configuration # files[] / environment[] / fields[]
# diagnostics # [ { rule_id } ]
# documentation # quickstart_zh / commands[] / updated_at / risks_zh[]
#
# 铁律:所有 command 一律 argv 数组,禁止 shell 元字符(| & ; $ \ > < (`);
# 本波不实现任何真实安装/检测/配置/授权命令(那是 Wave 2 的事)。
# AgentDock 适配器 · Anthropic Claude CodeWave 2 全字段
# 数据依据:调研底稿 agent-cli-survey-2026-08-24.md §2 + 架构 §3.3
# ============================================================
id: claude-code
name: Claude Code
name_zh: Claude Code
vendor: Anthropic
status: available
adapter_version: 1.0.0
license: 专有(仅官方渠道安装、不重打包、不修改二进制)
platforms:
windows:
architectures: [x64]
notes: 原生支持,Windows 10 1809+ / Windows Server 2019+
linux:
distributions: [ubuntu]
architectures: [x64]
min_ubuntu: "20.04"
official:
homepage: https://claude.ai
docs: https://code.claude.com/docs/en/setup
allowed_hosts: [claude.ai, registry.npmjs.org, github.com]
runtime_deps: []
install:
preferred: winget
channels:
- id: winget
platforms: [windows]
command: [winget, install, Anthropic.ClaudeCode]
package: Anthropic.ClaudeCode
elevate: never
post_checks: [detect]
- id: official_script
platforms: [windows]
script:
url: https://claude.ai/install.ps1
kind: powershell_irm
elevate: never
post_checks: [detect]
- id: npm
platforms: [windows, linux]
command: [npm, install, -g, "@anthropic-ai/claude-code"]
package: "@anthropic-ai/claude-code"
elevate: never
post_checks: [detect]
detect:
executable: claude
version_args: ["--version"]
version_regex: "^(\\d+\\.\\d+\\.\\d+)"
update:
method: winget_upgrade
command: [winget, upgrade, Anthropic.ClaudeCode]
uninstall:
method: package_manager
command: [winget, uninstall, Anthropic.ClaudeCode]
keep_config_default: true
authorization:
modes:
- mode: browser_oauth
command: [claude]
notes_zh: 运行 claude 后按浏览器提示登录(Pro/Max/Team/Enterprise/Console 账号)
- mode: api_key
env_keys: [ANTHROPIC_API_KEY]
notes_zh: 设置 ANTHROPIC_API_KEY 后 CLI 优先使用 API Key
configuration:
files:
- path: "~/.claude/settings.json"
format: json
scope: user
environment:
- key: ANTHROPIC_API_KEY
sensitive: true
maps_to_field: api_key
- key: ANTHROPIC_BASE_URL
sensitive: false
maps_to_field: base_url
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 块)
required: false
sensitive: false
type: url
storage: file
- id: api_key
label_zh: API Key
help_zh: 存入系统密钥库(对应 ANTHROPIC_API_KEY
required: false
sensitive: true
type: string
storage: keyring
diagnostics:
- rule_id: path.not_installed
- rule_id: path.not_in_path
- rule_id: version_conflict.multiple_copies
- rule_id: config.corrupt
documentation:
quickstart_zh: 安装后运行 `claude` 登录,或用 `claude -p "提示词"` 无头执行。
commands:
- cmd: claude
desc_zh: 启动交互式会话
- cmd: claude -p "提示词"
desc_zh: 无头单次执行
- cmd: claude doctor
desc_zh: 环境诊断
updated_at: "2026-08-24"
risks_zh:
- 专有许可:只引导官方渠道安装,不重打包、不修改二进制
+124 -21
View File
@@ -1,28 +1,131 @@
# ============================================================
# AgentDock 适配器占位(Wave 1,对齐架构 §3.1 完整 schema
# 本波仅填五字段(id/name/name_zh/vendor/status);其余字段留空,
# Wave 2 起按调研底稿 agent-cli-survey-2026-08-24.md 逐项填充,禁止编造。
#
# 完整字段(全部留空占位,Wave 2 填写):
# adapter_version # semver,如 1.2.0
# license # 展示用;专有许可注明「仅官方渠道安装、不重打包」
# platforms # windows/linuxarchitectures、notes、min_ubuntu
# official # homepage / docs / allowed_hosts(网络白名单)
# runtime_deps # [ { id, semver_range, required_for: [install|run] } ]
# install # preferred + channels[ {id, platforms, command[], script, package, elevate, elevate_reason_zh, post_checks} ]
# detect # executable / version_args / version_regex / version_unconfirmed / path_hints
# update # method + command[]
# uninstall # method + command[] + keep_config_default
# authorization # modes[ {mode, command[], env_keys[], status_command[], notes_zh} ]
# configuration # files[] / environment[] / fields[]
# diagnostics # [ { rule_id } ]
# documentation # quickstart_zh / commands[] / updated_at / risks_zh[]
#
# 铁律:所有 command 一律 argv 数组,禁止 shell 元字符(| & ; $ \ > < (`);
# 本波不实现任何真实安装/检测/配置/授权命令(那是 Wave 2 的事)。
# AgentDock 适配器 · OpenAI Codex CLIWave 2 全字段
# 数据依据:调研底稿 agent-cli-survey-2026-08-24.md §1 + 架构 §3.3
# ============================================================
id: codex
name: Codex CLI
name_zh: Codex CLI
vendor: OpenAI
status: available
adapter_version: 1.0.0
license: Apache-2.0
platforms:
windows:
architectures: [x64]
notes: 原生支持(PowerShell 脚本安装,非 WSL);亦可走 npm
linux:
distributions: [ubuntu]
architectures: [x64]
official:
homepage: https://github.com/openai/codex
docs: https://learn.chatgpt.com/docs/codex/cli
allowed_hosts: [chatgpt.com, github.com, releases.openai.com, registry.npmjs.org]
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]
- id: official_script
platforms: [windows]
script:
url: https://chatgpt.com/codex/install.ps1
kind: powershell_irm
elevate: never
post_checks: [detect]
- id: github_release
platforms: [windows, linux]
package: 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 账号(需 Plus/Pro/Business/EDU/Enterprise 计划)
- 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 注入,不进 argv
configuration:
files:
- path: "~/.codex/config.toml"
format: toml
scope: user
environment:
- 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
required: false
sensitive: false
type: string
storage: file
- id: openai_base_url
label_zh: Base URL
help_zh: 自定义 OpenAI 兼容端点(简化键)
required: false
sensitive: false
type: url
storage: file
- id: api_key
label_zh: API Key
help_zh: 存入系统密钥库,写入后仅显示「已保存」
required: false
sensitive: true
type: string
storage: keyring
diagnostics:
- rule_id: path.not_installed
- rule_id: path.not_in_path
- rule_id: version_conflict.multiple_copies
- rule_id: config.corrupt
documentation:
quickstart_zh: 安装后运行 `codex` 登录账号,或用 `codex exec "任务"` 无头执行。
commands:
- cmd: codex
desc_zh: 启动交互式会话
- cmd: codex exec "提示词"
desc_zh: 无头单次执行
- cmd: codex login status
desc_zh: 查询登录状态
updated_at: "2026-08-24"
risks_zh:
- --version 官方文档未确认,适配器已标 version_unconfirmed 并实测兜底
+109 -21
View File
@@ -1,28 +1,116 @@
# ============================================================
# AgentDock 适配器占位(Wave 1,对齐架构 §3.1 完整 schema
# 本波仅填五字段(id/name/name_zh/vendor/status);其余字段留空,
# Wave 2 起按调研底稿 agent-cli-survey-2026-08-24.md 逐项填充,禁止编造。
#
# 完整字段(全部留空占位,Wave 2 填写):
# adapter_version # semver,如 1.2.0
# license # 展示用;专有许可注明「仅官方渠道安装、不重打包」
# platforms # windows/linuxarchitectures、notes、min_ubuntu
# official # homepage / docs / allowed_hosts(网络白名单)
# runtime_deps # [ { id, semver_range, required_for: [install|run] } ]
# install # preferred + channels[ {id, platforms, command[], script, package, elevate, elevate_reason_zh, post_checks} ]
# detect # executable / version_args / version_regex / version_unconfirmed / path_hints
# update # method + command[]
# uninstall # method + command[] + keep_config_default
# authorization # modes[ {mode, command[], env_keys[], status_command[], notes_zh} ]
# configuration # files[] / environment[] / fields[]
# diagnostics # [ { rule_id } ]
# documentation # quickstart_zh / commands[] / updated_at / risks_zh[]
#
# 铁律:所有 command 一律 argv 数组,禁止 shell 元字符(| & ; $ \ > < (`);
# 本波不实现任何真实安装/检测/配置/授权命令(那是 Wave 2 的事)。
# AgentDock 适配器 · Google Gemini CLIWave 2 全字段
# 数据依据:调研底稿 agent-cli-survey-2026-08-24.md §3 + 架构 §3.3
# ============================================================
id: gemini
name: Gemini CLI
name_zh: Gemini CLI
vendor: Google
status: available
adapter_version: 1.0.0
license: Apache-2.0
platforms:
windows:
architectures: [x64]
notes: 官方要求 Windows 11 24H2+
linux:
distributions: [ubuntu]
architectures: [x64]
min_ubuntu: "20.04"
official:
homepage: https://github.com/google-gemini/gemini-cli
docs: https://geminicli.com/docs/get-started/installation/
allowed_hosts: [registry.npmjs.org, geminicli.com, github.com]
runtime_deps:
- id: node
semver_range: ">=20"
required_for: [install, run]
install:
preferred: npm
channels:
- id: npm
platforms: [windows, linux]
command: [npm, install, -g, "@google/gemini-cli"]
package: "@google/gemini-cli"
elevate: never
post_checks: [detect]
detect:
executable: gemini
version_args: ["--version"]
update:
method: npm_update
command: [npm, update, -g, "@google/gemini-cli"]
uninstall:
method: npm_uninstall
command: [npm, uninstall, -g, "@google/gemini-cli"]
keep_config_default: true
authorization:
modes:
- mode: browser_oauth
command: [gemini]
notes_zh: 运行 gemini 选「Sign in with Google」浏览器登录
- mode: api_key
env_keys: [GEMINI_API_KEY]
notes_zh: 设置 GEMINI_API_KEY 环境变量(AI Studio 申请)
configuration:
files:
- path: "~/.gemini/settings.json"
format: json
scope: user
environment:
- 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 使用的默认模型
required: false
sensitive: false
type: string
storage: file
- id: env.GOOGLE_GEMINI_BASE_URL
label_zh: Base URL
help_zh: 覆盖 Gemini API 默认地址(须 HTTPS,代理/网关场景)
required: false
sensitive: false
type: url
storage: file
- id: api_key
label_zh: API Key
help_zh: 存入系统密钥库(对应 GEMINI_API_KEY
required: false
sensitive: true
type: string
storage: keyring
diagnostics:
- rule_id: path.not_installed
- rule_id: path.not_in_path
- rule_id: dependency.node_below_min
- rule_id: version_conflict.multiple_copies
- rule_id: config.corrupt
documentation:
quickstart_zh: 安装后运行 `gemini` 登录,或用 `gemini -p "提示词"` 无头执行。
commands:
- cmd: gemini
desc_zh: 启动交互式会话
- cmd: gemini -p "提示词"
desc_zh: 强制非交互单次执行
updated_at: "2026-08-24"
risks_zh:
- 官方发行形态为 npm 包,依赖 Node 20+,适配器已声明运行时门槛
- Windows 官方要求 11 24H2+,低版本 Win10 显示「官方未支持」
+129 -21
View File
@@ -1,28 +1,136 @@
# ============================================================
# AgentDock 适配器占位(Wave 1,对齐架构 §3.1 完整 schema
# 本波仅填五字段(id/name/name_zh/vendor/status);其余字段留空,
# Wave 2 起按调研底稿 agent-cli-survey-2026-08-24.md 逐项填充,禁止编造
#
# 完整字段(全部留空占位,Wave 2 填写):
# adapter_version # semver,如 1.2.0
# license # 展示用;专有许可注明「仅官方渠道安装、不重打包」
# platforms # windows/linuxarchitectures、notes、min_ubuntu
# official # homepage / docs / allowed_hosts(网络白名单)
# runtime_deps # [ { id, semver_range, required_for: [install|run] } ]
# install # preferred + channels[ {id, platforms, command[], script, package, elevate, elevate_reason_zh, post_checks} ]
# detect # executable / version_args / version_regex / version_unconfirmed / path_hints
# update # method + command[]
# uninstall # method + command[] + keep_config_default
# authorization # modes[ {mode, command[], env_keys[], status_command[], notes_zh} ]
# configuration # files[] / environment[] / fields[]
# diagnostics # [ { rule_id } ]
# documentation # quickstart_zh / commands[] / updated_at / risks_zh[]
#
# 铁律:所有 command 一律 argv 数组,禁止 shell 元字符(| & ; $ \ > < (`);
# 本波不实现任何真实安装/检测/配置/授权命令(那是 Wave 2 的事)。
# AgentDock 适配器 · Kimi CLI(月之暗面 Moonshot)(Wave 2 全字段
# 数据依据:调研底稿 agent-cli-survey-2026-08-24.mdKimi 章节)+ 架构 §3.3
# 铁律:只走官方 code.kimi.com 脚本 / PyPI kimi-cli,严禁 npm(仿名包)
# ============================================================
id: kimi
name: Kimi CLI
name_zh: Kimi CLI
vendor: 月之暗面
status: available
adapter_version: 1.0.0
license: Apache-2.0
platforms:
windows:
architectures: [x64]
notes: 原生支持(PowerShell 脚本安装,非 WSL
linux:
distributions: [ubuntu]
architectures: [x64]
official:
homepage: https://github.com/MoonshotAI/kimi-cli
docs: https://moonshotai.github.io/kimi-cli/en/guides/getting-started.html
allowed_hosts: [code.kimi.com, pypi.org, github.com]
runtime_deps:
- id: uv
semver_range: ">=0"
required_for: [install]
- id: python
semver_range: ">=3.12"
required_for: [run]
install:
preferred: pypi_uv
channels:
- id: pypi_uv
platforms: [windows, linux]
command: [uv, tool, install, kimi-cli]
package: kimi-cli
elevate: never
post_checks: [detect]
- id: official_script
platforms: [windows]
script:
url: https://code.kimi.com/install.ps1
kind: powershell_irm
elevate: never
post_checks: [detect]
- id: official_script
platforms: [linux]
script:
url: https://code.kimi.com/install.sh
kind: bash_pipe
elevate: never
post_checks: [detect]
detect:
executable: kimi
version_args: ["--version"]
update:
method: pypi_upgrade
command: [uv, tool, upgrade, kimi-cli]
uninstall:
method: package_manager
command: [uv, tool, uninstall, kimi-cli]
keep_config_default: true
authorization:
modes:
- mode: browser_oauth
command: [kimi, login]
notes_zh: Kimi Code 浏览器 OAuth(推荐)
- mode: api_key
env_keys: [KIMI_API_KEY]
notes_zh: Moonshot 开放平台 API Key
configuration:
files:
- path: "~/.kimi/config.toml"
format: toml
scope: user
environment:
- 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 使用的默认模型
required: false
sensitive: false
type: string
storage: file
- id: env.KIMI_BASE_URL
label_zh: Base URL
help_zh: 自定义 OpenAI 兼容端点
required: false
sensitive: false
type: url
storage: file
- id: api_key
label_zh: API Key
help_zh: 存入系统密钥库(对应 KIMI_API_KEY
required: false
sensitive: true
type: string
storage: keyring
diagnostics:
- rule_id: path.not_installed
- rule_id: path.not_in_path
- rule_id: dependency.uv
- rule_id: dependency.python_below_min
- rule_id: version_conflict.multiple_copies
- rule_id: config.corrupt
documentation:
quickstart_zh: 安装后运行 `kimi login` 登录,或用 `kimi -p "提示词"` 无头执行。
commands:
- cmd: kimi
desc_zh: 启动交互式会话
- cmd: kimi login
desc_zh: 登录(浏览器 OAuth 或 API Key
- cmd: kimi -p "提示词"
desc_zh: 无头单次执行
updated_at: "2026-08-24"
risks_zh:
- 严禁 npmnpm 上的 kimi-code 是第三方仿名包,与月之暗面无关
- 官方脚本会自动安装 uv 并从 PyPI kimi-cli 安装
+121 -21
View File
@@ -1,28 +1,128 @@
# ============================================================
# AgentDock 适配器占位(Wave 1,对齐架构 §3.1 完整 schema
# 本波仅填五字段(id/name/name_zh/vendor/status);其余字段留空,
# Wave 2 起按调研底稿 agent-cli-survey-2026-08-24.md 逐项填充,禁止编造。
#
# 完整字段(全部留空占位,Wave 2 填写):
# adapter_version # semver,如 1.2.0
# license # 展示用;专有许可注明「仅官方渠道安装、不重打包」
# platforms # windows/linuxarchitectures、notes、min_ubuntu
# official # homepage / docs / allowed_hosts(网络白名单)
# runtime_deps # [ { id, semver_range, required_for: [install|run] } ]
# install # preferred + channels[ {id, platforms, command[], script, package, elevate, elevate_reason_zh, post_checks} ]
# detect # executable / version_args / version_regex / version_unconfirmed / path_hints
# update # method + command[]
# uninstall # method + command[] + keep_config_default
# authorization # modes[ {mode, command[], env_keys[], status_command[], notes_zh} ]
# configuration # files[] / environment[] / fields[]
# diagnostics # [ { rule_id } ]
# documentation # quickstart_zh / commands[] / updated_at / risks_zh[]
#
# 铁律:所有 command 一律 argv 数组,禁止 shell 元字符(| & ; $ \ > < (`);
# 本波不实现任何真实安装/检测/配置/授权命令(那是 Wave 2 的事)。
# AgentDock 适配器 · OpenCodeopencode.ai)(Wave 2 全字段
# 数据依据:调研底稿 agent-cli-survey-2026-08-24.mdOpenCode 章节)+ 架构 §3.3
# ============================================================
id: opencode
name: OpenCode
name_zh: OpenCode
vendor: opencode.ai
status: available
adapter_version: 1.0.0
license: MIT
platforms:
windows:
architectures: [x64]
notes: 原生支持(choco/scoop/exe,无需 WSL
linux:
distributions: [ubuntu]
architectures: [x64]
official:
homepage: https://opencode.ai
docs: https://opencode.ai/docs
allowed_hosts: [opencode.ai, registry.npmjs.org, github.com, community.chocolatey.org, get.scoop.sh]
runtime_deps: []
install:
preferred: npm
channels:
- id: npm
platforms: [windows, linux]
command: [npm, install, -g, opencode-ai]
package: opencode-ai
elevate: never
post_checks: [detect]
- id: choco
platforms: [windows]
command: [choco, install, opencode]
package: opencode
elevate: required
elevate_reason_zh: choco 安装需要管理员权限写入系统目录
post_checks: [detect]
- id: scoop
platforms: [windows]
command: [scoop, install, opencode]
package: opencode
elevate: never
post_checks: [detect]
- id: official_script
platforms: [linux]
script:
url: https://opencode.ai/install
kind: bash_pipe
elevate: never
post_checks: [detect]
detect:
executable: opencode
version_args: ["--version"]
update:
method: self_update_cmd
command: [opencode, upgrade]
uninstall:
method: npm_uninstall
command: [npm, uninstall, -g, opencode-ai]
keep_config_default: true
authorization:
modes:
- mode: browser_oauth
command: [opencode, auth, login]
notes_zh: 浏览器 OAuthClaude Pro/Max、ChatGPT 订阅等)
- mode: api_key
status_command: [opencode, auth, list]
env_keys: [OPENAI_API_KEY, ANTHROPIC_API_KEY]
notes_zh: 各家 API Keyopencode auth list 可查看凭据
- mode: device_code
notes_zh: GitHub Copilot 设备码登录(github.com/login/device 输码)
configuration:
files:
- path: "~/.config/opencode/opencode.json"
format: json
scope: user
environment:
- key: OPENAI_API_KEY
sensitive: true
maps_to_field: api_key
- key: ANTHROPIC_API_KEY
sensitive: true
maps_to_field: api_key
fields:
- id: model
label_zh: 默认模型
help_zh: OpenCode 使用的默认模型
required: false
sensitive: false
type: string
storage: file
- id: api_key
label_zh: API Key
help_zh: 存入系统密钥库(对应 OPENAI_API_KEY / ANTHROPIC_API_KEY
required: false
sensitive: true
type: string
storage: keyring
diagnostics:
- rule_id: path.not_installed
- rule_id: path.not_in_path
- rule_id: version_conflict.multiple_copies
- rule_id: config.corrupt
documentation:
quickstart_zh: 安装后运行 `opencode` 或 `opencode run "提示词"` 无头执行。
commands:
- cmd: opencode
desc_zh: 启动交互式会话
- cmd: opencode run "提示词"
desc_zh: 无头单次执行
- cmd: opencode auth list
desc_zh: 查看已配置凭据
updated_at: "2026-08-24"
risks_zh:
- 官方未文档化安装包校验(SHA256/签名),适配器不强制校验
+3
View File
@@ -19,3 +19,6 @@ serde_json = "1"
agentdock-platform = { path = "../../../crates/agentdock-platform" }
agentdock-adapter = { path = "../../../crates/agentdock-adapter" }
agentdock-store = { path = "../../../crates/agentdock-store" }
agentdock-core = { path = "../../../crates/agentdock-core" }
agentdock-secrets = { path = "../../../crates/agentdock-secrets" }
agentdock-diag = { path = "../../../crates/agentdock-diag" }
+121
View File
@@ -0,0 +1,121 @@
//! IPC 命令:CLI 全链路(detectCli / previewAction / runAction / readConfig /
//! writeConfig / authStatus / authorize / diagnose,架构 §2 IPC 契约)
//!
//! 编排全部走 `agentdock-core::Engine`。runAction 通过 Tauri 事件流式回传
//! stdout/stderr;敏感输出经 `agentdock-secrets::redact` 脱敏后才进事件。
use std::collections::BTreeMap;
use agentdock_adapter::{AdapterAction, DryRunPlan};
use agentdock_core::{ActionEvent, ActionOpts, AuthStatus, ConfigFormState, DetectResult, Engine, WriteResult};
use agentdock_diag::DiagnosticReport;
use serde_json::json;
use tauri::Emitter;
/// 把 action 字符串映射为 AdapterActionsnake_case,与 IPC 契约一致)。
fn parse_action(action: &str) -> Result<AdapterAction, String> {
match action {
"install" => Ok(AdapterAction::Install),
"update" => Ok(AdapterAction::Update),
"uninstall" => Ok(AdapterAction::Uninstall),
"write_config" => Ok(AdapterAction::WriteConfig),
"authorize" => Ok(AdapterAction::Authorize),
"repair" => Ok(AdapterAction::Repair),
other => Err(format!("未知动作: {other}")),
}
}
/// 读取单个适配器完整定义(CLI 详情页展示文档/渠道/许可用)。
#[tauri::command(rename = "getAdapter")]
pub fn get_adapter(id: String, state: tauri::State<'_, Engine>) -> Result<agentdock_adapter::Adapter, String> {
state.adapter(&id).map_err(|e| e.to_string())
}
/// 检测单个 CLIdetectCli)。
#[tauri::command(rename = "detectCli")]
pub fn detect_cli(id: String, state: tauri::State<'_, Engine>) -> Result<DetectResult, String> {
state.detect(&id).map_err(|e| e.to_string())
}
/// 干燥运行(previewAction):返回将执行的命令与影响面,不真正执行。
#[tauri::command(rename = "previewAction")]
pub fn preview_action(
id: String,
action: String,
channel: Option<String>,
state: tauri::State<'_, Engine>,
) -> Result<DryRunPlan, String> {
let action = parse_action(&action)?;
let mut plan = state.preview(&id, action).map_err(|e| e.to_string())?;
// 渠道覆盖时调整计划里的命令为所选渠道(供确认弹窗展示)
if let Some(ch) = channel {
if let Some(adapter) = state.adapter(&id).ok() {
if let Some(install) = adapter.install.as_ref() {
if let Some(target) = install.channels.iter().find(|c| c.id == ch) {
if !target.command.is_empty() {
plan.commands = vec![target.command.clone()];
plan.elevate = matches!(target.elevate.as_deref(), Some("if_needed") | Some("required"));
plan.elevate_reason_zh = target.elevate_reason_zh.clone();
}
}
}
}
}
Ok(plan)
}
/// 流式执行动作(runAction):事件经 `cli-action-event` 回传。
#[tauri::command(rename = "runAction")]
pub fn run_action(
app: tauri::AppHandle,
id: String,
action: String,
channel: Option<String>,
state: tauri::State<'_, Engine>,
) -> Result<(), String> {
let action = parse_action(&action)?;
let engine = state.inner().clone();
let opts = ActionOpts { channel };
std::thread::spawn(move || {
let cli_id = id.clone();
let result = engine.run(&id, action, &opts, |ev| {
let _ = app.emit("cli-action-event", json!({ "cli_id": cli_id, "event": ev }));
});
if let Err(e) = result {
let _ = app.emit(
"cli-action-event",
json!({ "cli_id": cli_id, "event": ActionEvent::error(e.to_string()) }),
);
}
});
Ok(())
}
/// 读取配置表单状态(readConfig)。
#[tauri::command(rename = "readConfig")]
pub fn read_config(id: String, state: tauri::State<'_, Engine>) -> Result<ConfigFormState, String> {
state.read_config(&id).map_err(|e| e.to_string())
}
/// 写配置(writeConfig):patch 为 { field_id: value }。
#[tauri::command(rename = "writeConfig")]
pub fn write_config(
id: String,
patch: BTreeMap<String, String>,
state: tauri::State<'_, Engine>,
) -> Result<WriteResult, String> {
state.write_config(&id, &patch).map_err(|e| e.to_string())
}
/// 授权状态(authStatus)。
#[tauri::command(rename = "authStatus")]
pub fn auth_status(id: String, state: tauri::State<'_, Engine>) -> Result<AuthStatus, String> {
state.auth_status(&id).map_err(|e| e.to_string())
}
/// 诊断(diagnose):内部实时取本机环境快照。
#[tauri::command(rename = "diagnose")]
pub fn diagnose(id: String, state: tauri::State<'_, Engine>) -> Result<DiagnosticReport, String> {
let env = agentdock_platform::detect::detect_env();
state.diagnose(&id, &env).map_err(|e| e.to_string())
}
@@ -1,2 +1,3 @@
pub mod catalog;
pub mod cli;
pub mod env;
+20 -1
View File
@@ -1,6 +1,12 @@
mod commands;
use std::path::PathBuf;
use std::sync::Arc;
use tauri::Manager;
use agentdock_core::Engine;
use agentdock_secrets::KeyringSecretStore;
/// 解析适配器目录。
/// 优先级:环境变量 AGENTDOCK_ADAPTERS_DIR > CWD 相对路径(tauri dev 时 CWD=apps/desktop
@@ -26,16 +32,29 @@ fn adapters_dir() -> PathBuf {
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.setup(|_app| {
.setup(|app| {
// 自测标记:`tauri dev` 启动后日志出现该行即代表壳已就绪
println!("[agentdock] window-ready");
// Wave 0:初始化 SQLite 空库(架构 §2 状态存储层)
let _ = agentdock_store::Store::open_default();
// Wave 2:初始化编排引擎(适配器目录 + 系统密钥库)
let secrets: Arc<dyn agentdock_secrets::SecretStore> = Arc::new(KeyringSecretStore::new());
let engine = Engine::new(adapters_dir(), secrets);
app.manage(engine);
Ok(())
})
.invoke_handler(tauri::generate_handler![
commands::env::detect_env,
commands::catalog::list_catalog,
commands::cli::detect_cli,
commands::cli::get_adapter,
commands::cli::preview_action,
commands::cli::run_action,
commands::cli::read_config,
commands::cli::write_config,
commands::cli::auth_status,
commands::cli::diagnose,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+21 -10
View File
@@ -7,6 +7,7 @@ import { MyCliPage } from "./pages/MyCli";
import { ConfigCenterPage } from "./pages/ConfigCenter";
import { BackupPage } from "./pages/Backup";
import { SettingsPage } from "./pages/Settings";
import { CliDetailPage } from "./pages/CliDetail";
import { useEnv } from "./hooks/useEnv";
import type { PlatformEnv, RuntimeInfo } from "./ipc/types";
@@ -27,35 +28,45 @@ function envWarningCount(env: PlatformEnv | null): number {
export default function App() {
const [page, setPage] = useState<PageKey>("overview");
const [detailCliId, setDetailCliId] = useState<string | null>(null);
const { env } = useEnv();
const warningCount = envWarningCount(env);
function navigate(next: PageKey) {
setDetailCliId(null);
setPage(next);
}
const content = useMemo(() => {
if (detailCliId) {
return <CliDetailPage id={detailCliId} onBack={() => setDetailCliId(null)} />;
}
switch (page) {
case "overview":
return <OverviewPage />;
return <OverviewPage onNavigate={navigate} />;
case "catalog":
return <CatalogPage />;
return <CatalogPage onOpenDetail={setDetailCliId} />;
case "my-cli":
return <MyCliPage />;
return <MyCliPage onNavigate={navigate} />;
case "config":
return <ConfigCenterPage />;
return <ConfigCenterPage onNavigate={navigate} />;
case "backup":
return <BackupPage />;
case "settings":
return <SettingsPage />;
}
}, [page]);
}, [page, detailCliId, env]);
const title = detailCliId ? "CLI 详情" : PAGE_META[page].title;
return (
<div className="app-shell">
<div className="bg-grid grid-drift" aria-hidden="true" />
<Sidebar current={page} onNavigate={setPage} />
<Sidebar current={page} onNavigate={navigate} />
<div className="app-main">
<Header title={PAGE_META[page].title} warningCount={warningCount} />
<Header title={title} warningCount={warningCount} />
<main className="app-content">
{/* key=page 触发 §4.2 页面切换动效:translateY(8px)+淡入 */}
<div key={page} className="page-enter">
{/* key 触发 §4.2 页面切换动效:translateY(8px)+淡入 */}
<div key={detailCliId ?? page} className="page-enter">
{content}
</div>
</main>
+35 -1
View File
@@ -1,12 +1,46 @@
/**
* CLI 双字母头像缩写注册表(视觉规范 v1.3 §7:全系统唯一来源)
* 规则:① 首两个字母(大写);② 冲突时保留先收录者,后收录方取「首字母 + 辨识辅音」;
* ③ 仍冲突升级「首字母 + 末字母」。新收录 CLI 必须先在此注册,不得现场发明。
*/
const CLI_MONOGRAMS: Record<string, string> = {
codex: "CO",
"claude-code": "CL",
gemini: "GE",
copilot: "CP",
kimi: "KI",
qwen: "QW",
codebuddy: "CB",
opencode: "OC",
crush: "CR",
goose: "GO",
aider: "AI",
cursor: "CU",
cline: "CN",
warp: "WA",
/* 第二批 4 个预注册(暂缓接入,登记防碰撞) */
grok: "GR",
amp: "AM",
"deepseek-dsh": "DS",
plandex: "PL",
};
/** 未注册时的兜底:取主名单词首两个字母(大写),仅用于未来新增且尚未登记的过渡态 */
function fallbackAbbr(name: string): string {
return name.replace(/\s+/g, "").slice(0, 2).toUpperCase();
}
/** CLI 单色字母头像(视觉规范 §7:双字母单色,--ad-bg-2 底 + 1px --ad-border */
export function CliMonogram({
id,
name,
size = 32,
}: {
id: string;
name: string;
size?: number;
}) {
const chars = name.replace(/\s+/g, "").slice(0, 2).toUpperCase();
const chars = CLI_MONOGRAMS[id] ?? fallbackAbbr(name);
return (
<span
className="monogram"
+205
View File
@@ -0,0 +1,205 @@
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 { MonoChip } from "./MonoChip";
/** 配置表单(视觉规范 §3.4:单列 ≤640px、敏感字段密码框 + 密钥库说明、吸底保存条) */
export function ConfigForm({ id }: { id: string }) {
const [state, setState] = useState<ConfigFormState | null>(null);
const [values, setValues] = useState<Record<string, string>>({});
const [revealed, setRevealed] = useState<Record<string, boolean>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [result, setResult] = useState<WriteResult | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
readConfig(id)
.then((s) => {
if (cancelled) return;
setState(s);
const init: Record<string, string> = {};
for (const f of s.fields) {
if (!f.sensitive && f.value != null) init[f.id] = f.value;
}
setValues(init);
setLoading(false);
})
.catch((e) => {
if (!cancelled) {
setError(String(e));
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [id]);
const sensitiveSaved = useMemo(() => {
const m: Record<string, boolean> = {};
state?.fields.forEach((f) => {
if (f.sensitive) m[f.id] = f.has_value;
});
return m;
}, [state]);
if (loading) return <p className="panel-empty"></p>;
if (error) return <p className="panel-empty">{error}</p>;
if (!state) return null;
const hasFields = state.fields.length > 0;
async function onSave() {
setSaving(true);
setSaved(false);
setResult(null);
setError(null);
const patch: Record<string, string> = {};
for (const f of state!.fields) {
const v = values[f.id];
if (v != null && v !== "") {
patch[f.id] = v;
}
}
if (Object.keys(patch).length === 0) {
setError("没有需要保存的改动");
setSaving(false);
return;
}
try {
const r = await writeConfig(id, patch);
setResult(r);
setSaved(true);
setValues({});
// 刷新回显
const s = await readConfig(id);
setState(s);
} catch (e) {
setError(String(e));
} finally {
setSaving(false);
}
}
return (
<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 }))}
/>
))}
{state.files.length > 0 && (
<div className="config-file-info">
<span className="config-file-label"></span>
{state.files.map((f) => (
<span key={f.path} className="config-file-path">
<MonoChip>{f.path}</MonoChip>
{f.exists ? (f.parse_ok ? " · 已解析" : " · 解析异常") : " · 尚未创建"}
</span>
))}
</div>
)}
{hasFields && (
<div className="config-savebar">
<div className="config-save-msg">
{saved && (
<span className="save-ok">
<Check size={14} strokeWidth={1.5} aria-hidden="true" />
</span>
)}
{result?.backup_path && (
<span className="save-detail">
· {result.backup_path.split(/[\\/]/).pop()}
</span>
)}
{error && <span className="save-error">{error}</span>}
</div>
<button
type="button"
className="btn btn-primary"
onClick={onSave}
disabled={saving}
>
{saving ? "保存中…" : "保存配置"}
</button>
</div>
)}
</div>
);
}
function Field({
field,
value,
saved,
revealed,
onChange,
onToggleReveal,
}: {
field: ConfigFieldState;
value: string;
saved: boolean;
revealed: boolean;
onChange: (v: string) => void;
onToggleReveal: () => void;
}) {
const isPassword = field.sensitive;
const inputType = isPassword ? (revealed ? "text" : "password") : field.field_type === "url" ? "text" : "text";
return (
<div className="field">
<label className="field-label" htmlFor={`field-${field.id}`}>
{field.label_zh}
{field.required && <span className="field-required"> *</span>}
</label>
<div className="field-control">
{isPassword && saved && (
<span className="field-lock" title="已加密保存于系统密钥库">
<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)}
/>
{isPassword && (
<button
type="button"
className="field-eye"
onClick={onToggleReveal}
aria-label={revealed ? "隐藏" : "显示"}
tabIndex={-1}
>
{revealed ? <EyeOff size={16} strokeWidth={1.5} /> : <Eye size={16} strokeWidth={1.5} />}
</button>
)}
</div>
<div className="field-help">
{field.help_zh}
{isPassword && (
<span className="field-keyring-note">
{saved ? " · 已加密保存于系统密钥库" : " · 保存后写入系统密钥库,绝不明文落盘"}
</span>
)}
</div>
</div>
);
}
+18 -17
View File
@@ -10,24 +10,25 @@ interface HeaderProps {
export function Header({ title, warningCount = 0 }: HeaderProps) {
return (
<header className="header">
<h1 className="header-title">{title}</h1>
<div className="header-actions">
<div className="search-box">
<Search size={16} strokeWidth={1.5} className="search-icon" aria-hidden="true" />
<input
type="search"
placeholder="搜索 CLI…"
aria-label="搜索 CLI"
// 搜索功能属于后续波次,Wave 0 仅为占位
/>
<div className="header-inner">
<h1 className="header-title">{title}</h1>
<div className="header-actions">
<div className="search-box">
<Search size={16} strokeWidth={1.5} className="search-icon" aria-hidden="true" />
<input
type="search"
placeholder="搜索 CLI"
aria-label="搜索 CLI"
/>
</div>
{warningCount > 0 && (
<button type="button" className="env-chip">
<span className="status-dot warn breathe" aria-hidden="true" />
<span>{warningCount} </span>
<ChevronDown size={12} strokeWidth={1.5} aria-hidden="true" />
</button>
)}
</div>
{warningCount > 0 && (
<button type="button" className="env-chip">
<span className="status-dot warn breathe" aria-hidden="true" />
<span>{warningCount} </span>
<ChevronDown size={12} strokeWidth={1.5} aria-hidden="true" />
</button>
)}
</div>
</header>
);
+16 -4
View File
@@ -11,17 +11,19 @@ interface KpiCardProps {
label: string;
value: number;
tone: KpiTone;
/** 副标题:对象点名句式(视觉规范 §3.1.1) */
/** 副标题:对象点名句式(视觉规范 §3.1.1)0 值用「确认句 + 行动」短句 */
subtitle: string;
/** 0 值行动后缀(如「去目录看看」),仅当存在对应页面动作时出现 */
action?: { label: string; onClick: () => void };
}
/**
* KPI 卡(视觉规范 §3.1.1):
* - 径向渐变玻璃板 + 上缘高光 + --ad-shadow-panel
* - 顶部色条贯通整宽;数值为 0 时色条降灰、数字回 --ad-text-1
* - 数字随状态色贯穿
* - 数字随状态色贯穿;0 值副文案 = 确认句 + 青字行动后缀
*/
export function KpiCard({ label, value, tone, subtitle }: KpiCardProps) {
export function KpiCard({ label, value, tone, subtitle, action }: KpiCardProps) {
const color = value > 0 ? TONE_COLOR[tone] : "var(--ad-border)";
const valueColor = value > 0 ? TONE_COLOR[tone] : "var(--ad-text-1)";
return (
@@ -32,7 +34,17 @@ export function KpiCard({ label, value, tone, subtitle }: KpiCardProps) {
{value}
</div>
<div className="kpi-label">{label}</div>
<div className="kpi-subtitle">{subtitle}</div>
<div className="kpi-subtitle">
{subtitle}
{action && value === 0 && (
<>
{" · "}
<button type="button" className="link-btn" onClick={action.onClick}>
{action.label}
</button>
</>
)}
</div>
</div>
</div>
);
+29
View File
@@ -0,0 +1,29 @@
import type { ReactNode } from "react";
import { X } from "lucide-react";
interface ModalProps {
title: string;
onClose?: () => void;
children: ReactNode;
footer?: ReactNode;
}
/** 弹窗(视觉规范 §3.6--ad-bg-2 底 + --ad-radius-l + --ad-shadow-pop + 遮罩) */
export function Modal({ title, onClose, children, footer }: ModalProps) {
return (
<div className="modal-mask" role="dialog" aria-modal="true" aria-label={title}>
<div className="modal">
<div className="modal-head">
<h3 className="modal-title">{title}</h3>
{onClose && (
<button type="button" className="modal-close" onClick={onClose} aria-label="关闭">
<X size={16} strokeWidth={1.5} aria-hidden="true" />
</button>
)}
</div>
<div className="modal-body">{children}</div>
{footer && <div className="modal-foot">{footer}</div>}
</div>
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { useCallback, useEffect, useState } from "react";
import { authStatus, detectCli, getAdapter } from "../ipc";
import type { Adapter, AuthStatus, DetectResult } from "../ipc/types";
export interface UseCliDetailResult {
adapter: Adapter | null;
detect: DetectResult | null;
auth: AuthStatus | null;
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
}
/** 加载单个 CLI 的适配器定义 + 检测结果 + 授权状态(CLI 详情页) */
export function useCliDetail(id: string): UseCliDetailResult {
const [adapter, setAdapter] = useState<Adapter | null>(null);
const [detect, setDetect] = useState<DetectResult | null>(null);
const [auth, setAuth] = useState<AuthStatus | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
try {
const [a, d, s] = await Promise.all([getAdapter(id), detectCli(id), authStatus(id)]);
setAdapter(a);
setDetect(d);
setAuth(s);
setError(null);
} catch (err) {
setError(String(err));
} finally {
setLoading(false);
}
}, [id]);
useEffect(() => {
setLoading(true);
void refresh();
}, [refresh]);
return { adapter, detect, auth, loading, error, refresh };
}
+85 -7
View File
@@ -1,6 +1,19 @@
// 类型化 invoke 封装(唯一前端 → Rust 入口,架构 §2「界面层禁直接拼命令」)
import { invoke } from "@tauri-apps/api/core";
import type { CatalogEntry, PlatformEnv } from "./types";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import type {
Adapter,
AuthStatus,
CatalogEntry,
CliAction,
CliActionEvent,
ConfigFormState,
DetectResult,
DiagnosticReport,
DryRunPlan,
PlatformEnv,
WriteResult,
} from "./types";
/** 是否运行在 Tauri 环境(否则走 mock,便于纯浏览器联调 UI) */
function isTauri(): boolean {
@@ -8,20 +21,68 @@ function isTauri(): boolean {
}
export async function detectEnv(): Promise<PlatformEnv> {
if (!isTauri()) {
return mockPlatformEnv();
}
if (!isTauri()) return mockPlatformEnv();
return invoke<PlatformEnv>("detectEnv");
}
export async function listCatalog(): Promise<CatalogEntry[]> {
if (!isTauri()) {
return mockCatalog();
}
if (!isTauri()) return mockCatalog();
return invoke<CatalogEntry[]>("listCatalog");
}
// ---- Wave 2CLI 全链路 ----
export async function getAdapter(id: string): Promise<Adapter> {
return invoke<Adapter>("getAdapter", { id });
}
export async function detectCli(id: string): Promise<DetectResult> {
if (!isTauri()) return mockDetect(id);
return invoke<DetectResult>("detectCli", { id });
}
export async function previewAction(
id: string,
action: CliAction,
channel?: string,
): Promise<DryRunPlan> {
return invoke<DryRunPlan>("previewAction", { id, action, channel });
}
export async function runAction(id: string, action: CliAction, channel?: string): Promise<void> {
return invoke<void>("runAction", { id, action, channel });
}
export async function readConfig(id: string): Promise<ConfigFormState> {
return invoke<ConfigFormState>("readConfig", { id });
}
export async function writeConfig(
id: string,
patch: Record<string, string>,
): Promise<WriteResult> {
return invoke<WriteResult>("writeConfig", { id, patch });
}
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> {
return invoke<DiagnosticReport>("diagnose", { id });
}
/** 订阅 runAction 的流式事件(cli-action-event)。返回取消订阅函数。 */
export async function onCliAction(
handler: (payload: CliActionEvent) => void,
): Promise<UnlistenFn> {
if (!isTauri()) return () => {};
return listen<CliActionEvent>("cli-action-event", (e) => handler(e.payload));
}
// ---- 浏览器联调用 mock(仅当不在 Tauri 内时生效,不影响真实数据) ----
const mockCatalog = (): CatalogEntry[] => [
{ id: "codex", name: "Codex CLI", name_zh: "Codex CLI", vendor: "OpenAI", status: "available" },
{ id: "claude-code", name: "Claude Code", name_zh: "Claude Code", vendor: "Anthropic", status: "available" },
@@ -39,6 +100,23 @@ const mockCatalog = (): CatalogEntry[] => [
{ id: "warp", name: "Warp Agent CLI", name_zh: "Warp Agent CLI", vendor: "Warp", status: "available" },
];
const mockDetect = (id: string): DetectResult => ({
cli_id: id,
status: "not_installed",
version: null,
executable: null,
version_unconfirmed: false,
checked_at: Math.floor(Date.now() / 1000),
});
const mockAuth = (id: string): AuthStatus => ({
cli_id: id,
status: "unknown",
via: "unknown",
detail_zh: "尚未检测到授权信息",
checked_at: Math.floor(Date.now() / 1000),
});
const mockPlatformEnv = (): PlatformEnv => ({
os: "windows",
os_version: "Windows 11 24H2 (Build 26100)",
+175 -1
View File
@@ -1,4 +1,4 @@
// IPC 契约类型(对齐架构 §5 PlatformEnv / listCatalog
// IPC 契约类型(对齐架构 §2「关键 IPC 契约」与 core/types.rs
// 字段命名与 Rust 侧 serde 输出一致(snake_case)。
export interface PlatformEnv {
@@ -55,3 +55,177 @@ export interface CatalogEntry {
vendor: string;
status: string;
}
// ---- Wave 2CLI 全链路契约 ----
export type CliAction = "install" | "update" | "uninstall" | "write_config" | "authorize" | "repair";
export type DetectStatus =
| "installed"
| "not_installed"
| "not_in_path"
| "permission_denied"
| "exec_failed"
| "version_unparseable";
export interface DetectResult {
cli_id: string;
status: DetectStatus;
version: string | null;
executable: string | null;
version_unconfirmed: boolean;
checked_at: number;
}
export type AuthState = "authorized" | "unauthorized" | "unknown" | "possibly_expired";
export interface AuthStatus {
cli_id: string;
status: AuthState;
via: string;
detail_zh: string;
checked_at: number;
}
export interface ConfigFieldState {
id: string;
label_zh: string;
help_zh: string | null;
required: boolean;
sensitive: boolean;
field_type: string;
storage: string;
value: string | null;
has_value: boolean;
}
export interface ConfigFileState {
path: string;
format: string;
scope: string | null;
exists: boolean;
parse_ok: boolean;
error: string | null;
}
export interface EnvFieldState {
key: string;
sensitive: boolean;
maps_to_field: string | null;
}
export interface ConfigFormState {
cli_id: string;
files: ConfigFileState[];
fields: ConfigFieldState[];
environment: EnvFieldState[];
}
export interface WriteResult {
backup_path: string | null;
written_file: string | null;
written_fields: string[];
errors: string[];
}
export interface DryRunPlan {
commands: string[][];
elevate: boolean;
elevate_reason_zh: string | null;
affected_files: string[];
rollback_zh: string;
}
export type ActionEventKind = "step" | "stdout" | "stderr" | "done" | "error";
export interface ActionEvent {
kind: ActionEventKind;
message: string;
data: unknown;
}
export interface CliActionEvent {
cli_id: string;
event: ActionEvent;
}
export interface DiagnosticFinding {
rule_id: string;
severity: "info" | "warn" | "error";
message_zh: string;
evidence: string | null;
}
export interface DiagnosticReport {
cli_id: string;
findings: DiagnosticFinding[];
}
// ---- 适配器完整定义(getAdapter 返回,detail 页展示用) ----
export interface AdapterChannel {
id: string;
platforms?: string[];
command?: string[];
script?: { url?: string | null; kind?: string | null } | null;
package?: string | null;
elevate?: string | null;
elevate_reason_zh?: string | null;
post_checks?: string[];
}
export interface Adapter {
id: string;
name: string;
name_zh: string;
vendor: string;
status: string;
adapter_version?: string | null;
license?: string | null;
platforms?: {
windows?: { architectures?: string[]; notes?: string | null } | null;
linux?: { distributions?: string[]; architectures?: string[]; min_ubuntu?: string | null } | null;
} | null;
official?: { homepage?: string | null; docs?: string | null; allowed_hosts?: string[] } | null;
runtime_deps?: { id: string; semver_range?: string | null; required_for?: string[] }[];
install?: { preferred?: string | null; channels: AdapterChannel[] } | null;
detect?: {
executable: string;
version_args?: string[];
version_regex?: string | null;
version_unconfirmed?: boolean | null;
path_hints?: string[];
} | null;
update?: { method?: string | null; command?: string[] } | null;
uninstall?: { method?: string | null; command?: string[]; keep_config_default?: boolean } | null;
authorization?: {
modes: {
mode: string;
command?: string[];
env_keys?: string[];
status_command?: string[];
notes_zh?: string | null;
}[];
} | null;
configuration?: {
files: { path: string; format: string; scope?: string | null }[];
environment?: { key: string; sensitive?: boolean; maps_to_field?: string | null }[];
fields?: {
id: string;
label_zh: string;
help_zh?: string | null;
required?: boolean;
sensitive?: boolean;
type: string;
storage: string;
platforms?: string[];
docs_url?: string | null;
}[];
} | null;
documentation?: {
quickstart_zh?: string | null;
commands?: { cmd: string; desc_zh?: string | null }[];
updated_at?: string | null;
risks_zh?: string[];
} | null;
}
+9 -4
View File
@@ -1,17 +1,22 @@
import { Archive } from "lucide-react";
/** 备份与迁移(PRD §7):创建备份、查看内容、恢复。Wave 0 占位。 */
/** 备份与迁移(PRD §7):创建备份、查看内容、恢复。空态按 v1.3 §3.7。 */
export function BackupPage() {
return (
<div className="placeholder-page">
<div className="placeholder-icon">
<Archive size={40} strokeWidth={1.5} aria-hidden="true" />
</div>
<h2 className="placeholder-title"></h2>
<h2 className="placeholder-title"></h2>
<p className="placeholder-desc">
Wave 4
CLI
</p>
<button type="button" className="btn btn-secondary" disabled>
<button
type="button"
className="btn btn-secondary"
disabled
title="安装 CLI 后才能创建备份"
>
</button>
</div>
+16 -6
View File
@@ -2,7 +2,6 @@ import { useMemo, useState } from "react";
import { Search } from "lucide-react";
import { useCatalog } from "../hooks/useCatalog";
import { StatusBadge } from "../components/StatusBadge";
import { MonoChip } from "../components/MonoChip";
import { CliMonogram } from "../components/CliMonogram";
type StatusFilter = "all" | "installed" | "uninstalled" | "update";
@@ -22,7 +21,7 @@ const PLATFORM_FILTERS: { key: PlatformFilter; label: string }[] = [
];
/** CLI 目录页(视觉规范 §3.2 + PRD §7):搜索 + 筛选 + 14 张卡片(数据来自 catalog.yaml */
export function CatalogPage() {
export function CatalogPage({ onOpenDetail }: { onOpenDetail: (id: string) => void }) {
const { entries, loading, error } = useCatalog();
const [query, setQuery] = useState("");
const [status, setStatus] = useState<StatusFilter>("all");
@@ -88,19 +87,30 @@ export function CatalogPage() {
<div className="catalog-grid">
{filtered.map((entry) => (
<article className="catalog-card" key={entry.id}>
<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 name={entry.name} size={40} />
<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>
<p className="catalog-card-desc"> · Wave 1 </p>
<p className="catalog-card-desc"> · </p>
<div className="catalog-card-bottom">
<StatusBadge kind="uninstalled" label="未安装" />
<span className="catalog-platforms">Windows · Linux</span>
<MonoChip>v1</MonoChip>
</div>
</article>
))}
+490
View File
@@ -0,0 +1,490 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
ArrowLeft,
BookOpen,
ChevronDown,
CircleHelp,
ClipboardList,
Loader2,
ShieldAlert,
Terminal,
Trash2,
} from "lucide-react";
import { useCliDetail } from "../hooks/useCliDetail";
import { diagnose, onCliAction, previewAction, runAction } from "../ipc";
import type {
ActionEvent,
Adapter,
AuthStatus,
CliAction,
DetectResult,
DiagnosticReport,
DryRunPlan,
} from "../ipc/types";
import { CliMonogram } from "../components/CliMonogram";
import { MonoChip } from "../components/MonoChip";
import { Modal } from "../components/Modal";
import { ConfigForm } from "../components/ConfigForm";
type Tab = "overview" | "config" | "docs" | "diag";
const TABS: { key: Tab; label: string }[] = [
{ key: "overview", label: "概览" },
{ key: "config", label: "配置" },
{ key: "docs", label: "中文文档" },
{ key: "diag", label: "诊断" },
];
/** 授权状态灯(§3.3:已授权=绿、未授权=紫、可能过期=黄呼吸、未知=灰) */
function AuthLight({ auth }: { auth: AuthStatus | null }) {
if (!auth) return <span className="status-dot unknown" aria-hidden="true" />;
const cls =
auth.status === "authorized"
? "status-dot ok"
: auth.status === "unauthorized"
? "status-dot auth"
: auth.status === "possibly_expired"
? "status-dot warn breathe"
: "status-dot unknown";
const label =
auth.status === "authorized"
? "已授权"
: auth.status === "unauthorized"
? "未授权"
: auth.status === "possibly_expired"
? "授权可能过期"
: "未知";
return (
<span className="auth-light">
<span className={cls} aria-hidden="true" />
<span className="auth-light-text">{label}</span>
</span>
);
}
/** 检测状态中文 */
function detectLabel(d: DetectResult | null): string {
if (!d) return "未检测";
switch (d.status) {
case "installed":
return "已安装";
case "not_installed":
return "未安装";
case "not_in_path":
return "不在 PATH";
case "version_unparseable":
return "版本未知";
case "permission_denied":
return "无访问权限";
case "exec_failed":
return "执行失败";
default:
return d.status;
}
}
export function CliDetailPage({ id, onBack }: { id: string; onBack: () => void }) {
const { adapter, detect, auth, loading, error, refresh } = useCliDetail(id);
const [tab, setTab] = useState<Tab>("overview");
const [confirm, setConfirm] = useState<{ action: CliAction; plan: DryRunPlan } | null>(null);
const [running, setRunning] = useState(false);
const [runTitle, setRunTitle] = useState("");
const [log, setLog] = useState<ActionEvent[]>([]);
const logEndRef = useRef<HTMLDivElement | null>(null);
const installed = detect?.status === "installed";
// 订阅流式事件
useEffect(() => {
let unlisten: (() => void) | undefined;
onCliAction((payload) => {
if (payload.cli_id !== id) return;
const ev = payload.event;
if (ev.kind === "done") {
setRunning(false);
void refresh();
return;
}
if (ev.kind === "error") {
setRunning(false);
setLog((l) => [...l, { kind: "error", message: ev.message, data: null }]);
return;
}
setLog((l) => [...l, ev]);
}).then((fn) => {
unlisten = fn;
});
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 });
}
async function confirmRun() {
if (!confirm) return;
const action = confirm.action;
setConfirm(null);
setRunning(true);
setLog([]);
setRunTitle(action === "install" ? "正在安装" : action === "uninstall" ? "正在卸载" : "正在执行");
await runAction(id, action);
}
if (loading) {
return <p className="panel-empty"></p>;
}
if (error) {
return (
<div className="cli-detail">
<p className="panel-empty">{error}</p>
</div>
);
}
if (!adapter) return null;
return (
<div className="cli-detail">
<button type="button" className="link-btn detail-back" onClick={onBack}>
<ArrowLeft size={14} strokeWidth={1.5} aria-hidden="true" />
</button>
{/* 头卡(§3.3 */}
<section className="panel cli-head">
<div className="cli-head-main">
<CliMonogram id={adapter.id} name={adapter.name} size={56} />
<div className="cli-head-info">
<div className="cli-head-name">{adapter.name_zh}</div>
<div className="cli-head-vendor">
{adapter.vendor}
{adapter.license && <span className="cli-head-license"> · {adapter.license}</span>}
</div>
<div className="cli-head-meta">
<span className="status-badge-mini">{detectLabel(detect)}</span>
{detect?.version && <MonoChip>{detect.version}</MonoChip>}
{detect?.executable && (
<span className="cli-head-path">
<MonoChip>{detect.executable}</MonoChip>
</span>
)}
{detect?.version_unconfirmed && (
<span className="cli-head-unconfirmed"></span>
)}
</div>
</div>
</div>
<div className="cli-head-side">
<AuthLight auth={auth} />
<div className="cli-head-actions">
{!installed ? (
<button type="button" className="btn btn-primary" onClick={() => openConfirm("install")}>
</button>
) : (
<button type="button" className="btn btn-secondary" onClick={() => setTab("config")}>
</button>
)}
<button
type="button"
className="btn btn-text-danger"
disabled={!installed}
title={!installed ? "尚未安装" : "卸载(默认保留配置)"}
onClick={() => openConfirm("uninstall")}
>
<Trash2 size={14} strokeWidth={1.5} aria-hidden="true" />
</button>
</div>
</div>
</section>
{/* 标签页 */}
<nav className="tabs" role="tablist" aria-label="CLI 详情标签页">
{TABS.map((t) => (
<button
key={t.key}
type="button"
role="tab"
aria-selected={tab === t.key}
className={tab === t.key ? "tab active" : "tab"}
onClick={() => setTab(t.key)}
>
{t.label}
</button>
))}
</nav>
<section className="cli-detail-body">
{tab === "overview" && <OverviewTab adapter={adapter} detect={detect} />}
{tab === "config" && <ConfigForm id={id} />}
{tab === "docs" && <DocsTab adapter={adapter} />}
{tab === "diag" && <DiagTab id={id} />}
</section>
{/* 安装/卸载确认弹窗(§3.3:完整展示命令/权限/影响,确认才执行) */}
{confirm && (
<Modal
title={confirm.action === "install" ? `确认安装 ${adapter.name_zh}` : `确认卸载 ${adapter.name_zh}`}
onClose={() => setConfirm(null)}
footer={
<>
<button type="button" className="btn btn-secondary" onClick={() => setConfirm(null)}>
</button>
<button type="button" className="btn btn-primary" onClick={confirmRun}>
{confirm.action === "install" ? "安装" : "卸载"}
</button>
</>
}
>
<ConfirmBody plan={confirm.plan} action={confirm.action} />
</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>
</Modal>
)}
</div>
);
}
function ConfirmBody({ plan, action }: { plan: DryRunPlan; action: CliAction }) {
return (
<div className="confirm-body">
<div className="confirm-section">
<div className="confirm-label"></div>
{plan.commands.length === 0 ? (
<p className="confirm-empty"></p>
) : (
plan.commands.map((cmd, i) => (
<div key={i} className="confirm-cmd">
<MonoChip>{cmd.join(" ")}</MonoChip>
</div>
))
)}
</div>
<div className="confirm-section">
<div className="confirm-label"></div>
<p className="confirm-text">
{plan.elevate ? (
<>{plan.elevate_reason_zh ? `${plan.elevate_reason_zh}` : ""}</>
) : (
"无需管理员权限(安装到用户目录)"
)}
</p>
</div>
{plan.affected_files.length > 0 && (
<div className="confirm-section">
<div className="confirm-label"></div>
<div className="confirm-files">
{plan.affected_files.map((f) => (
<span key={f} className="confirm-file">
<MonoChip>{f}</MonoChip>
</span>
))}
</div>
</div>
)}
<div className="confirm-section">
<div className="confirm-label"></div>
<p className="confirm-text">{plan.rollback_zh}</p>
</div>
{action === "uninstall" && (
<p className="confirm-note"></p>
)}
</div>
);
}
function OverviewTab({ adapter, detect }: { adapter: Adapter; detect: DetectResult | null }) {
const channels = adapter.install?.channels ?? [];
return (
<div className="detail-overview">
<div className="detail-block">
<h3 className="detail-block-title"></h3>
<p className="detail-block-text">
{adapter.documentation?.quickstart_zh ?? "参见官方文档。"}
</p>
</div>
<div className="detail-block">
<h3 className="detail-block-title"></h3>
{channels.length === 0 ? (
<p className="detail-block-text"></p>
) : (
<ul className="detail-channels">
{channels.map((c) => (
<li key={c.id} className="detail-channel">
<MonoChip>{c.id}</MonoChip>
{c.command && c.command.length > 0 && <MonoChip>{c.command.join(" ")}</MonoChip>}
{c.package && <span className="detail-channel-pkg"> {c.package}</span>}
</li>
))}
</ul>
)}
</div>
{detect && detect.status !== "installed" && detect.status !== "not_installed" && (
<div className="detail-block detail-note">
<CircleHelp size={14} strokeWidth={1.5} aria-hidden="true" />
{detectLabel(detect)} PATH
</div>
)}
</div>
);
}
function DocsTab({ adapter }: { adapter: Adapter }) {
const doc = adapter.documentation;
return (
<div className="detail-docs">
<div className="detail-block">
<h3 className="detail-block-title">
<BookOpen size={14} strokeWidth={1.5} aria-hidden="true" />
</h3>
{(doc?.commands ?? []).length === 0 ? (
<p className="detail-block-text"></p>
) : (
<ul className="detail-commands">
{doc!.commands!.map((c) => (
<li key={c.cmd} className="detail-command">
<MonoChip>{c.cmd}</MonoChip>
{c.desc_zh && <span className="detail-command-desc">{c.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" />
<div>
{doc!.risks_zh!.map((r) => (
<p key={r}>{r}</p>
))}
</div>
</div>
)}
{adapter.official?.homepage && (
<div className="detail-block detail-block-text">
<MonoChip>{adapter.official.homepage}</MonoChip>
</div>
)}
</div>
);
}
function severityMeta(sev: "info" | "warn" | "error") {
switch (sev) {
case "info":
return { cls: "diag-sev-info", label: "提示" };
case "warn":
return { cls: "diag-sev-warn", label: "警告" };
case "error":
return { cls: "diag-sev-error", label: "错误" };
}
}
function DiagTab({ id }: { id: string }) {
const [report, setReport] = useState<DiagnosticReport | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [open, setOpen] = useState<Record<number, boolean>>({});
useEffect(() => {
let cancelled = false;
diagnose(id)
.then((r) => {
if (!cancelled) {
setReport(r);
setLoading(false);
}
})
.catch((e) => {
if (!cancelled) {
setError(String(e));
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [id]);
if (loading) return <p className="panel-empty"></p>;
if (error) return <p className="panel-empty">{error}</p>;
if (!report) return null;
const findings = useMemo(() => report.findings, [report]);
return (
<div className="diag-result">
<div className="diag-summary">
{findings.length === 0 ? (
<>
<span className="status-dot ok" aria-hidden="true" />
<span></span>
</>
) : (
<>
<span className={`status-dot ${findings.some((f) => f.severity === "error") ? "error" : "warn"}`} aria-hidden="true" />
<span>
{findings.filter((f) => f.severity === "error").length} {" "}
{findings.filter((f) => f.severity === "warn").length} {" "}
{findings.filter((f) => f.severity === "info").length}
</span>
</>
)}
</div>
{findings.map((f, i) => {
const meta = severityMeta(f.severity);
return (
<div key={i} className="diag-finding">
<div className={`diag-sev-bar ${meta.cls}`} aria-hidden="true" />
<div className="diag-finding-body">
<div className="diag-finding-head">
<span className={`diag-sev-tag ${meta.cls}`}>{meta.label}</span>
<span className="diag-finding-rule">{f.rule_id}</span>
</div>
<p className="diag-finding-msg">{f.message_zh}</p>
{f.evidence && (
<>
<button
type="button"
className="link-btn diag-evidence-toggle"
onClick={() => setOpen((o) => ({ ...o, [i]: !o[i] }))}
>
<ChevronDown size={14} strokeWidth={1.5} aria-hidden="true" />
</button>
{open[i] && <pre className="diag-evidence">{f.evidence}</pre>}
</>
)}
</div>
</div>
);
})}
</div>
);
}
+6 -5
View File
@@ -1,17 +1,18 @@
import { SlidersHorizontal } from "lucide-react";
import type { PageKey } from "../components/Sidebar";
/** 配置中心(PRD §7):按 CLI 展示中文配置表单。Wave 0 占位。 */
export function ConfigCenterPage() {
/** 配置中心(PRD §7):按 CLI 展示中文配置表单。空态按 v1.3 §3.7。 */
export function ConfigCenterPage({ onNavigate }: { onNavigate: (p: PageKey) => void }) {
return (
<div className="placeholder-page">
<div className="placeholder-icon">
<SlidersHorizontal size={40} strokeWidth={1.5} aria-hidden="true" />
</div>
<h2 className="placeholder-title"></h2>
<h2 className="placeholder-title"> CLI</h2>
<p className="placeholder-desc">
Wave 2 使
CLI
</p>
<button type="button" className="btn btn-secondary" disabled>
<button type="button" className="btn btn-secondary" onClick={() => onNavigate("catalog")}>
CLI
</button>
</div>
+6 -5
View File
@@ -1,17 +1,18 @@
import { SquareTerminal } from "lucide-react";
import type { PageKey } from "../components/Sidebar";
/** 我的 CLIPRD §7):版本 / 路径 / 授权状态。Wave 0 尚无已安装 CLI,展示空态。 */
export function MyCliPage() {
/** 我的 CLIPRD §7):版本 / 路径 / 授权状态。空态按 v1.3 §3.7。 */
export function MyCliPage({ onNavigate }: { onNavigate: (p: PageKey) => void }) {
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>
<h2 className="placeholder-title"> CLI</h2>
<p className="placeholder-desc">
Wave 1 CLI
CLI
</p>
<button type="button" className="btn btn-primary" disabled>
<button type="button" className="btn btn-primary" onClick={() => onNavigate("catalog")}>
CLI
</button>
</div>
+236 -94
View File
@@ -1,12 +1,13 @@
import { useState } from "react";
import { Activity, Archive, ChevronRight, Lock, Plus } from "lucide-react";
import { Activity, Archive, Lock, Plus, ScanSearch } from "lucide-react";
import { useEnv } from "../hooks/useEnv";
import { useCatalog } from "../hooks/useCatalog";
import { KpiCard } from "../components/KpiCard";
import { MonoChip } from "../components/MonoChip";
import { StatusBadge } from "../components/StatusBadge";
import { CliMonogram } from "../components/CliMonogram";
import type { PlatformEnv, RuntimeInfo } from "../ipc/types";
import type { PlatformEnv, RuntimeInfo, RuntimeStatus, CatalogEntry } from "../ipc/types";
import type { PageKey } from "../components/Sidebar";
/** 平台相关运行时集合(Windows 不看 aptLinux 不看 winget */
const WINDOWS_RUNTIMES = ["node", "npm", "python", "uv", "git", "winget"] as const;
@@ -24,42 +25,61 @@ function runtimeProblems(env: PlatformEnv): { name: string; info: RuntimeInfo }[
return problems;
}
function runtimeStatusText(status: string): string {
/** 本机环境运行时项四元组(v1.3 §3.1.6):状态点 + 名称 + 版本位 + 行动位 */
function runtimeMeta(status: RuntimeStatus): {
dot: string;
missingText: string | null;
needsGuide: boolean;
} {
switch (status) {
case "not_in_path":
return "不在 PATH";
case "installed":
return { dot: "status-dot ok", missingText: null, needsGuide: false };
case "not_installed":
return "未安装";
return { dot: "status-dot unknown", missingText: "未检测到", needsGuide: true };
case "not_in_path":
return { dot: "status-dot warn breathe", missingText: "不在 PATH", needsGuide: false };
case "permission_denied":
return { dot: "status-dot warn breathe", missingText: "无访问权限", needsGuide: false };
case "exec_failed":
return "执行失败";
return { dot: "status-dot warn breathe", missingText: "执行失败", needsGuide: false };
case "version_unparseable":
return "版本未知";
return { dot: "status-dot warn breathe", missingText: "版本未知", needsGuide: false };
default:
return "未知";
return { dot: "status-dot unknown", missingText: "未知", needsGuide: false };
}
}
function RuntimeItem({ name, info }: { name: string; info: RuntimeInfo }) {
const dotClass =
info.status === "installed"
? "status-dot ok"
: info.status === "exec_failed"
? "status-dot error"
: "status-dot warn";
function RuntimeItem({
name,
info,
onNavigate,
}: {
name: string;
info: RuntimeInfo;
onNavigate: (p: PageKey) => void;
}) {
const meta = runtimeMeta(info.status);
return (
<div className="runtime-item">
<span className={dotClass} aria-hidden="true" />
<span className={meta.dot} aria-hidden="true" />
<span className="runtime-name">{name}</span>
{info.version ? (
<MonoChip>{info.version}</MonoChip>
<span className="runtime-version">
<MonoChip>{info.version}</MonoChip>
</span>
) : (
<span className="runtime-missing">{runtimeStatusText(info.status)}</span>
<span className="runtime-missing">{meta.missingText}</span>
)}
{meta.needsGuide && (
<button type="button" className="link-btn runtime-action" onClick={() => onNavigate("catalog")}>
</button>
)}
</div>
);
}
function EnvPanel() {
function EnvPanel({ onNavigate }: { onNavigate: (p: PageKey) => void }) {
const { env, loading, error } = useEnv();
const [showAllPath, setShowAllPath] = useState(false);
@@ -122,7 +142,7 @@ function EnvPanel() {
(key) => {
const info = env.runtimes[key];
if (!info) return null;
return <RuntimeItem key={key} name={key} info={info} />;
return <RuntimeItem key={key} name={key} info={info} onNavigate={onNavigate} />;
},
)}
</div>
@@ -155,100 +175,222 @@ function EnvPanel() {
);
}
/** 总览页(视觉规范 §3.1 + PRD §7):KPI + 最近诊断/快速操作 + CLI 状态网格 + 本机环境 */
export function OverviewPage() {
/** 首跑空态引导插画(v1.3 §7:等距线框终端 + 集装箱 Dock,1px 线,霓虹点缀) */
function OnboardingArt() {
return (
<svg width="200" height="150" viewBox="0 0 200 150" fill="none" aria-hidden="true">
<rect x="24" y="18" width="120" height="86" rx="8" stroke="currentColor" strokeWidth="1" />
<line x1="24" y1="40" x2="144" y2="40" stroke="currentColor" strokeWidth="1" />
<circle cx="36" cy="29" r="2" style={{ fill: "var(--ad-primary)" }} />
<circle cx="46" cy="29" r="2" fill="currentColor" opacity="0.55" />
<circle cx="56" cy="29" r="2" fill="currentColor" opacity="0.55" />
<rect x="36" y="52" width="58" height="6" rx="3" fill="currentColor" opacity="0.5" />
<rect x="36" y="66" width="84" height="6" rx="3" fill="currentColor" opacity="0.32" />
<rect x="36" y="80" width="46" height="6" rx="3" style={{ fill: "var(--ad-accent)" }} opacity="0.8" />
<rect x="24" y="116" width="36" height="26" rx="4" stroke="currentColor" strokeWidth="1" />
<rect x="66" y="116" width="36" height="26" rx="4" stroke="currentColor" strokeWidth="1" />
<rect x="108" y="116" width="36" height="26" rx="4" stroke="currentColor" strokeWidth="1" />
<line x1="24" y1="142" x2="144" y2="142" stroke="currentColor" strokeWidth="1" opacity="0.4" />
</svg>
);
}
/** 首跑引导区(v1.3 §3.1.5:插画 + 三句式文案 + 主行动按钮) */
function Onboarding({ onNavigate }: { onNavigate: (p: PageKey) => void }) {
return (
<div className="panel onboarding">
<div className="onboarding-art">
<OnboardingArt />
</div>
<div className="onboarding-copy">
<h2 className="onboarding-title"></h2>
<p className="onboarding-desc">
Agent CLI
</p>
<div className="onboarding-actions">
<button type="button" className="btn btn-primary" onClick={() => onNavigate("catalog")}>
CLI
</button>
<button type="button" className="btn btn-secondary">
</button>
</div>
</div>
</div>
);
}
/** 最近诊断卡(v1.3 §3.1.2:无记录时展示空态,不渲染占位行与「查看全部」) */
function DiagPanel() {
// Wave 0.5 尚无诊断引擎数据,恒为空态(语义按 §3.7,不虚构记录)
const records: { cli: string; text: string; time: string }[] = [];
return (
<div className="panel diag-panel">
<div className="panel-head">
<h2 className="panel-title"></h2>
</div>
{records.length === 0 ? (
<div className="diag-empty">
<span className="diag-empty-text"></span>
<button type="button" className="btn btn-secondary">
<ScanSearch size={16} strokeWidth={1.5} aria-hidden="true" />
</button>
</div>
) : (
<ul className="diag-list">
{records.map((r) => (
<li className="diag-row" key={r.cli}>
<span className="status-dot ok" aria-hidden="true" />
<span className="diag-cli">{r.cli}</span>
<span className="diag-text">{r.text}</span>
<span className="diag-time">{r.time}</span>
</li>
))}
</ul>
)}
</div>
);
}
/** 快速操作卡(v1.3 §3.1.3:三按钮竖向操作组,创建备份依赖数据置灰) */
function QuickActions({
firstRun,
onNavigate,
}: {
firstRun: boolean;
onNavigate: (p: PageKey) => void;
}) {
return (
<div className="panel quick-card">
<h2 className="panel-title"></h2>
<button type="button" className="btn btn-primary" onClick={() => onNavigate("catalog")}>
<Plus size={16} strokeWidth={1.5} aria-hidden="true" /> CLI
</button>
<button type="button" className="btn btn-secondary">
<Activity size={16} strokeWidth={1.5} aria-hidden="true" />
</button>
<button
type="button"
className="btn btn-secondary"
disabled
title={firstRun ? "安装 CLI 后才能创建备份" : "暂无内容可备份"}
>
<Archive size={16} strokeWidth={1.5} aria-hidden="true" />
</button>
<div className="recent-installs">
<div className="group-label"></div>
<p className="group-empty"></p>
</div>
<p className="safety-note">
<Lock size={12} strokeWidth={1.5} aria-hidden="true" />
</p>
</div>
);
}
/** CLI 状态网格块(v1.3 §3.1.4;首跑精简为两行 + 安装入口,§3.1.5) */
function CliBlock({
entry,
compact,
onNavigate,
}: {
entry: CatalogEntry;
compact: boolean;
onNavigate: (p: PageKey) => void;
}) {
const installed = entry.status === "installed";
return (
<div className="cli-block">
<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>}
</div>
{!compact && (
<div className="cli-block-auth">
{installed ? (
<>
<span className="status-dot ok" aria-hidden="true" />
<span></span>
</>
) : (
<>
<span className="status-dot unknown" aria-hidden="true" />
<span> · </span>
</>
)}
</div>
)}
{compact && (
<div className="cli-block-install">
<button type="button" className="link-btn" onClick={() => onNavigate("catalog")}>
</button>
</div>
)}
</div>
);
}
interface OverviewProps {
onNavigate: (p: PageKey) => void;
}
/** 总览页(视觉规范 §3.1 + PRD §7):首跑空态 / KPI + 诊断/快速操作 + CLI 状态网格 + 本机环境 */
export function OverviewPage({ onNavigate }: OverviewProps) {
const { env } = useEnv();
const { entries } = useCatalog();
const installedCount = entries.filter((e) => e.status === "installed").length;
const firstRun = installedCount === 0; // 无安装且无诊断记录(当前无诊断引擎)
const warningCount = env ? runtimeProblems(env).length : 0;
const warningNames = env ? runtimeProblems(env).map((p) => p.name) : [];
return (
<div className="overview">
{/* ① KPI 一排 4 张 */}
<section className="kpi-grid" aria-label="概览指标">
<KpiCard label="已安装" value={0} tone="primary" subtitle="暂无已安装 CLI" />
<KpiCard label="待授权" value={0} tone="attention" subtitle="暂无待授权项" />
<KpiCard label="可更新" value={0} tone="accent" subtitle="暂无可用更新" />
<KpiCard
label="环境异常"
value={warningCount}
tone="warning"
subtitle={warningCount > 0 ? warningNames.join(" · ") : "环境正常"}
/>
</section>
{/* ① KPI 一排 4 张(首跑空态隐藏,v1.3 §3.1.5) */}
{!firstRun && (
<section className="kpi-grid" aria-label="概览指标">
<KpiCard label="已安装" value={installedCount} tone="primary" subtitle="暂无安装" action={{ label: "去目录看看", onClick: () => onNavigate("catalog") }} />
<KpiCard label="待授权" value={0} tone="attention" subtitle="暂无待授权" />
<KpiCard label="可更新" value={0} tone="accent" subtitle="全部已是最新" />
<KpiCard
label="环境异常"
value={warningCount}
tone="warning"
subtitle={warningCount > 0 ? warningNames.join(" · ") : "环境正常"}
/>
</section>
)}
{/* ② 中段:左 2/3 最近诊断 + 右 1/3 快速操作 */}
{/* ② 中段:诊断/引导 + 快速操作(超宽三栏见 global.css) */}
<section className="overview-mid">
<div className="panel diag-panel">
<div className="panel-head">
<h2 className="panel-title"></h2>
<button type="button" className="link-btn">
<ChevronRight size={14} strokeWidth={1.5} aria-hidden="true" />
</button>
</div>
<ul className="diag-list">
<li className="diag-row">
<span className="status-dot ok" aria-hidden="true" />
<span className="diag-cli"></span>
<span className="diag-text"> Wave 1 </span>
<span className="diag-time"></span>
</li>
</ul>
</div>
<div className="panel quick-card">
<h2 className="panel-title"></h2>
<button type="button" className="btn btn-primary" disabled>
<Plus size={16} strokeWidth={1.5} aria-hidden="true" /> CLI
</button>
<button type="button" className="btn btn-secondary" disabled>
<Activity size={16} strokeWidth={1.5} aria-hidden="true" />
</button>
<button type="button" className="btn btn-secondary" disabled>
<Archive size={16} strokeWidth={1.5} aria-hidden="true" />
</button>
<div className="recent-installs">
<div className="group-label"></div>
<p className="group-empty"></p>
</div>
<p className="safety-note">
<Lock size={12} strokeWidth={1.5} aria-hidden="true" />
</p>
</div>
{firstRun ? <Onboarding onNavigate={onNavigate} /> : <DiagPanel />}
<QuickActions firstRun={firstRun} onNavigate={onNavigate} />
</section>
{/* ③ 下段:CLI 状态网格通栏 */}
{/* ③ 下段:CLI 状态网格通栏(首跑精简:可安装列表 + 安装入口) */}
<section className="panel cli-status-panel">
<div className="panel-head">
<h2 className="panel-title">CLI </h2>
<button type="button" className="link-btn">
{entries.length}
<ChevronRight size={14} strokeWidth={1.5} aria-hidden="true" />
</button>
<h2 className="panel-title">
{firstRun ? `可安装的 CLI${entries.length}` : "CLI 状态"}
</h2>
{/* 「管理全部 N 个」入口:仅当未全量展示时才渲染(v1.3 §3.1.4),本页恒全量展示,故不渲染 */}
</div>
<div className="cli-status-grid">
{entries.map((entry) => (
<div className="cli-block" key={entry.id}>
<div className="cli-block-top">
<CliMonogram name={entry.name} size={32} />
<span className="cli-block-name">{entry.name_zh}</span>
<StatusBadge kind="uninstalled" label="未安装" />
</div>
<div className="cli-block-version">
<MonoChip></MonoChip>
</div>
<div className="cli-block-auth">
<span className="status-dot unknown" aria-hidden="true" />
<span> · </span>
</div>
</div>
<CliBlock key={entry.id} entry={entry} compact={firstRun} onNavigate={onNavigate} />
))}
</div>
</section>
{/* ④ 本机环境(真实检测结果,架构 §5) */}
<EnvPanel />
{/* ④ 本机环境(真实检测结果,架构 §5;超宽断点上提为第三栏 */}
<EnvPanel onNavigate={onNavigate} />
</div>
);
}
+3 -3
View File
@@ -9,14 +9,14 @@ export function SettingsPage() {
<div className="setting-row">
<div className="setting-info">
<div className="setting-label"></div>
<div className="setting-hint"> CLI Wave 1 </div>
<div className="setting-hint"> CLI </div>
</div>
<span className="setting-value"></span>
</div>
<div className="setting-row">
<div className="setting-info">
<div className="setting-label"></div>
<div className="setting-hint"> AgentDock CLI Wave 4 </div>
<div className="setting-hint"> AgentDock CLI </div>
</div>
<span className="setting-value"></span>
</div>
@@ -30,7 +30,7 @@ export function SettingsPage() {
<div className="setting-row">
<div className="setting-info">
<div className="setting-label"></div>
<div className="setting-hint">Wave 1 </div>
<div className="setting-hint"></div>
</div>
<span className="setting-value"></span>
</div>
File diff suppressed because it is too large Load Diff
+4
View File
@@ -12,6 +12,10 @@
--ad-border: #22304A; /* 常规 1px 分隔线 */
--ad-border-bright: #35507A; /* 悬浮/选中的边框 */
/* ---- 自定义滚动条(v1.3 §2.7:灰蓝滑块、透明轨道、不发光)---- */
--ad-scrollbar-thumb: #1A2438; /* = --ad-bg-3 */
--ad-scrollbar-thumb-hover: #35507A; /* = --ad-border-bright */
/* ---- 霓虹强调色(全系统只有这两个霓虹色)---- */
--ad-primary: #00E5FF; /* 主操作、链接、聚焦、选中态(青) */
--ad-primary-hover: #4DEFFF; /* 主操作悬浮 */
+2 -4
View File
@@ -30,10 +30,8 @@
--ad-btn-accent-glow-hover: 0 0 14px rgba(255, 77, 219, 0.30),
0 0 36px rgba(255, 77, 219, 0.12);
/* ---- 背景网格(§3.0:要么可见要么不做---- */
--ad-grid-color: rgba(0, 229, 255, 0.05); /* 透明度固定 5%,施工员不许再调 */
--ad-grid-size: 40px;
--ad-grid-mask: radial-gradient(120% 90% at 50% 40%, #000 30%, transparent 75%);
/* ---- 背景氛围(v1.3 §3.0:网格移除,降级为静态深空渐变,三档通用---- */
--ad-bg-gradient: radial-gradient(120% 90% at 50% 0%, #0D1420 0%, #070B14 70%);
/* ---- 弹窗遮罩(§3.6---- */
--ad-modal-mask: rgba(4, 7, 13, 0.7);
+5
View File
@@ -13,6 +13,11 @@
--ad-space-6: 24px;
--ad-space-8: 32px;
--ad-space-10: 40px;
--ad-space-12: 48px; /* v1.3 §3.0 超宽断点内容区边距 */
/* ---- 内容区宽度体系(v1.3 §6:标准 1440 / 宽阔 1760 / 超宽全宽)---- */
--ad-frame-max: 1440px;
--ad-frame-pad-x: var(--ad-space-8);
/* ---- 圆角 ---- */
--ad-radius-s: 4px; /* 标签、小按钮、chip */
-12
View File
@@ -34,18 +34,6 @@
box-shadow: var(--ad-glow-balanced);
}
/* 背景网格漂移(§4.8,60s 一周,仅高档/中档) */
[data-fx-tier="low"] .grid-drift {
animation: none;
}
.grid-drift {
animation: grid-drift 60s linear infinite;
}
@keyframes grid-drift {
from { background-position: 0 0; }
to { background-position: 40px 40px; }
}
/* 状态点呼吸(§4.5:仅警告/错误/授权可能过期呼吸;低档停用) */
[data-fx-tier="low"] .status-dot-breathe {
animation: none;
+4
View File
@@ -3,5 +3,9 @@ name = "agentdock-config"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "配置读写:多格式编解码(TOML/JSON)+ 原子写入 + 自动备份(架构 §6)"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
+103
View File
@@ -0,0 +1,103 @@
//! 原子写入 + 自动备份(架构 §6.1)
//!
//! 对目标文件 F
//! 1. F 存在 → 复制为 `F.bak.<时间戳>` 并返回备份路径;
//! 2. 写临时文件 `F.tmp.<pid>`
//! 3. `rename` 覆盖(Windows 用 `MoveFileEx` 替换语义由 std 的 rename 处理,覆盖已存在文件);
//! 4. 失败保留原文件与临时文件,错误上抛。
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::error::ConfigError;
/// 生成唯一后缀(时间戳 + 进程 id)。
fn suffix() -> String {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
format!("{}.{}", millis, std::process::id())
}
/// 计算备份路径:`F.bak.<时间戳>.<pid>`。
pub fn backup_path(path: &Path) -> PathBuf {
let mut name = path
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
name.push_str(".bak.");
name.push_str(&suffix());
path.with_file_name(name)
}
/// 原子写文件:先备份已存在文件(返回备份路径),再 tmp + rename。
pub fn atomic_write(path: &Path, content: &str) -> Result<Option<PathBuf>, ConfigError> {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)
.map_err(|e| ConfigError::Io(format!("创建目录 {} 失败: {e}", parent.display())))?;
}
}
let backup = if path.exists() {
let bak = backup_path(path);
std::fs::copy(path, &bak)
.map_err(|e| ConfigError::Io(format!("备份 {} 失败: {e}", path.display())))?;
Some(bak)
} else {
None
};
let tmp = path.with_file_name(format!(
"{}.tmp.{}",
path.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default(),
std::process::id()
));
std::fs::write(&tmp, content)
.map_err(|e| ConfigError::Io(format!("写临时文件 {} 失败: {e}", tmp.display())))?;
// rename 覆盖已存在文件(Windows 上 std::fs::rename 对已存在目标会失败,
// 先移除目标再 rename 以保证替换语义)。
if path.exists() {
std::fs::remove_file(path)
.map_err(|e| ConfigError::Io(format!("移除旧文件 {} 失败: {e}", path.display())))?;
}
std::fs::rename(&tmp, path)
.map_err(|e| ConfigError::Io(format!("替换文件 {} 失败: {e}", path.display())))?;
Ok(backup)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn atomic_write_creates_file_without_backup() {
let dir = std::env::temp_dir().join(format!("agentdock-config-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let f = dir.join("new.toml");
let backup = atomic_write(&f, "model = \"x\"\n").unwrap();
assert!(backup.is_none());
assert_eq!(std::fs::read_to_string(&f).unwrap(), "model = \"x\"\n");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn atomic_write_backs_up_existing_file() {
let dir = std::env::temp_dir().join(format!("agentdock-config-bak-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let f = dir.join("config.toml");
std::fs::write(&f, "old").unwrap();
let backup = atomic_write(&f, "new").unwrap().expect("已存在文件应产生备份");
assert!(backup.exists());
assert_eq!(std::fs::read_to_string(&f).unwrap(), "new");
assert_eq!(std::fs::read_to_string(&backup).unwrap(), "old");
let _ = std::fs::remove_dir_all(&dir);
}
}
+303
View File
@@ -0,0 +1,303 @@
//! 配置格式与统一编解码(架构 §6.2 `ConfigCodec`
//!
//! 统一以 `serde_json::Value` 作为内部表示(运行时单一真相)。TOML 通过
//! `toml::Value` 桥接(双向转换),JSON/JSONC 直接用 serde_json。yaml / crushrc
//! 属 Wave 3 工具,本波返回明确的「未实现」错误,不静默吞数据。
use serde_json::{Map, Value};
use crate::error::ConfigError;
/// 支持的配置格式。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigFormat {
Toml,
Json,
Jsonc,
Yaml,
Env,
Crushrc,
}
impl ConfigFormat {
pub fn from_str(s: &str) -> ConfigFormat {
match s {
"toml" => ConfigFormat::Toml,
"json" => ConfigFormat::Json,
"jsonc" => ConfigFormat::Jsonc,
"yaml" => ConfigFormat::Yaml,
"env" => ConfigFormat::Env,
"crushrc" => ConfigFormat::Crushrc,
_ => ConfigFormat::Json,
}
}
}
/// 解析配置文本为内部表示。
pub fn parse(format: ConfigFormat, text: &str) -> Result<Value, ConfigError> {
match format {
ConfigFormat::Json | ConfigFormat::Jsonc => {
// jsonc:剥离 // 与 /* */ 注释后按 JSON 解析(不追求极致,覆盖常见注释)
let text = strip_jsonc_comments(text);
serde_json::from_str(&text).map_err(|e| ConfigError::Parse(e.to_string()))
}
ConfigFormat::Toml => {
let tv: toml::Value = toml::from_str(text).map_err(|e| ConfigError::Parse(e.to_string()))?;
Ok(toml_to_json(&tv))
}
ConfigFormat::Env => parse_env(text),
ConfigFormat::Yaml | ConfigFormat::Crushrc => Err(ConfigError::Unsupported(format!(
"{:?} 格式本波(Wave 2)未实现,将在后续波次接入",
format
))),
}
}
/// 序列化内部表示为配置文本。
pub fn serialize(format: ConfigFormat, value: &Value) -> Result<String, ConfigError> {
match format {
ConfigFormat::Json | ConfigFormat::Jsonc => serde_json::to_string_pretty(value)
.map_err(|e| ConfigError::Parse(e.to_string())),
ConfigFormat::Toml => {
let tv = json_to_toml(value);
toml::to_string(&tv).map_err(|e| ConfigError::Parse(e.to_string()))
}
ConfigFormat::Env => serialize_env(value),
ConfigFormat::Yaml | ConfigFormat::Crushrc => Err(ConfigError::Unsupported(format!(
"{:?} 格式本波(Wave 2)未实现,将在后续波次接入",
format
))),
}
}
/// 按点分路径(如 `model` / `model.name` / `env.ANTHROPIC_API_KEY`)读取值。
pub fn get_path<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
if path.is_empty() {
return Some(value);
}
let mut cur = value;
for seg in path.split('.') {
match cur {
Value::Object(map) => cur = map.get(seg)?,
_ => return None,
}
}
Some(cur)
}
/// 按点分路径写入值,缺失的中间对象自动创建。
pub fn set_path(value: &mut Value, path: &str, new: Value) -> Result<(), ConfigError> {
let segs: Vec<&str> = path.split('.').filter(|s| !s.is_empty()).collect();
if segs.is_empty() {
return Err(ConfigError::InvalidPath("字段路径不能为空".into()));
}
set_path_impl(value, &segs, new)
}
fn set_path_impl(value: &mut Value, segs: &[&str], new: Value) -> Result<(), ConfigError> {
if !value.is_object() {
*value = Value::Object(Map::new());
}
let map = value
.as_object_mut()
.ok_or_else(|| ConfigError::InvalidPath("路径中间节点不是对象".into()))?;
let seg = segs[0];
if segs.len() == 1 {
map.insert(seg.to_string(), new);
return Ok(());
}
if !map.contains_key(seg) {
map.insert(seg.to_string(), Value::Object(Map::new()));
}
let next = map.get_mut(seg).unwrap();
set_path_impl(next, &segs[1..], new)
}
// ---- TOML <-> JSON 双向桥接 ----
fn toml_to_json(v: &toml::Value) -> Value {
match v {
toml::Value::String(s) => Value::String(s.clone()),
toml::Value::Integer(i) => Value::Number((*i).into()),
toml::Value::Float(f) => serde_json::Number::from_f64(*f)
.map(Value::Number)
.unwrap_or(Value::Null),
toml::Value::Boolean(b) => Value::Bool(*b),
toml::Value::Datetime(d) => Value::String(d.to_string()),
toml::Value::Array(a) => Value::Array(a.iter().map(toml_to_json).collect()),
toml::Value::Table(t) => {
let mut m = Map::new();
for (k, v) in t {
m.insert(k.clone(), toml_to_json(v));
}
Value::Object(m)
}
}
}
fn json_to_toml(v: &Value) -> toml::Value {
match v {
Value::String(s) => toml::Value::String(s.clone()),
Value::Number(n) => {
if let Some(i) = n.as_i64() {
toml::Value::Integer(i)
} else if let Some(f) = n.as_f64() {
toml::Value::Float(f)
} else {
toml::Value::String(n.to_string())
}
}
Value::Bool(b) => toml::Value::Boolean(*b),
Value::Array(a) => toml::Value::Array(a.iter().map(json_to_toml).collect()),
Value::Object(m) => {
let mut t = toml::map::Map::new();
for (k, v) in m {
t.insert(k.clone(), json_to_toml(v));
}
toml::Value::Table(t)
}
Value::Null => toml::Value::String(String::new()),
}
}
// ---- env 格式(KEY=VALUE----
fn parse_env(text: &str) -> Result<Value, ConfigError> {
let mut m = Map::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((k, v)) = line.split_once('=') {
let v = v.trim();
let v = v.trim_matches('"').trim_matches('\'');
m.insert(k.trim().to_string(), Value::String(v.to_string()));
}
}
Ok(Value::Object(m))
}
fn serialize_env(value: &Value) -> Result<String, ConfigError> {
let obj = value.as_object().ok_or_else(|| {
ConfigError::Parse("env 格式要求顶层为对象".into())
})?;
let mut out = String::new();
for (k, v) in obj {
if let Some(s) = v.as_str() {
out.push_str(&format!("{k}={s}\n"));
}
}
Ok(out)
}
/// 剥离 JSONC 的 // 与 /* */ 注释(保守处理,不处理字符串内的注释序列)。
fn strip_jsonc_comments(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let chars: Vec<char> = text.chars().collect();
let mut i = 0;
let n = chars.len();
let mut in_string = false;
let mut escaped = false;
while i < n {
let c = chars[i];
if in_string {
out.push(c);
if escaped {
escaped = false;
} else if c == '\\' {
escaped = true;
} else if c == '"' {
in_string = false;
}
i += 1;
continue;
}
if c == '"' {
in_string = true;
out.push(c);
i += 1;
continue;
}
if c == '/' && i + 1 < n && chars[i + 1] == '/' {
while i < n && chars[i] != '\n' {
i += 1;
}
continue;
}
if c == '/' && i + 1 < n && chars[i + 1] == '*' {
i += 2;
while i + 1 < n && !(chars[i] == '*' && chars[i + 1] == '/') {
i += 1;
}
i += 2;
continue;
}
out.push(c);
i += 1;
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn toml_roundtrip() {
let text = "model = \"gpt-5\"\n[model_providers.proxy]\nbase_url = \"http://proxy\"\n";
let v = parse(ConfigFormat::Toml, text).unwrap();
assert_eq!(get_path(&v, "model").and_then(|x| x.as_str()), Some("gpt-5"));
assert_eq!(
get_path(&v, "model_providers.proxy.base_url").and_then(|x| x.as_str()),
Some("http://proxy")
);
let out = serialize(ConfigFormat::Toml, &v).unwrap();
assert!(out.contains("gpt-5"));
}
#[test]
fn json_roundtrip() {
let text = r#"{ "model": "claude-sonnet", "env": { "ANTHROPIC_API_KEY": "x" } }"#;
let v = parse(ConfigFormat::Json, text).unwrap();
assert_eq!(get_path(&v, "model").and_then(|x| x.as_str()), Some("claude-sonnet"));
let out = serialize(ConfigFormat::Json, &v).unwrap();
assert!(out.contains("claude-sonnet"));
}
#[test]
fn jsonc_strips_comments() {
let text = "{\n // 注释\n \"model\": \"opencode\" /* 行尾 */\n}";
let v = parse(ConfigFormat::Jsonc, text).unwrap();
assert_eq!(get_path(&v, "model").and_then(|x| x.as_str()), Some("opencode"));
}
#[test]
fn set_path_creates_nested() {
let mut v = json!({});
set_path(&mut v, "env.ANTHROPIC_BASE_URL", json!("https://proxy")).unwrap();
set_path(&mut v, "model", json!("claude")).unwrap();
assert_eq!(
get_path(&v, "env.ANTHROPIC_BASE_URL").and_then(|x| x.as_str()),
Some("https://proxy")
);
assert_eq!(get_path(&v, "model").and_then(|x| x.as_str()), Some("claude"));
}
#[test]
fn unsupported_format_is_explicit() {
assert!(matches!(
parse(ConfigFormat::Crushrc, "foo=bar"),
Err(ConfigError::Unsupported(_))
));
}
#[test]
fn env_roundtrip() {
let v = parse(ConfigFormat::Env, "# c\nGEMINI_API_KEY=\"sk-x\"\n").unwrap();
assert_eq!(get_path(&v, "GEMINI_API_KEY").and_then(|x| x.as_str()), Some("sk-x"));
let out = serialize(ConfigFormat::Env, &v).unwrap();
assert!(out.contains("GEMINI_API_KEY=sk-x"));
}
}
+26
View File
@@ -0,0 +1,26 @@
//! 配置层错误类型(中文,不含密钥明文)
use std::fmt;
#[derive(Debug)]
pub enum ConfigError {
Io(String),
Parse(String),
/// 不支持的配置格式(本波未实现)
Unsupported(String),
/// 字段路径非法
InvalidPath(String),
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ConfigError::Io(m) => write!(f, "IO: {m}"),
ConfigError::Parse(m) => write!(f, "解析失败: {m}"),
ConfigError::Unsupported(m) => write!(f, "不支持: {m}"),
ConfigError::InvalidPath(m) => write!(f, "路径非法: {m}"),
}
}
}
impl std::error::Error for ConfigError {}
+55 -7
View File
@@ -1,18 +1,66 @@
//! agentdock-config —— 配置读写层(架构 §6)
//!
//! 职责:原子写入 + 自动备份、多格式统一编解码(ConfigCodec trait
//! toml / json / jsonc / yaml / env / crushrc)。
//! Wave 0:空骨架,随 Wave 1/2 落地。
//! 职责:多格式编解码(`codec`)、原子写入 + 自动备份(`atomic`)、
//! 配置文件路径解析(`~` 与平台变量展开)。
/// 配置层能力标记(占位)
pub const LAYER: &str = "agentdock-config";
pub mod atomic;
pub mod codec;
pub mod error;
pub use atomic::{atomic_write, backup_path};
pub use codec::{ConfigFormat, get_path, parse, serialize, set_path};
pub use error::ConfigError;
use std::path::PathBuf;
/// 解析适配器声明的配置文件路径:展开 `~`(用户主目录),
/// 并把 Windows 路径分隔符统一处理(YAML 里写的是 `~/.codex/...`)。
pub fn resolve_path(raw: &str) -> PathBuf {
let expanded = expand_home(raw);
PathBuf::from(expanded)
}
/// 展开前导 `~` 为用户主目录(Windows 用 USERPROFILELinux 用 HOME)。
pub fn expand_home(raw: &str) -> String {
if raw == "~" {
return home_dir();
}
if let Some(rest) = raw.strip_prefix("~/") {
return format!("{}{}{}", home_dir(), std::path::MAIN_SEPARATOR, rest);
}
if let Some(rest) = raw.strip_prefix("~\\") {
return format!("{}{}{}", home_dir(), std::path::MAIN_SEPARATOR, rest);
}
raw.to_string()
}
/// 用户主目录(含路径分隔符兜底)。
pub fn home_dir() -> String {
#[cfg(windows)]
{
std::env::var("USERPROFILE").unwrap_or_else(|_| ".".to_string())
}
#[cfg(not(windows))]
{
std::env::var("HOME").unwrap_or_else(|_| ".".to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn layer_identity() {
assert_eq!(LAYER, "agentdock-config");
fn expand_home_prefix() {
let out = expand_home("~/.codex/config.toml");
assert!(!out.starts_with("~/"));
assert!(out.ends_with("config.toml"));
let bare = expand_home("~");
assert!(!bare.starts_with("~/"));
}
#[test]
fn plain_path_unchanged() {
assert_eq!(expand_home("/etc/codex/config.toml"), "/etc/codex/config.toml");
}
}
+10
View File
@@ -3,5 +3,15 @@ name = "agentdock-core"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "编排层:发现/安装/配置/授权/诊断流水线(架构 §2 核心编排)"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
regex = "1"
agentdock-adapter = { path = "../agentdock-adapter" }
agentdock-config = { path = "../agentdock-config" }
agentdock-secrets = { path = "../agentdock-secrets" }
agentdock-exec = { path = "../agentdock-exec" }
agentdock-diag = { path = "../agentdock-diag" }
agentdock-platform = { path = "../agentdock-platform" }
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
//! 编排层错误类型(中文)
use std::fmt;
#[derive(Debug)]
pub enum EngineError {
/// 未找到指定 CLI
NotFound(String),
/// 适配器层错误
Adapter(String),
/// 配置层错误
Config(String),
/// 进程执行错误
Exec(String),
/// 密钥库错误
Secrets(String),
/// 能力本波未实现
NotSupported(String),
}
impl fmt::Display for EngineError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
EngineError::NotFound(m) => write!(f, "未找到: {m}"),
EngineError::Adapter(m) => write!(f, "适配器: {m}"),
EngineError::Config(m) => write!(f, "配置: {m}"),
EngineError::Exec(m) => write!(f, "执行: {m}"),
EngineError::Secrets(m) => write!(f, "密钥库: {m}"),
EngineError::NotSupported(m) => write!(f, "未支持: {m}"),
}
}
}
impl std::error::Error for EngineError {}
impl From<agentdock_config::ConfigError> for EngineError {
fn from(e: agentdock_config::ConfigError) -> Self {
EngineError::Config(e.to_string())
}
}
impl From<agentdock_exec::ExecError> for EngineError {
fn from(e: agentdock_exec::ExecError) -> Self {
EngineError::Exec(e.to_string())
}
}
impl From<std::io::Error> for EngineError {
fn from(e: std::io::Error) -> Self {
EngineError::Exec(e.to_string())
}
}
+13 -13
View File
@@ -1,17 +1,17 @@
//! agentdock-core —— 编排层(架构 §2 模块关系:UI --invoke--> commands --→ core orchestrator
//!
//! 职责:发现 / 安装 / 配置 / 授权 / 诊断 流水线编排。
//! Wave 0:空骨架,流水线自 Wave 1 起逐波落地。
//! 把 adapter/config/secrets/exec/diag/platform 串成完整流水线:
//! detectCli / previewAction / runAction / readConfig / writeConfig /
//! authStatus / authorize / diagnose。
/// 编排层能力标记(占位)
pub const LAYER: &str = "agentdock-core";
pub mod engine;
pub mod error;
pub mod process;
pub mod types;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn layer_identity() {
assert_eq!(LAYER, "agentdock-core");
}
}
pub use engine::{ActionOpts, Engine, auth_status, authorize, detect_one, diagnose, parse_version, read_config, run_action, write_config};
pub use error::EngineError;
pub use types::{
ActionEvent, AuthStatus, ConfigFieldState, ConfigFileState, ConfigFormState, DetectResult,
EnvFieldState, WriteResult, now_secs,
};
+89
View File
@@ -0,0 +1,89 @@
//! 进程探测与流式执行辅助(内部)
//!
//! 所有命令经 `agentdock-exec::validate_argv` 校验后执行,禁止 shell 拼接。
//! 探测类命令(`--version`)隐藏控制台窗口(Windows CREATE_NO_WINDOW)。
use std::io::BufRead;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
/// 按平台分隔符拆分 PATH。
fn path_entries() -> Vec<String> {
std::env::var("PATH")
.unwrap_or_default()
.split(if cfg!(windows) { ';' } else { ':' })
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect()
}
/// 在 PATH 中查找可执行文件(返回全部命中,用于版本冲突检测)。
/// Windows 优先 .exe/.cmd/.bat,最后回退无扩展名。
pub fn which_all(program: &str) -> Vec<PathBuf> {
let direct = Path::new(program);
if direct.is_absolute() && direct.is_file() {
return vec![direct.to_path_buf()];
}
let exts: &[&str] = if cfg!(windows) { &[".exe", ".cmd", ".bat", ""] } else { &[""] };
let mut found = Vec::new();
for dir in path_entries() {
for ext in exts {
let cand = Path::new(&dir).join(format!("{program}{ext}"));
if cand.is_file() && !found.contains(&cand) {
found.push(cand);
}
}
}
found
}
/// 运行只读探测命令并返回 (exit_ok, stdout+stderr 合并文本)。
pub fn run_capture(exe: &Path, args: &[String]) -> std::io::Result<(bool, String)> {
let mut cmd = Command::new(exe);
cmd.args(args);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
}
let out = cmd.output()?;
let mut text = String::from_utf8_lossy(&out.stdout).to_string();
if text.trim().is_empty() {
text = String::from_utf8_lossy(&out.stderr).to_string();
}
Ok((out.status.success(), text))
}
/// 流式执行命令,逐行回调(stdout 与 stderr 均按行输出)。返回是否成功。
/// 顺序读:先 stdout 后 stderr;对长任务足够,且实现简单可靠、无闭包 Send 负担。
pub fn run_streaming<F>(exe: &str, args: &[String], mut on_line: F) -> std::io::Result<bool>
where
F: FnMut(bool, &str),
{
let mut cmd = Command::new(exe);
cmd.args(args);
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x0800_0000);
}
let mut child = cmd.spawn()?;
if let Some(stdout) = child.stdout.take() {
let reader = std::io::BufReader::new(stdout);
for line in reader.lines().map_while(Result::ok) {
on_line(false, &line);
}
}
if let Some(stderr) = child.stderr.take() {
let reader = std::io::BufReader::new(stderr);
for line in reader.lines().map_while(Result::ok) {
on_line(true, &line);
}
}
let status = child.wait()?;
Ok(status.success())
}
+129
View File
@@ -0,0 +1,129 @@
//! IPC 契约数据类型(对齐架构 §2「关键 IPC 契约」)
//!
//! 字段命名与前端 TS 类型一一对应(serde 输出 snake_case)。
//! 所有时间戳统一用 Unix 秒(u64),由前端做相对时间展示,后端不做日期数学。
use serde::{Deserialize, Serialize};
/// 检测结果(detectCli)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DetectResult {
pub cli_id: String,
/// installed | not_installed | not_in_path | permission_denied | exec_failed | version_unparseable
pub status: String,
pub version: Option<String>,
/// 解析到的可执行文件绝对路径
pub executable: Option<String>,
/// 适配器声明「版本检测文档未确认」(UI 显示「实测兜底」)
pub version_unconfirmed: bool,
/// Unix 秒
pub checked_at: u64,
}
/// 授权状态(authStatus)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthStatus {
pub cli_id: String,
/// authorized | unauthorized | unknown | possibly_expired
pub status: String,
/// 结论来源:status_command | keyring | unknown
pub via: String,
pub detail_zh: String,
pub checked_at: u64,
}
/// 单个配置字段的读取状态(readConfig)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigFieldState {
pub id: String,
pub label_zh: String,
pub help_zh: Option<String>,
pub required: bool,
pub sensitive: bool,
/// string | url | enum | bool
pub field_type: String,
/// file | env | keyring
pub storage: String,
/// 敏感字段永不明文回显;值为 None 时结合 has_value 展示「已保存 / 未保存」
pub value: Option<String>,
/// 密钥库类字段:是否已有值
pub has_value: bool,
}
/// 配置文件解析状态。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigFileState {
/// 解析后的绝对路径
pub path: String,
pub format: String,
pub scope: Option<String>,
pub exists: bool,
pub parse_ok: bool,
pub error: Option<String>,
}
/// 环境变量映射(仅供说明与脱敏,本波不注入执行环境)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnvFieldState {
pub key: String,
pub sensitive: bool,
pub maps_to_field: Option<String>,
}
/// 配置表单状态(readConfig 返回)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigFormState {
pub cli_id: String,
pub files: Vec<ConfigFileState>,
pub fields: Vec<ConfigFieldState>,
pub environment: Vec<EnvFieldState>,
}
/// 写配置结果(writeConfig 返回)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WriteResult {
/// 自动备份路径(无备份时为 None)
pub backup_path: Option<String>,
/// 实际写入的配置文件路径
pub written_file: Option<String>,
/// 成功写入的字段 id
pub written_fields: Vec<String>,
/// 逐字段错误(key=field id, value=中文错误)
pub errors: Vec<String>,
}
/// 动作流事件(runAction 流式输出)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActionEvent {
/// step | stdout | stderr | done | error
pub kind: String,
/// 已脱敏的消息文本
pub message: 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 }
}
pub fn stdout(line: impl Into<String>) -> Self {
ActionEvent { kind: "stdout".into(), message: line.into(), data: None }
}
pub fn stderr(line: impl Into<String>) -> Self {
ActionEvent { kind: "stderr".into(), message: line.into(), data: None }
}
pub fn done(data: Option<serde_json::Value>) -> Self {
ActionEvent { kind: "done".into(), message: String::new(), data }
}
pub fn error(msg: impl Into<String>) -> Self {
ActionEvent { kind: "error".into(), message: msg.into(), data: None }
}
}
/// 当前 Unix 秒。
pub fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
+2
View File
@@ -3,5 +3,7 @@ name = "agentdock-diag"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "诊断规则引擎:PATH/依赖/版本冲突/配置损坏 四类检查(架构 §10)"
[dependencies]
serde = { version = "1", features = ["derive"] }
+213 -6
View File
@@ -1,17 +1,224 @@
//! agentdock-diag —— 诊断规则引擎(架构 §10)
//!
//! 职责PATH / 依赖缺失 / 版本冲突 / 配置损坏 四类检查的规则引擎。
//! Wave 0:空骨架,随 Wave 2 落地。
//! 四类必检PATH / 依赖版本 / 版本冲突 / 配置损坏。每个检查器都是纯函数:
//! 输入真实探测结果与声明阈值,输出 `Option<Finding>`。规则本身不依赖适配器
//! 或进程执行(由 core 编排层传入数据),便于单元测试与「构造缺失场景」验证。
/// 诊断层能力标记(占位)
pub const LAYER: &str = "agentdock-diag";
use serde::{Deserialize, Serialize};
/// 诊断级别(对齐视觉规范 §3.5 四级 + 六色语义)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Severity {
Info,
Warn,
Error,
}
impl Severity {
pub fn label_zh(&self) -> &'static str {
match self {
Severity::Info => "提示",
Severity::Warn => "警告",
Severity::Error => "错误",
}
}
}
/// 单条诊断结论(架构 §10.1:级别 + 证据 + 中文解释)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Finding {
/// 规则 id(如 path.not_installed / dependency.node_below_min
pub rule_id: String,
pub severity: Severity,
pub message_zh: String,
/// 原始证据(命令输出 / 路径 / 版本),脱敏后落盘
pub evidence: Option<String>,
}
/// 一份诊断报告(面向单个 CLI)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiagnosticReport {
pub cli_id: String,
pub findings: Vec<Finding>,
}
impl DiagnosticReport {
pub fn new(cli_id: &str) -> Self {
DiagnosticReport { cli_id: cli_id.to_string(), findings: Vec::new() }
}
pub fn push(&mut self, f: Finding) {
self.findings.push(f);
}
}
/// PATH 类:可执行文件状态。
/// `status` 取 detect 结果:installed / not_installed / not_in_path / version_unparseable / exec_failed / permission_denied。
pub fn check_path(cli_name_zh: &str, executable: &str, status: &str) -> Option<Finding> {
match status {
"not_installed" => Some(Finding {
rule_id: "path.not_installed".into(),
severity: Severity::Warn,
message_zh: format!("未检测到 {cli_name_zh} 的可执行文件({executable}"),
evidence: Some(format!("在 PATH 中未找到 {executable}")),
}),
"not_in_path" => Some(Finding {
rule_id: "path.not_in_path".into(),
severity: Severity::Warn,
message_zh: format!("{cli_name_zh} 已安装但不在 PATH 中({executable}"),
evidence: Some(format!("找到 {executable},但所在目录未加入 PATH")),
}),
"version_unparseable" => Some(Finding {
rule_id: "detect.version_unparseable".into(),
severity: Severity::Info,
message_zh: format!("{cli_name_zh} 可执行但版本号解析失败"),
evidence: Some(format!("{executable} 的版本输出无法解析")),
}),
"exec_failed" | "permission_denied" => Some(Finding {
rule_id: "detect.exec_failed".into(),
severity: Severity::Error,
message_zh: format!("{cli_name_zh} 执行探测失败({executable}"),
evidence: Some(format!("执行 {executable} 时失败(status={status}")),
}),
_ => None,
}
}
/// 依赖版本类:实际版本是否满足声明的 semver 范围(如 >=20)。
pub fn check_dependency_version(dep_name: &str, range: &str, actual: Option<&str>) -> Option<Finding> {
let Some(actual) = actual else {
return Some(Finding {
rule_id: format!("dependency.{dep_name}_missing"),
severity: Severity::Error,
message_zh: format!("缺少依赖 {dep_name}(要求 {range}"),
evidence: Some(format!("未检测到 {dep_name}")),
});
};
if version_satisfies(actual, range) {
return None;
}
Some(Finding {
rule_id: format!("dependency.{dep_name}_below_min"),
severity: Severity::Error,
message_zh: format!("{dep_name} 版本 {actual} 不满足要求 {range}"),
evidence: Some(format!("{dep_name} {actual} 需要 {range}")),
})
}
/// 版本冲突类:同名可执行文件在 PATH 中出现多份。
pub fn check_version_conflict(executable: &str, resolved_paths: &[String]) -> Option<Finding> {
if resolved_paths.len() <= 1 {
return None;
}
Some(Finding {
rule_id: "version_conflict.multiple_copies".into(),
severity: Severity::Warn,
message_zh: format!("检测到 {executable} 在 PATH 中存在多份副本"),
evidence: Some(format!("{} 份:{}", resolved_paths.len(), resolved_paths.join(" ; "))),
})
}
/// 配置损坏类:配置文件解析失败。
pub fn check_config_parse(_format: &str, path: &str, parse_ok: bool, err: Option<&str>) -> Option<Finding> {
if parse_ok {
return None;
}
Some(Finding {
rule_id: "config.corrupt".into(),
severity: Severity::Error,
message_zh: format!("配置文件解析失败({path}"),
evidence: Some(err.unwrap_or("未知解析错误").to_string()),
})
}
/// 极简 semver 范围判断:仅支持 `>=X` / `>=X.Y`(本波工具依赖均为这种形态)。
pub fn version_satisfies(version: &str, range: &str) -> bool {
let Some(range) = range.trim().strip_prefix(">=") else {
// 无法识别的范围按「无限制」处理,避免误报
return true;
};
let range = range.trim();
let req: Vec<u64> = range.split('.').filter_map(|s| s.parse::<u64>().ok()).collect();
let got: Vec<u64> = version
.split(|c: char| !c.is_ascii_digit())
.filter(|s| !s.is_empty())
.filter_map(|s| s.parse::<u64>().ok())
.collect();
if req.is_empty() || got.is_empty() {
return true;
}
for i in 0..req.len() {
let g = got.get(i).copied().unwrap_or(0);
if g > req[i] {
return true;
}
if g < req[i] {
return false;
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn layer_identity() {
assert_eq!(LAYER, "agentdock-diag");
fn path_not_installed_rule() {
let f = check_path("Codex CLI", "codex", "not_installed").unwrap();
assert_eq!(f.rule_id, "path.not_installed");
assert_eq!(f.severity, Severity::Warn);
assert!(f.message_zh.contains("codex"));
}
#[test]
fn path_installed_is_clean() {
assert!(check_path("Codex CLI", "codex", "installed").is_none());
}
#[test]
fn dependency_below_min_hits() {
// Node 18 < 20
let f = check_dependency_version("node", ">=20", Some("18.20.0")).unwrap();
assert_eq!(f.rule_id, "dependency.node_below_min");
assert_eq!(f.severity, Severity::Error);
}
#[test]
fn dependency_ok_passes() {
assert!(check_dependency_version("node", ">=20", Some("24.18.0")).is_none());
}
#[test]
fn dependency_missing_hits() {
let f = check_dependency_version("node", ">=20", None).unwrap();
assert!(f.rule_id.contains("missing"));
}
#[test]
fn version_conflict_hits_when_multiple() {
let paths = vec!["C:\\a\\codex.exe".into(), "C:\\b\\codex.exe".into()];
let f = check_version_conflict("codex", &paths).unwrap();
assert_eq!(f.rule_id, "version_conflict.multiple_copies");
assert!(check_version_conflict("codex", &["C:\\a\\codex.exe".into()]).is_none());
}
#[test]
fn config_corrupt_hits() {
let f = check_config_parse("json", "~/.gemini/settings.json", false, Some("expected value")).unwrap();
assert_eq!(f.rule_id, "config.corrupt");
assert_eq!(f.severity, Severity::Error);
assert!(check_config_parse("json", "p", true, None).is_none());
}
#[test]
fn semver_range_logic() {
assert!(version_satisfies("24.18.0", ">=20"));
assert!(version_satisfies("20.0.0", ">=20"));
assert!(version_satisfies("22.1.0", ">=22"));
assert!(!version_satisfies("18.20.0", ">=20"));
assert!(!version_satisfies("20.10.0", ">=22"));
assert!(version_satisfies("v24.18.0", ">=20"));
}
}
+1 -1
View File
@@ -15,4 +15,4 @@ pub use error::SecretStoreError;
pub use keyring::KeyringSecretStore;
pub use mock::MockSecretStore;
pub use redact::{REDACTED, redact, redact_value};
pub use store::SecretStore;
pub use store::{SecretStore, service_name};