5 Commits
Author SHA1 Message Date
leeferandCursor dd5a9378d2 Wave 2.2: 流式安装输出、软件内授权四模式、本机环境一键装与总览缓存
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 17:11:14 +08:00
总工 a2fd5e369b Wave 2.1: 返工黑屏/安装进度/官方配置三层/文档深度/真机列表
老板实测 5 条修复:DiagTab Hooks 黑屏、安装三阶段进度、配置按官方字段三层分组、FR-09 中文文档、detectCliAll 接入列表页。
2026-08-25 11:57:10 +08:00
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
AgentDock 施工员 7208586850 feat(wave-1): 适配器框架与安全基座(schema校验/dry-run/exec沙箱/密钥库/日志脱敏/字段拆分) 2026-08-25 00:38:10 +08:00
85 changed files with 16305 additions and 438 deletions
Generated
+4880
View File
File diff suppressed because it is too large Load Diff
+57
View File
@@ -0,0 +1,57 @@
# AgentDock Wave 2.2 实现说明
基线:工位 git `wave-2.1` tagd2e9de1,即 HEL-137 交付的 `agentdock-wave-2.1-source.zip`)。
本波对应老板复验 Wave 2.1 后提的 4 条「可用性」硬要求,逐条解决如下。
## 1. 安装输出实时滚动 + 失败提示人话化
**根因**:旧 `run_streaming` 先读 stdout、读完后才读 stderr,且主循环只在一行到达时才检查取消。导致 stderr 内容全部延迟到进程结束才一次性上屏(老板所见「最后才显示结果和中间过程」)。
**修复**`crates/agentdock-core/src/process.rs`):
- `run_streaming` / `run_streaming_cancellable` 改为**两条读取线程并发**消费 stdout/stderr,经 mpsc 通道按到达顺序实时回调,主循环用 `recv_timeout(200ms)` 兜底(静默期也能响应取消)。
- 新增 `resolve_exe`PATH 解析到 .exe/.cmd/.bat 实际路径)、`run_with_stdin`API Key 经 stdin 注入官方登录命令)、`run_terminal`CREATE_NEW_CONSOLE 一次性终端窗口)、`open_with_shell`explorer/xdg-open 打开文件或 URL)。
- `RunningProcess` 提供 `request_cancel` / `cancel_flag``kill` 在 Windows 用 `taskkill /T /F` 杀整棵进程树,避免 `.cmd` 包装的 node 子进程变孤儿。
**人话化错误映射**`crates/agentdock-core/src/errors_zh.rs`):
- `program not found` → 「未找到 X 命令:需要先安装 Y(可在本机环境区一键安装)」+ `missing_runtime`npm→node、python→python、git、winget、uv…)。
- 网络超时/连接拒绝/DNS、磁盘不足、权限不足等均映射为中文建议。
- 原始报错保留在 `raw`,事件 `data` 携带结构化 `ErrorHint { code, friendly_zh, raw, missing_runtime }``ActionEvent::error_hint`)。
**前端**`pages/CliDetail.tsx`):失败态默认展示人话版,原始报错折叠可展开;`missing_runtime` 时给内联「去安装 Node.js」按钮,直达本机环境区一键安装(联动第 3 条)。
## 2. 软件内授权(PRD FR-06
**引擎**`crates/agentdock-core/src/engine.rs`):
- 新增 `Engine::authorize_stream` + `cancel_authorize` + 会话注册表(可取消进程句柄)。
- 四类流程:
- `api_key`:有官方命令时(如 codex `login --with-api-key`)从密钥库读 key 经 stdin 注入;无命令(opencode)仅核对密钥库。
- `browser_oauth`:后台运行官方登录命令,流式回传、可取消、结束后刷新授权状态。
- `device_code`:运行官方命令,`parse_device_code` 解析 `user_code` + `verification_url`(ANSI 去色 + 正则),大号验证码上屏。
- `local_tui``run_terminal` 打开一次性本机终端窗口,结束只回传脱敏成败状态。
- 全程不记录令牌明文,日志/状态只存枚举;输出经 `redact` 脱敏。
**前端**`components/AuthPanel.tsx`):配置页「授权 / 登录」第一层从说明文字升级为可操作流程——每个授权方式一个「开始授权」按钮,弹「授权进行中」面板(可取消),设备码大号展示 + 一键复制 + 「打开授权网页」,结束后即时刷新授权状态灯。
## 3. 本机环境一键安装 + 修总览诊断
**运行时来源表**`crates/agentdock-core/src/runtime_install.rs`):
- 内置 node/python/git/winget/uv 官方来源(download_url + download_page + size_approx + elevate_needed + allowed_hosts)。
- `is_url_host_allowed` 白名单校验(安全红线);`download_with_curl` 用系统 curl.exe 下载。
**IPC**`src-tauri/src/commands/runtime.rs`):`previewRuntimeInstall` / `installRuntime`(下载→打开安装向导,进度经 `runtime-install-event` 回传)/ `openRuntimePage`(官网兜底)。
**前端**`components/RuntimeInstallModal.tsx` + `pages/Overview.tsx`):本机环境未安装项变「安装」按钮 → 弹确认框(官方来源/体积/权限)→ 下载并打开安装向导;失败或不可直接安装时兜底「打开官方下载页」。
**修诊断**:新增 `diagnoseAll` IPC(对全部已装工具跑诊断),总览「立即诊断」(诊断卡 + 快速操作两处)都接上,弹全量诊断汇总。
## 4. 总览加载闪烁
- `hooks/useDetectAll.ts` / `useEnv.ts`:检测结果**模块级本地缓存**——切页回来先用缓存立即渲染,后台静默刷新。
- `pages/Overview.tsx`:检测未完成(首次、无缓存)时显示**骨架屏**「正在检测本机 CLI…」,绝不显示「工具箱是空的」;该文案仅在检测完成且确实零安装时出现。
## 涉及文件
- Rust`process.rs``errors_zh.rs`(新)、`runtime_install.rs`(新)、`engine.rs``types.rs``lib.rs`core);`commands/cli.rs``commands/runtime.rs`(新)、`commands/mod.rs``lib.rs`desktop);`secrets/src/keyring.rs`(加真机往返测试)。
- 前端:`ipc/index.ts``ipc/types.ts``components/AuthPanel.tsx`(新)、`components/RuntimeInstallModal.tsx`(新)、`components/ConfigForm.tsx``pages/CliDetail.tsx``pages/Overview.tsx``hooks/useDetectAll.ts``hooks/useEnv.ts``styles/global.css`
未引入任何新依赖(下载用系统 curl.exe、打开用 explorer.exe、任务树终止用 taskkill)。
+56
View File
@@ -0,0 +1,56 @@
# AgentDock Wave 2.2 自测记录
## 构建与测试
- `cargo test`:**97 通过 / 0 失败**(基线 Wave 2.1 为 78,本波 +19;另有 7 个 `--ignored` 真机用例)。
- 前端 `npm run build`(tsc + vite):通过,无类型错误。
- `tauri dev` 实机启动:窗口正常打开,控制台出现 `[agentdock] window-ready`(无崩溃)。
## 4 条要求逐条自证
### 1. 安装输出实时滚动(留证:时间戳)
运行交错输出 stdout/stderr 的命令,记录每行到达时间(`cargo test -p agentdock-core real_machine_streaming_evidence -- --ignored --nocapture`):
```
[ 173ms] stdout out1
[ 177ms] stderr err1
[ 591ms] stdout out2
[ 591ms] stderr err2
[ 995ms] stdout out3
[ 995ms] stderr err3
[ 1402ms] stdout out4
[ 1402ms] stderr err4
```
stderr 在 stdout 结束前**交错实时到达**(旧实现会等 stdout 全部读完才输出 stderr,即结尾回放)。断言 `first_stderr_idx < last_stdout_idx` 通过。另附总览截图 `shot-overview-wave22.png`1920×1080,窗口已打开)。
人话化映射:单元测试覆盖 `program_not_found→Node``permission_denied``network_timeout``disk_full`、兜底 `exec_failed``errors_zh` 6 例全过);`ActionEvent::error_hint` 携带结构化 `ErrorHint`(含 `missing_runtime`)已测。
### 2. 软件内授权(留证:codex 设备码到出码步骤)
真机跑 `codex login --device-auth`,8 秒后自动取消(走到出码即可,不要求真实账号完成最后一步):
```
=== codex 设备码流程(到出码步骤) ===
验证链接: https://auth.openai.com/codex/device
设备码: BGBY-I9EY2
```
断言链接指向官方域名、设备码非空均通过;取消时 `taskkill /T /F` 正确终止整棵进程树(日志可见「成功: 已终止 PID …」)。设备码解析(含 ANSI 去色)另有 2 个单元测试。真机密钥库往返(Windows Credential Manager,写假 key→读回一致→删除清理)通过。
### 3. 本机环境一键安装 + 诊断
- `runtime_install`:来源表白名单校验(`is_url_host_allowed` 精确/子域/伪造域名)6 例单元测试通过;`previewRuntimeInstall` / `openRuntimePage` / `installRuntime`curl 下载→explorer 打开向导→失败兜底官网)已接线。**未做**:真实下载 30MB+ 安装包并走完安装向导(会弹 UAC,工位不宜真装;代码路径已就位,UI 确认/白名单/兜底逻辑已测)。
- 总览「立即诊断」:`diagnoseAll` 对全部已装工具跑四类诊断并弹汇总,已接两处入口。**未做**:真机点按截图(留给总工真机 GUI 复核)。
### 4. 总览加载闪烁
- `useDetectAll` / `useEnv` 模块级缓存:切页先用缓存渲染、后台刷新;首检(无缓存)显示骨架屏,空态文案仅在检测完成且零安装时出现。逻辑已实现并通过前端 build。
## 未测到 / 待真机复核
- 真实下载大安装包并走完安装向导(需 UAC,工位不做)。
- 浏览器 OAuth 全流程到真实登录完成(本机 codex 已登录,为避免覆盖现有会话未真跑完整登录;`browser_oauth` 路径与设备码共用同一套流式/取消/状态刷新机制,且设备码流程已真机走通)。
- Linux 分支(本波依旧只 Windows)。
- 总工真机 GUI 逐页点按复核(本卡截图由全屏截取,AI 读图受限,以真机复核为准)。
+262 -1
View File
@@ -2,7 +2,7 @@
"$schema": "http://json-schema.org/draft-07/schema#", "$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://agentdock.local/schemas/adapter.schema.json", "$id": "https://agentdock.local/schemas/adapter.schema.json",
"title": "AgentDock Adapter", "title": "AgentDock Adapter",
"description": "Agent CLI 适配器定义(v1)。Wave 0含占位字段;完整 schema 见架构 §3.1,随 Wave 1 落地。", "description": "Agent CLI 适配器定义(v1,对齐架构 §3.1)。首批 14 工具在 Wave 1保留 id/name/name_zh/vendor/status 五字段占位;其余字段(platforms/official/runtime_deps/install/detect/update/uninstall/authorization/configuration/diagnostics/documentation)为可选,供 Wave 2 起逐工具填充。所有 command 必须是 argv 数组,禁止 shell 元字符与字符串拼接。",
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["id", "name", "name_zh", "vendor", "status"], "required": ["id", "name", "name_zh", "vendor", "status"],
@@ -28,6 +28,267 @@
"type": "string", "type": "string",
"enum": ["available", "watch"], "enum": ["available", "watch"],
"description": "目录状态:available=可安装列表;watch=观察中(第二批)" "description": "目录状态:available=可安装列表;watch=观察中(第二批)"
},
"adapter_version": {
"type": "string",
"description": "适配器自身版本(semver,如 1.2.0",
"pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\\+[0-9A-Za-z.-]+)?$"
},
"license": {
"type": "string",
"description": "展示用许可;专有许可注明「仅官方渠道安装、不重打包」"
},
"platforms": {
"type": "object",
"additionalProperties": false,
"properties": {
"windows": {
"type": "object",
"additionalProperties": false,
"properties": {
"architectures": {
"type": "array",
"items": { "type": "string", "enum": ["x64", "arm64"] }
},
"notes": {
"type": "string",
"description": "如 Gemini 要求 Win11 24H2+"
}
}
},
"linux": {
"type": "object",
"additionalProperties": false,
"properties": {
"distributions": {
"type": "array",
"items": { "type": "string", "enum": ["ubuntu", "debian"] }
},
"architectures": {
"type": "array",
"items": { "type": "string", "enum": ["x64", "arm64"] }
},
"min_ubuntu": {
"type": "string",
"description": "最低 Ubuntu 版本,如 22.04"
}
}
}
}
},
"official": {
"type": "object",
"additionalProperties": false,
"properties": {
"homepage": { "type": "string", "format": "uri" },
"docs": { "type": "string", "format": "uri" },
"allowed_hosts": {
"type": "array",
"items": { "type": "string" },
"description": "网络白名单(诊断/下载仅可访问这些主机)"
}
}
},
"runtime_deps": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id"],
"properties": {
"id": { "type": "string", "enum": ["node", "python", "git", "powershell", "uv", "bash"] },
"semver_range": { "type": "string", "description": "如 >=20" },
"required_for": {
"type": "array",
"items": { "type": "string", "enum": ["install", "run"] }
}
}
}
},
"install": {
"type": "object",
"additionalProperties": false,
"properties": {
"preferred": { "type": "string", "description": "默认渠道 channel id" },
"channels": {
"type": "array",
"items": { "$ref": "#/definitions/channel" }
}
}
},
"detect": {
"type": "object",
"additionalProperties": false,
"required": ["executable"],
"properties": {
"executable": { "type": "string", "description": "PATH 上的命令名;Cursor 为 agent" },
"version_args": { "type": "array", "items": { "type": "string" } },
"version_regex": { "type": "string" },
"version_unconfirmed": { "type": "boolean", "description": "调研标注「文档未能确认」时 true" },
"path_hints": { "type": "array", "items": { "type": "string" }, "description": "非 PATH 常见位置" }
}
},
"update": {
"type": "object",
"additionalProperties": false,
"properties": {
"method": {
"type": "string",
"enum": ["npm_update", "self_update_cmd", "channel_reinstall", "winget_upgrade", "pypi_upgrade", "manual"]
},
"command": { "type": "array", "items": { "type": "string" } }
}
},
"uninstall": {
"type": "object",
"additionalProperties": false,
"properties": {
"method": { "type": "string", "enum": ["npm_uninstall", "package_manager", "manual_delete"] },
"command": { "type": "array", "items": { "type": "string" } },
"keep_config_default": { "type": "boolean", "default": true }
}
},
"authorization": {
"type": "object",
"additionalProperties": false,
"properties": {
"modes": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["mode"],
"properties": {
"mode": { "type": "string", "enum": ["browser_oauth", "device_code", "api_key", "local_tui"] },
"command": { "type": "array", "items": { "type": "string" } },
"env_keys": { "type": "array", "items": { "type": "string" } },
"status_command": { "type": "array", "items": { "type": "string" } },
"notes_zh": { "type": "string" }
}
}
}
}
},
"configuration": {
"type": "object",
"additionalProperties": false,
"properties": {
"files": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["path", "format"],
"properties": {
"path": { "type": "string", "description": "支持 ~ 与平台变量" },
"format": { "type": "string", "enum": ["toml", "json", "jsonc", "yaml", "env", "crushrc"] },
"scope": { "type": "string", "enum": ["user", "project", "system"] }
}
}
},
"environment": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["key"],
"properties": {
"key": { "type": "string" },
"sensitive": { "type": "boolean", "default": false },
"maps_to_field": { "type": "string" }
}
}
},
"fields": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "label_zh", "type", "storage"],
"properties": {
"id": { "type": "string" },
"label_zh": { "type": "string" },
"help_zh": { "type": "string" },
"required": { "type": "boolean", "default": false },
"sensitive": { "type": "boolean", "default": false },
"type": { "type": "string", "enum": ["string", "url", "enum", "bool"] },
"storage": { "type": "string", "enum": ["file", "env", "keyring"], "description": "keyring 永不进普通备份" },
"platforms": { "type": "array", "items": { "type": "string", "enum": ["windows", "linux"] } },
"docs_url": { "type": "string", "format": "uri" }
}
}
}
}
},
"diagnostics": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["rule_id"],
"properties": {
"rule_id": { "type": "string", "description": "引用规则库或内联" }
}
}
},
"documentation": {
"type": "object",
"additionalProperties": false,
"properties": {
"quickstart_zh": { "type": "string" },
"commands": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"cmd": { "type": "string" },
"desc_zh": { "type": "string" }
}
}
},
"updated_at": { "type": "string", "format": "date" },
"risks_zh": { "type": "array", "items": { "type": "string" } }
}
}
},
"definitions": {
"channel": {
"type": "object",
"additionalProperties": false,
"required": ["id"],
"properties": {
"id": {
"type": "string",
"enum": ["npm", "official_script", "winget", "choco", "scoop", "brew", "apt", "pypi_uv", "github_release"]
},
"platforms": {
"type": "array",
"items": { "type": "string", "enum": ["windows", "linux"] }
},
"command": {
"type": "array",
"items": { "type": "string" },
"description": "命令必须是 argv 数组,禁止字符串拼接与 shell 元字符(| & ; $ \\ > < ( `"
},
"script": {
"type": "object",
"additionalProperties": false,
"properties": {
"url": { "type": "string", "format": "uri" },
"kind": { "type": "string", "enum": ["powershell_irm", "bash_pipe", "ps1_file"] },
"integrity": {
"type": "object",
"additionalProperties": false,
"properties": { "sha256": { "type": "string", "pattern": "^[0-9a-fA-F]{64}$" } }
}
}
},
"package": { "type": "string", "description": "npm/pypi 包名" },
"elevate": { "type": "string", "enum": ["never", "if_needed", "required"] },
"elevate_reason_zh": { "type": "string" },
"post_checks": { "type": "array", "items": { "type": "string" } }
}
} }
} }
} }
+23 -1
View File
@@ -1,4 +1,26 @@
# aider 适配器占位(Wave 0 # ============================================================
# 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 的事)。
# ============================================================
id: aider id: aider
name: Aider name: Aider
name_zh: Aider name_zh: Aider
+156 -1
View File
@@ -1,6 +1,161 @@
# claude-code 适配器占位(Wave 0 # ============================================================
# AgentDock 适配器 · Anthropic Claude CodeWave 2.1
# 数据依据:调研底稿 agent-cli-survey-2026-08-24.md §2 + 架构 §3.3
# ============================================================
id: claude-code id: claude-code
name: Claude Code name: Claude Code
name_zh: Claude Code name_zh: Claude Code
vendor: Anthropic vendor: Anthropic
status: available status: available
adapter_version: 1.1.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
# 配置机制(调研底稿 §2「配置机制」):用户级 ~/.claude/settings.jsonJSON
# 官方确认:settings.json 内 env 块可注入环境变量;ANTHROPIC_API_KEY / ANTHROPIC_BASE_URL /
# ANTHROPIC_AUTH_TOKEN / ANTHROPIC_CUSTOM_HEADERS;默认模型经 /model 命令调整(非 settings.json 字段)。
configuration:
files:
- path: "~/.claude/settings.json"
format: json
scope: user
environment:
- key: ANTHROPIC_API_KEY
sensitive: true
maps_to_field: api_key
- key: ANTHROPIC_AUTH_TOKEN
sensitive: true
maps_to_field: auth_token
fields:
- id: env.ANTHROPIC_BASE_URL
label_zh: Base URL
group: advanced
help_zh: 官方环境变量 ANTHROPIC_BASE_URL,写入 settings.json 的 env 块(自定义 API 端点/网关/代理)
required: false
sensitive: false
type: url
storage: file
docs_url: https://code.claude.com/docs/en/env-vars
- id: api_key
label_zh: API Key
group: auth
help_zh: 存入系统密钥库(对应 ANTHROPIC_API_KEY),设置后优先于订阅登录
required: false
sensitive: true
type: string
storage: keyring
docs_url: https://code.claude.com/docs/en/env-vars
- id: auth_token
label_zh: Bearer 令牌
group: auth
help_zh: 可选,存入系统密钥库(对应 ANTHROPIC_AUTH_TOKENBearer 头);一般用户无需填写
required: false
sensitive: true
type: string
storage: keyring
docs_url: https://code.claude.com/docs/en/env-vars
diagnostics:
- rule_id: path.not_installed
- rule_id: path.not_in_path
- rule_id: version_conflict.multiple_copies
- rule_id: config.corrupt
documentation:
quickstart_zh: 安装后运行 `claude` 登录,或用 `claude -p "提示词"` 无头执行。
install_zh: 推荐 winget`winget install Anthropic.ClaudeCode`);也可走官方 PowerShell 脚本(irm https://claude.ai/install.ps1)或 npm`@anthropic-ai/claude-code`Node 22+)。
auth_zh: ① 浏览器 OAuth:运行 `claude` 按提示登录(Pro/Max/Team/Enterprise/Console 账号;免费版不含 Claude Code);② API Key:设置 ANTHROPIC_API_KEY 后 CLI 优先使用(非交互 -p 模式下有 key 即直接使用)。
commands:
- cmd: claude
desc_zh: 启动交互式会话
- cmd: claude -p "提示词"
desc_zh: 无头单次执行(--print
- cmd: claude -p "提示词" --output-format json
desc_zh: 结构化 JSON 输出
- cmd: claude --allowedTools "Bash(git:*)"
desc_zh: 预授权指定工具
- cmd: claude --continue
desc_zh: 续接最近的会话
- cmd: claude --resume <会话ID>
desc_zh: 恢复指定会话
- cmd: claude doctor
desc_zh: 环境诊断
- cmd: claude --version
desc_zh: 显示版本号
params:
- param: -p / --print
desc_zh: 非交互模式,执行后退出
- param: --output-format
desc_zh: 输出格式(text / json / stream-json
- param: --json-schema
desc_zh: 约束输出为指定 JSON Schema
- param: --allowedTools
desc_zh: 预授权工具列表
- param: --bare
desc_zh: 极简启动模式
updated_at: "2026-08-25"
risks_zh:
- 专有许可:只引导官方渠道安装,不重打包、不修改二进制、不代付费
- 原生 Windows 无沙箱功能,需要沙箱请使用 WSL2(以官方文档为准)
- 默认模型经 /model 命令调整,非 settings.json 字段,故本表单未提供模型字段
+23 -1
View File
@@ -1,4 +1,26 @@
# cline 适配器占位(Wave 0 # ============================================================
# 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 的事)。
# ============================================================
id: cline id: cline
name: Cline name: Cline
name_zh: Cline name_zh: Cline
+23 -1
View File
@@ -1,4 +1,26 @@
# codebuddy 适配器占位(Wave 0 # ============================================================
# 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 的事)。
# ============================================================
id: codebuddy id: codebuddy
name: CodeBuddy Code name: CodeBuddy Code
name_zh: CodeBuddy Code name_zh: CodeBuddy Code
+181 -1
View File
@@ -1,6 +1,186 @@
# codex 适配器占位(Wave 0 # ============================================================
# AgentDock 适配器 · OpenAI Codex CLIWave 2.1
# 数据依据:调研底稿 agent-cli-survey-2026-08-24.md §1 + 架构 §3.3
# ============================================================
id: codex id: codex
name: Codex CLI name: Codex CLI
name_zh: Codex CLI name_zh: Codex CLI
vendor: OpenAI vendor: OpenAI
status: available status: available
adapter_version: 1.1.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
# 配置机制(调研底稿 §1「配置机制」):用户级 ~/.codex/config.tomlTOML
# 官方确认可配:model / approval_policy / sandbox_mode / model_reasoning_effort /
# web_search / log_dir / [model_providers.<id>]base_url / env_key / wire_api/
# 简化键 openai_base_url。表单字段名与官方文档一致。
configuration:
files:
- path: "~/.codex/config.toml"
format: toml
scope: user
environment:
- key: OPENAI_API_KEY
sensitive: true
maps_to_field: api_key
fields:
- id: model
label_zh: 默认模型
group: common
help_zh: 官方键 model,如 gpt-5.6-terra(对应官方 config-basic 文档)
required: false
sensitive: false
type: string
storage: file
docs_url: https://learn.chatgpt.com/docs/config-file/config-basic
- id: approval_policy
label_zh: 审批策略
group: advanced
help_zh: 官方键 approval_policy,控制执行命令前是否需人工确认
required: false
sensitive: false
type: enum
storage: file
docs_url: https://learn.chatgpt.com/docs/config-file/config-basic
options:
- value: untrusted
label_zh: 不信任(全部确认)
- value: on-failure
label_zh: 失败时确认
- value: on-request
label_zh: 每次请求确认
- value: never
label_zh: 永不确认
- id: openai_base_url
label_zh: Base URL
group: advanced
help_zh: 官方简化键 openai_base_url,自定义 OpenAI 兼容端点/网关/代理
required: false
sensitive: false
type: url
storage: file
docs_url: https://learn.chatgpt.com/docs/config-file/config-advanced
- id: api_key
label_zh: API Key
group: auth
help_zh: 存入系统密钥库(对应环境变量 OPENAI_API_KEY),写入后仅显示「已保存」;也可用 codex login --with-api-key 注入
required: false
sensitive: true
type: string
storage: keyring
docs_url: https://learn.chatgpt.com/docs/config-file/config-advanced
diagnostics:
- rule_id: path.not_installed
- rule_id: path.not_in_path
- rule_id: version_conflict.multiple_copies
- rule_id: config.corrupt
documentation:
quickstart_zh: 安装后运行 `codex` 登录 ChatGPT 账号,或用 `codex exec "任务"` 无头执行。
install_zh: 推荐 `npm install -g @openai/codex`(本适配器默认渠道);也可走官方 PowerShell 脚本(irm https://chatgpt.com/codex/install.ps1)或 GitHub Releases 二进制。
auth_zh: 三种授权:① 浏览器登录 ChatGPT 账号(`codex login`,需订阅计划);② 设备码(`codex login --device-auth`);③ API Key`codex login --with-api-key`,从 stdin 读取,或经 OPENAI_API_KEY 环境变量)。
commands:
- cmd: codex
desc_zh: 启动交互式会话
- cmd: codex exec "提示词"
desc_zh: 无头单次执行(别名 codex e
- cmd: codex exec --json "提示词"
desc_zh: 以 JSONL 事件流输出
- cmd: codex exec --output-schema <schema>
desc_zh: 约束输出为指定 JSON Schema
- cmd: codex exec -o 文件
desc_zh: 将输出写入文件
- cmd: codex exec --sandbox
desc_zh: 在沙箱中执行
- cmd: codex login
desc_zh: 浏览器登录 ChatGPT 账号
- cmd: codex login --device-auth
desc_zh: 设备码流程登录
- cmd: codex login --with-api-key
desc_zh: 从 stdin 读取 API Key 登录
- cmd: codex login status
desc_zh: 查询当前登录状态
params:
- param: --json
desc_zh: 以 JSONL 事件流输出(exec 子命令)
- param: --output-schema
desc_zh: 约束输出为指定 JSON Schema
- param: -o / --output
desc_zh: 输出写入文件
- param: --ephemeral
desc_zh: 使用不落盘的临时会话
- param: --sandbox
desc_zh: 在沙箱中执行
- param: --device-auth
desc_zh: 使用设备码流程登录(login 子命令)
updated_at: "2026-08-25"
risks_zh:
- --version 官方文档未确认,适配器已标 version_unconfirmed 并实测兜底
- 表单覆盖常用配置键;model_providers 表等高级自定义 provider 请以官方文档为准(见上方链接)
+23 -1
View File
@@ -1,4 +1,26 @@
# copilot 适配器占位(Wave 0 # ============================================================
# 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 的事)。
# ============================================================
id: copilot id: copilot
name: Copilot CLI name: Copilot CLI
name_zh: Copilot CLI name_zh: Copilot CLI
+23 -1
View File
@@ -1,4 +1,26 @@
# crush 适配器占位(Wave 0 # ============================================================
# 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 的事)。
# ============================================================
id: crush id: crush
name: Crush name: Crush
name_zh: Crush name_zh: Crush
+23 -1
View File
@@ -1,4 +1,26 @@
# cursor 适配器占位(Wave 0 # ============================================================
# 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 的事)。
# ============================================================
id: cursor id: cursor
name: Cursor CLI name: Cursor CLI
name_zh: Cursor CLI name_zh: Cursor CLI
+143 -1
View File
@@ -1,6 +1,148 @@
# gemini 适配器占位(Wave 0 # ============================================================
# AgentDock 适配器 · Google Gemini CLIWave 2.1
# 数据依据:调研底稿 agent-cli-survey-2026-08-24.md §3 + 架构 §3.3
# ============================================================
id: gemini id: gemini
name: Gemini CLI name: Gemini CLI
name_zh: Gemini CLI name_zh: Gemini CLI
vendor: Google vendor: Google
status: available status: available
adapter_version: 1.1.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 申请)
# 配置机制(调研底稿 §3「配置机制」):用户级 ~/.gemini/settings.jsonJSON,官方发布 JSON Schema
# 关键键:model.name(默认模型)、security.auth.selectedType(认证方式);
# 环境变量:GEMINI_API_KEY / GOOGLE_GEMINI_BASE_URL(须 HTTPS/ GEMINI_MODEL。
configuration:
files:
- path: "~/.gemini/settings.json"
format: json
scope: user
environment:
- key: GEMINI_API_KEY
sensitive: true
maps_to_field: api_key
fields:
- id: model.name
label_zh: 默认模型
group: common
help_zh: 官方键 model.namesettings.json 中的默认模型
required: false
sensitive: false
type: string
storage: file
docs_url: https://geminicli.com/docs/reference/configuration/
- id: env.GOOGLE_GEMINI_BASE_URL
label_zh: Base URL
group: advanced
help_zh: 官方环境变量 GOOGLE_GEMINI_BASE_URL(覆盖 Gemini API 默认地址,须 HTTPS,代理/网关场景);写入 settings.json 的 env 块
required: false
sensitive: false
type: url
storage: file
docs_url: https://geminicli.com/docs/reference/configuration/
- id: api_key
label_zh: API Key
group: auth
help_zh: 存入系统密钥库(对应环境变量 GEMINI_API_KEYAI Studio 申请)
required: false
sensitive: true
type: string
storage: keyring
docs_url: https://geminicli.com/docs/get-started/authentication/
diagnostics:
- rule_id: path.not_installed
- 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` 选「Sign in with Google」登录,或用 `gemini -p "提示词"` 无头执行。
install_zh: 推荐 `npm install -g @google/gemini-cli`(需 Node 20+);免安装可用 `npx @google/gemini-cli` 临时试用。
auth_zh: ① Google 账号 OAuth:运行 `gemini` 选「Sign in with Google」浏览器登录(免费档 60 请求/分钟);② API Key:设置 GEMINI_API_KEY 环境变量(AI Studio 申请,免费档 1000 请求/天);③ Vertex AI 走 ADC/服务账号(本表单不覆盖)。
commands:
- cmd: gemini
desc_zh: 启动交互式会话
- cmd: gemini -p "提示词"
desc_zh: 强制非交互模式,单次执行
- cmd: gemini -p "提示词" --output-format json
desc_zh: 以 JSON 输出结果
- cmd: gemini -m <模型>
desc_zh: 指定模型
- cmd: gemini -i
desc_zh: 执行后进入交互模式
- cmd: gemini --version
desc_zh: 显示版本号
- cmd: npx @google/gemini-cli
desc_zh: 免安装临时运行
- cmd: GEMINI_API_KEY=<key> gemini
desc_zh: 用 API Key 环境变量启动
params:
- param: -p / --prompt
desc_zh: 强制非交互模式
- param: --output-format
desc_zh: 输出格式(text / json / stream-json
- param: -m / --model
desc_zh: 指定模型
- param: -i
desc_zh: 执行后转交互
- param: -v / --version
desc_zh: 显示版本号并退出
updated_at: "2026-08-25"
risks_zh:
- 官方发行形态为 npm 包,依赖 Node 20+,适配器已声明运行时门槛
- Windows 官方要求 11 24H2+,低版本 Win10 显示「官方未支持」
- Vertex AIADC/服务账号)与 security.auth.selectedType 高级配置本表单不覆盖,以官方文档为准
+23 -1
View File
@@ -1,4 +1,26 @@
# goose 适配器占位(Wave 0 # ============================================================
# 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 的事)。
# ============================================================
id: goose id: goose
name: Goose name: Goose
name_zh: Goose name_zh: Goose
+165 -1
View File
@@ -1,6 +1,170 @@
# kimi 适配器占位(Wave 0 # ============================================================
# AgentDock 适配器 · Kimi CLI(月之暗面 Moonshot)(Wave 2.1
# 数据依据:调研底稿 agent-cli-survey-2026-08-24.mdKimi 章节)+ 架构 §3.3
# 铁律:只走官方 code.kimi.com 脚本 / PyPI kimi-cli,严禁 npm(仿名包)。
# ============================================================
id: kimi id: kimi
name: Kimi CLI name: Kimi CLI
name_zh: Kimi CLI name_zh: Kimi CLI
vendor: 月之暗面 vendor: 月之暗面
status: available status: available
adapter_version: 1.1.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
# 配置机制(调研底稿 Kimi 章节):~/.kimi/config.tomlTOML,兼容 JSON
# 可配 default_model、providerstype/base_url/api_key)、models 等;
# 环境变量:KIMI_API_KEY / KIMI_BASE_URL / KIMI_MODEL_NAME / KIMI_MODEL_TEMPERATURE。
configuration:
files:
- path: "~/.kimi/config.toml"
format: toml
scope: user
environment:
- key: KIMI_API_KEY
sensitive: true
maps_to_field: api_key
fields:
- id: default_model
label_zh: 默认模型
group: common
help_zh: 官方键 default_modelKimi CLI 使用的默认模型
required: false
sensitive: false
type: string
storage: file
docs_url: https://moonshotai.github.io/kimi-cli/en/configuration/config-files.html
- id: env.KIMI_BASE_URL
label_zh: Base URL
group: advanced
help_zh: 官方环境变量 KIMI_BASE_URL(自定义 OpenAI 兼容端点);写入 config.toml 的 env 表,若需进程级生效请以官方 env-vars 文档为准
required: false
sensitive: false
type: url
storage: file
docs_url: https://moonshotai.github.io/kimi-cli/en/configuration/env-vars.html
- id: api_key
label_zh: API Key
group: auth
help_zh: 存入系统密钥库(对应环境变量 KIMI_API_KEYMoonshot 开放平台获取)
required: false
sensitive: true
type: string
storage: keyring
docs_url: https://moonshotai.github.io/kimi-cli/en/configuration/env-vars.html
diagnostics:
- rule_id: path.not_installed
- 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 "提示词"` 无头执行。
install_zh: 只走官方渠道:`uv tool install kimi-cli`(本适配器默认)或官方脚本 code.kimi.com/install.shinstall.ps1)。脚本会自动装 uv 并从 PyPI kimi-cli 安装。严禁 npmnpm 上的 kimi-code 是第三方仿名包)。
auth_zh: ① Kimi Code 浏览器 OAuth(推荐,`kimi login`);② Moonshot 开放平台 API Keyplatform 侧获取后粘贴,经 KIMI_API_KEY 注入)。`kimi logout` 清除 OAuth 凭据。
commands:
- cmd: kimi
desc_zh: 启动交互式会话
- cmd: kimi login
desc_zh: 登录(浏览器 OAuth 或 API Key
- cmd: kimi logout
desc_zh: 清除 OAuth 凭据
- cmd: kimi -p "提示词"
desc_zh: 无头单次执行
- cmd: kimi --output-format stream-json -p "提示词"
desc_zh: print 模式 JSON 流输出
- cmd: kimi --quiet
desc_zh: 静默模式
- cmd: kimi --version
desc_zh: 显示版本号
- cmd: kimi info
desc_zh: 显示版本与协议信息
- cmd: kimi acp
desc_zh: IDE 集成 stdioACP 协议)
params:
- param: -p / --prompt
desc_zh: 处理后退出(无头模式)
- param: --print
desc_zh: 打印模式
- param: --output-format
desc_zh: 输出格式(如 stream-json
- param: --quiet
desc_zh: 静默模式
- param: --afk
desc_zh: 离键模式
- param: --config-file
desc_zh: 指定配置文件路径
updated_at: "2026-08-25"
risks_zh:
- 严禁 npmnpm 上的 kimi-code 是第三方仿名包,与月之暗面无关
- 官方脚本会自动安装 uv 并从 PyPI kimi-cli 安装(Python 3.123.14
- providers 表(type/base_url/api_key 多 provider)等高级配置本表单不覆盖,以官方 config-files 文档为准
+163 -1
View File
@@ -1,6 +1,168 @@
# opencode 适配器占位(Wave 0 # ============================================================
# AgentDock 适配器 · OpenCodeopencode.ai)(Wave 2.1
# 数据依据:调研底稿 agent-cli-survey-2026-08-24.mdOpenCode 章节)+ 架构 §3.3
# ============================================================
id: opencode id: opencode
name: OpenCode name: OpenCode
name_zh: OpenCode name_zh: OpenCode
vendor: opencode.ai vendor: opencode.ai
status: available status: available
adapter_version: 1.1.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 输码)
# 配置机制(调研底稿 OpenCode 章节):全局 ~/.config/opencode/opencode.jsonJSON,带注释 jsonc 亦可)
# model 字段设默认模型;每个 provider 可单独改 baseURL(含本地 Ollama/LM Studio 等 OpenAI 兼容端点);
# API Key 用环境变量引用接入,也可在 TUI 用 /connect 交互保存(凭据落 ~/.local/share/opencode/auth.json)。
configuration:
files:
- path: "~/.config/opencode/opencode.json"
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: 默认模型
group: common
help_zh: 官方键 model,全局默认模型(如 anthropic/claude-sonnet-4
required: false
sensitive: false
type: string
storage: file
docs_url: https://opencode.ai/docs/config/
- id: provider
label_zh: 默认 Provider
group: common
help_zh: 官方键 provider,指定默认模型提供方(可选;留空则用模型名推断)
required: false
sensitive: false
type: string
storage: file
docs_url: https://opencode.ai/docs/providers/
- id: api_key
label_zh: API Key
group: auth
help_zh: 存入系统密钥库(对应 OPENAI_API_KEY / ANTHROPIC_API_KEY 等环境变量);也可在 TUI 用 /connect 交互保存
required: false
sensitive: true
type: string
storage: keyring
docs_url: https://opencode.ai/docs/providers/
diagnostics:
- rule_id: path.not_installed
- rule_id: path.not_in_path
- rule_id: version_conflict.multiple_copies
- rule_id: config.corrupt
documentation:
quickstart_zh: 安装后运行 `opencode`,或用 `opencode run "提示词"` 无头执行。
install_zh: 多渠道:npm `opencode-ai`(本适配器默认)、Windows choco/scoop、Linux 官方脚本、Homebrew。桌面版另有原生 .exe 安装器。
auth_zh: 授权面最全:① 纯 API Key(各家环境变量);② 浏览器 OAuthClaude Pro/Max、ChatGPT 订阅);③ 设备码(GitHub Copilotgithub.com/login/device 输码);④ 企业级 OAuth。不绑定特定厂商。
commands:
- cmd: opencode
desc_zh: 启动交互式会话
- cmd: opencode run "提示词"
desc_zh: 无头单次执行
- cmd: opencode run "提示词" --model <模型>
desc_zh: 指定模型无头执行
- cmd: opencode auth login
desc_zh: 浏览器 OAuth 登录
- cmd: opencode auth list
desc_zh: 查看已配置凭据
- cmd: opencode upgrade
desc_zh: 自升级
- cmd: opencode serve
desc_zh: 无头 HTTP 服务器模式
- cmd: opencode --version
desc_zh: 显示版本号
params:
- param: run "提示词"
desc_zh: 一次性执行后退出
- param: --model
desc_zh: 指定模型
- param: --format json
desc_zh: JSON 事件流输出
- param: --auto-approve
desc_zh: 自动批准权限请求
- param: -v / --version
desc_zh: 显示版本号
updated_at: "2026-08-25"
risks_zh:
- 官方未文档化安装包校验(SHA256/签名),适配器不强制校验
- 每个 provider 的 baseURL 高级配置(本地 Ollama/LM Studio 等)本表单不覆盖,以官方 providers 文档为准
+23 -1
View File
@@ -1,4 +1,26 @@
# qwen 适配器占位(Wave 0 # ============================================================
# 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 的事)。
# ============================================================
id: qwen id: qwen
name: Qwen Code name: Qwen Code
name_zh: Qwen Code name_zh: Qwen Code
+23 -1
View File
@@ -1,4 +1,26 @@
# warp 适配器占位(Wave 0 # ============================================================
# 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 的事)。
# ============================================================
id: warp id: warp
name: Warp Agent CLI name: Warp Agent CLI
name_zh: Warp Agent CLI name_zh: Warp Agent CLI
+3
View File
@@ -19,3 +19,6 @@ serde_json = "1"
agentdock-platform = { path = "../../../crates/agentdock-platform" } agentdock-platform = { path = "../../../crates/agentdock-platform" }
agentdock-adapter = { path = "../../../crates/agentdock-adapter" } agentdock-adapter = { path = "../../../crates/agentdock-adapter" }
agentdock-store = { path = "../../../crates/agentdock-store" } agentdock-store = { path = "../../../crates/agentdock-store" }
agentdock-core = { path = "../../../crates/agentdock-core" }
agentdock-secrets = { path = "../../../crates/agentdock-secrets" }
agentdock-diag = { path = "../../../crates/agentdock-diag" }
+168
View File
@@ -0,0 +1,168 @@
//! 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, AuthFlowEvent, AuthStatus, ConfigFormState, ConfigVerifyResult, 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())
}
/// 批量检测全部 CLIdetectCliAll),供总览/目录/我的 CLI 接真机状态。
#[tauri::command(rename = "detectCliAll")]
pub fn detect_cli_all(state: tauri::State<'_, Engine>) -> Result<Vec<DetectResult>, String> {
state.detect_all().map_err(|e| e.to_string())
}
/// 干燥运行(previewAction):返回将执行的命令与影响面,不真正执行。
#[tauri::command(rename = "previewAction")]
pub fn preview_action(
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())
}
/// 配置写入后的「生效检查」(verifyConfig):重读配置文件 + CLI 版本探测。
#[tauri::command(rename = "verifyConfig")]
pub fn verify_config(id: String, state: tauri::State<'_, Engine>) -> Result<ConfigVerifyResult, String> {
state.verify_config(&id).map_err(|e| e.to_string())
}
/// 授权状态(authStatus)。
#[tauri::command(rename = "authStatus")]
pub fn auth_status(id: String, state: tauri::State<'_, Engine>) -> Result<AuthStatus, String> {
state.auth_status(&id).map_err(|e| e.to_string())
}
/// 软件内授权(authorize):按官方授权方式运行登录命令,事件经 `cli-auth-event` 流式回传。
#[tauri::command(rename = "authorize")]
pub fn authorize(
app: tauri::AppHandle,
id: String,
mode: String,
state: tauri::State<'_, Engine>,
) -> Result<(), String> {
let engine = state.inner().clone();
std::thread::spawn(move || {
let cli_id = id.clone();
let m = mode.clone();
let result = engine.authorize_stream(&id, &mode, |ev| {
let _ = app.emit("cli-auth-event", &ev);
});
if let Err(e) = result {
let _ = app.emit("cli-auth-event", AuthFlowEvent::error(&cli_id, &m, e.to_string()));
}
});
Ok(())
}
/// 取消正在进行的授权流程。
#[tauri::command(rename = "cancelAuthorize")]
pub fn cancel_authorize(id: String, mode: String, state: tauri::State<'_, Engine>) {
state.cancel_authorize(&id, &mode);
}
/// 诊断(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())
}
/// 批量诊断全部已装工具(总览「立即诊断」入口用),内部实时取本机环境快照。
#[tauri::command(rename = "diagnoseAll")]
pub fn diagnose_all(state: tauri::State<'_, Engine>) -> Result<Vec<DiagnosticReport>, String> {
let env = agentdock_platform::detect::detect_env();
state.diagnose_all(&env).map_err(|e| e.to_string())
}
@@ -1,2 +1,4 @@
pub mod catalog; pub mod catalog;
pub mod cli;
pub mod env; pub mod env;
pub mod runtime;
@@ -0,0 +1,85 @@
//! IPC 命令:本机环境运行时一键安装(Wave 2.2 Req 3
//!
//! 下载仅限官方白名单(`RuntimeSource.allowed_hosts`),下载前经
//! `is_url_host_allowed` 校验;下载用系统 `curl.exe`;下载成功后用
//! `open_with_shell` 打开安装向导(用户在向导里点完即装)。失败兜底
//! 提供「打开官方下载页」。应用本身不提权、不静默安装。
use agentdock_core::{
is_url_host_allowed, open_with_shell, source_for, RuntimeSource,
};
use serde_json::json;
use tauri::Emitter;
/// 预览运行时安装来源(确认弹窗展示官方地址/体积/权限)。
#[tauri::command(rename = "previewRuntimeInstall")]
pub fn preview_runtime_install(runtime: String) -> Result<RuntimeSource, String> {
source_for(&runtime).ok_or_else(|| format!("未知运行时: {runtime}"))
}
/// 兜底:打开运行时官方下载页(默认浏览器)。
#[tauri::command(rename = "openRuntimePage")]
pub fn open_runtime_page(runtime: String) -> Result<(), String> {
let src = source_for(&runtime).ok_or_else(|| format!("未知运行时: {runtime}"))?;
open_with_shell(&src.download_page).map_err(|e| e.to_string())
}
/// 一键安装运行时:下载官方安装包 → 打开安装向导。进度经 `runtime-install-event` 回传。
#[tauri::command(rename = "installRuntime")]
pub fn install_runtime(app: tauri::AppHandle, runtime: String) -> Result<(), String> {
let src = source_for(&runtime).ok_or_else(|| format!("未知运行时: {runtime}"))?;
std::thread::spawn(move || {
let emit = |app: &tauri::AppHandle, payload: serde_json::Value| {
let _ = app.emit("runtime-install-event", payload);
};
// 不可直接下载的运行时(如 uv):直接打开官方下载页
let Some(url) = src.download_url.clone().filter(|_| src.direct_installable) else {
let _ = open_with_shell(&src.download_page);
emit(
&app,
json!({ "runtime": runtime, "phase": "opened_page", "message": format!("已打开{}官方下载页", src.label_zh) }),
);
return;
};
// 安全红线:来源必须落白名单
emit(&app, json!({ "runtime": runtime, "phase": "validate", "message": format!("正在校验来源({})…", src.source_label) }));
if !is_url_host_allowed(&url, &src.allowed_hosts) {
emit(
&app,
json!({ "runtime": runtime, "phase": "error", "message": "下载来源不在官方白名单内,已中止(安全红线)", "fallback": true }),
);
return;
}
// 下载到临时目录
let dir = std::env::temp_dir().join("agentdock-downloads");
let _ = std::fs::create_dir_all(&dir);
let file_name = url.rsplit('/').next().unwrap_or("installer.bin");
let dest = dir.join(file_name);
emit(&app, json!({ "runtime": runtime, "phase": "download", "message": format!("正在下载 {}{},来自 {})…", src.label_zh, src.size_approx, src.source_label) }));
match agentdock_core::download_with_curl(&url, &dest) {
Ok(()) => {
emit(&app, json!({ "runtime": runtime, "phase": "open", "message": "下载完成,正在打开安装向导(请在向导中完成安装)…" }));
if let Err(e) = open_with_shell(&dest.to_string_lossy()) {
emit(
&app,
json!({ "runtime": runtime, "phase": "error", "message": format!("无法打开安装向导:{e}"), "fallback": true }),
);
return;
}
emit(&app, json!({ "runtime": runtime, "phase": "done", "message": "安装向导已打开" }));
}
Err(e) => {
emit(
&app,
json!({ "runtime": runtime, "phase": "error", "message": format!("下载失败:{e}。可点击「打开官方下载页」手动下载。"), "fallback": true }),
);
}
}
});
Ok(())
}
+28 -1
View File
@@ -1,6 +1,12 @@
mod commands; mod commands;
use std::path::PathBuf; 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 /// 优先级:环境变量 AGENTDOCK_ADAPTERS_DIR > CWD 相对路径(tauri dev 时 CWD=apps/desktop
@@ -26,16 +32,37 @@ fn adapters_dir() -> PathBuf {
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.setup(|_app| { .setup(|app| {
// 自测标记:`tauri dev` 启动后日志出现该行即代表壳已就绪 // 自测标记:`tauri dev` 启动后日志出现该行即代表壳已就绪
println!("[agentdock] window-ready"); println!("[agentdock] window-ready");
// Wave 0:初始化 SQLite 空库(架构 §2 状态存储层) // Wave 0:初始化 SQLite 空库(架构 §2 状态存储层)
let _ = agentdock_store::Store::open_default(); 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(()) Ok(())
}) })
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
commands::env::detect_env, commands::env::detect_env,
commands::catalog::list_catalog, commands::catalog::list_catalog,
commands::cli::detect_cli,
commands::cli::detect_cli_all,
commands::cli::get_adapter,
commands::cli::preview_action,
commands::cli::run_action,
commands::cli::read_config,
commands::cli::write_config,
commands::cli::verify_config,
commands::cli::auth_status,
commands::cli::authorize,
commands::cli::cancel_authorize,
commands::cli::diagnose,
commands::cli::diagnose_all,
commands::runtime::preview_runtime_install,
commands::runtime::open_runtime_page,
commands::runtime::install_runtime,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
+36 -10
View File
@@ -7,6 +7,7 @@ import { MyCliPage } from "./pages/MyCli";
import { ConfigCenterPage } from "./pages/ConfigCenter"; import { ConfigCenterPage } from "./pages/ConfigCenter";
import { BackupPage } from "./pages/Backup"; import { BackupPage } from "./pages/Backup";
import { SettingsPage } from "./pages/Settings"; import { SettingsPage } from "./pages/Settings";
import { CliDetailPage } from "./pages/CliDetail";
import { useEnv } from "./hooks/useEnv"; import { useEnv } from "./hooks/useEnv";
import type { PlatformEnv, RuntimeInfo } from "./ipc/types"; import type { PlatformEnv, RuntimeInfo } from "./ipc/types";
@@ -27,35 +28,60 @@ function envWarningCount(env: PlatformEnv | null): number {
export default function App() { export default function App() {
const [page, setPage] = useState<PageKey>("overview"); const [page, setPage] = useState<PageKey>("overview");
const [detailCliId, setDetailCliId] = useState<string | null>(null);
const [pendingRuntime, setPendingRuntime] = useState<string | null>(null);
const { env } = useEnv(); const { env } = useEnv();
const warningCount = envWarningCount(env); const warningCount = envWarningCount(env);
function navigate(next: PageKey) {
setDetailCliId(null);
setPage(next);
}
// 从详情页/其它页直达本机环境区某个运行时的一键安装(联动 Wave 2.2 Req 1/3
function openRuntimeInstall(runtime: string) {
setDetailCliId(null);
setPage("overview");
setPendingRuntime(runtime);
}
const content = useMemo(() => { const content = useMemo(() => {
if (detailCliId) {
return <CliDetailPage id={detailCliId} onBack={() => setDetailCliId(null)} onInstallRuntime={openRuntimeInstall} />;
}
switch (page) { switch (page) {
case "overview": case "overview":
return <OverviewPage />; return (
<OverviewPage
onNavigate={navigate}
onOpenDetail={setDetailCliId}
pendingRuntime={pendingRuntime}
onRuntimeHandled={() => setPendingRuntime(null)}
/>
);
case "catalog": case "catalog":
return <CatalogPage />; return <CatalogPage onOpenDetail={setDetailCliId} />;
case "my-cli": case "my-cli":
return <MyCliPage />; return <MyCliPage onNavigate={navigate} onOpenDetail={setDetailCliId} />;
case "config": case "config":
return <ConfigCenterPage />; return <ConfigCenterPage onNavigate={navigate} />;
case "backup": case "backup":
return <BackupPage />; return <BackupPage />;
case "settings": case "settings":
return <SettingsPage />; return <SettingsPage />;
} }
}, [page]); }, [page, detailCliId, env, pendingRuntime]);
const title = detailCliId ? "CLI 详情" : PAGE_META[page].title;
return ( return (
<div className="app-shell"> <div className="app-shell">
<div className="bg-grid grid-drift" aria-hidden="true" /> <Sidebar current={page} onNavigate={navigate} />
<Sidebar current={page} onNavigate={setPage} />
<div className="app-main"> <div className="app-main">
<Header title={PAGE_META[page].title} warningCount={warningCount} /> <Header title={title} warningCount={warningCount} />
<main className="app-content"> <main className="app-content">
{/* key=page 触发 §4.2 页面切换动效:translateY(8px)+淡入 */} {/* key 触发 §4.2 页面切换动效:translateY(8px)+淡入 */}
<div key={page} className="page-enter"> <div key={detailCliId ?? page} className="page-enter">
{content} {content}
</div> </div>
</main> </main>
+192
View File
@@ -0,0 +1,192 @@
import { useEffect, useState } from "react";
import { Copy, ExternalLink, Loader2, ShieldCheck, X } from "lucide-react";
import { authorize, cancelAuthorize, onCliAuth } from "../ipc";
import type { AuthFlowEvent, AuthModeInfo } from "../ipc/types";
import { Modal } from "./Modal";
/** 官方授权方式中文名 */
function authModeLabel(mode: string): string {
switch (mode) {
case "browser_oauth":
return "账号授权(浏览器)";
case "device_code":
return "设备码";
case "api_key":
return "API Key";
case "local_tui":
return "本机终端授权";
default:
return mode;
}
}
function modeButtonLabel(mode: string): string {
switch (mode) {
case "browser_oauth":
return "开始授权";
case "device_code":
return "开始授权";
case "local_tui":
return "打开终端授权";
case "api_key":
return "用已保存的 Key 授权";
default:
return "去授权";
}
}
interface FlowState {
mode: string;
events: AuthFlowEvent[];
device: { code: string; url: string } | null;
done: boolean | null; // null=进行中
}
/** 软件内授权面板(Wave 2.2 Req 2):把「说明文字」升级为可操作授权流程。
* 覆盖 API Key / 浏览器 / 设备码 / 本机终端四类,事件流经 cli-auth-event 实时回传。 */
export function AuthPanel({
id,
authModes,
onAuthChanged,
}: {
id: string;
authModes: AuthModeInfo[];
onAuthChanged: () => void;
}) {
const [flow, setFlow] = useState<FlowState | null>(null);
useEffect(() => {
let unlisten: (() => void) | undefined;
onCliAuth((ev) => {
if (ev.cli_id !== id) return;
setFlow((f) => {
if (!f) return f;
const next: FlowState = { ...f, events: [...f.events, ev] };
if (ev.kind === "device_code" && ev.user_code && ev.verification_url) {
next.device = { code: ev.user_code, url: ev.verification_url };
}
if (ev.kind === "done") {
next.done = ev.authorized ?? false;
onAuthChanged();
}
if (ev.kind === "error" || ev.kind === "cancelled") {
next.done = false;
}
return next;
});
}).then((fn) => {
unlisten = fn;
});
return () => unlisten?.();
}, [id, onAuthChanged]);
function start(mode: string) {
setFlow({ mode, events: [], device: null, done: null });
void authorize(id, mode);
}
function cancel() {
if (flow) void cancelAuthorize(id, flow.mode);
}
function close() {
setFlow(null);
}
if (authModes.length === 0) return null;
return (
<>
<ul className="auth-modes">
{authModes.map((m) => (
<li key={m.mode} className="auth-mode">
<span className="auth-mode-name">{authModeLabel(m.mode)}</span>
{m.notes_zh && <span className="auth-mode-note">{m.notes_zh}</span>}
<button type="button" className="btn btn-secondary auth-mode-action" onClick={() => start(m.mode)}>
{modeButtonLabel(m.mode)}
</button>
</li>
))}
</ul>
{flow && (
<Modal
title={`${authModeLabel(flow.mode)}授权 · ${id}`}
onClose={flow.done == null ? undefined : close}
footer={
flow.done == null ? (
<button type="button" className="btn btn-secondary" onClick={cancel}>
<X size={14} strokeWidth={1.5} aria-hidden="true" />
</button>
) : (
<button type="button" className="btn btn-primary" onClick={close}>
</button>
)
}
>
<AuthFlowBody flow={flow} />
</Modal>
)}
</>
);
}
function AuthFlowBody({ flow }: { flow: FlowState }) {
const { device, events, done } = flow;
return (
<div className="auth-flow">
{/* 设备码:大号验证码 + 一键复制 + 打开授权网页 */}
{device && (
<div className="auth-device">
<div className="auth-device-label"></div>
<div className="auth-device-code">
<span className="auth-device-code-text">{device.code}</span>
<button
type="button"
className="btn btn-secondary auth-copy"
onClick={() => void navigator.clipboard.writeText(device.code)}
>
<Copy size={14} strokeWidth={1.5} aria-hidden="true" />
</button>
</div>
<a className="btn btn-primary auth-open" href={device.url} target="_blank" rel="noreferrer">
<ExternalLink size={14} strokeWidth={1.5} aria-hidden="true" />
</a>
<div className="auth-device-hint"></div>
</div>
)}
{/* 状态行 */}
<div className="auth-status">
{done == null ? (
<span className="auth-waiting">
<Loader2 size={14} strokeWidth={1.5} className="spin" aria-hidden="true" />
{device ? "等待浏览器确认…(可随时取消)" : "授权进行中,等待浏览器确认…(可随时取消)"}
</span>
) : done ? (
<span className="auth-done">
<ShieldCheck size={14} strokeWidth={1.5} aria-hidden="true" />
</span>
) : (
<span className="auth-failed">
<X size={14} strokeWidth={1.5} aria-hidden="true" />
</span>
)}
</div>
{/* 实时输出(脱敏后) */}
{events.length > 0 && (
<div className="auth-log">
{events
.filter((e) => e.kind === "line" || e.kind === "waiting")
.map((e, i) => (
<div key={i} className={`auth-log-line ${e.kind}`}>
{e.message}
</div>
))}
</div>
)}
</div>
);
}
+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 */ /** CLI 单色字母头像(视觉规范 §7:双字母单色,--ad-bg-2 底 + 1px --ad-border */
export function CliMonogram({ export function CliMonogram({
id,
name, name,
size = 32, size = 32,
}: { }: {
id: string;
name: string; name: string;
size?: number; size?: number;
}) { }) {
const chars = name.replace(/\s+/g, "").slice(0, 2).toUpperCase(); const chars = CLI_MONOGRAMS[id] ?? fallbackAbbr(name);
return ( return (
<span <span
className="monogram" className="monogram"
+304
View File
@@ -0,0 +1,304 @@
import { useEffect, useMemo, useState } from "react";
import { Check, ChevronDown, CircleAlert, ExternalLink, Eye, EyeOff, Lock, ShieldCheck } from "lucide-react";
import { readConfig, verifyConfig, writeConfig } from "../ipc";
import type {
ConfigFieldState,
ConfigFormState,
ConfigVerifyResult,
WriteResult,
} from "../ipc/types";
import { AuthPanel } from "./AuthPanel";
import { MonoChip } from "./MonoChip";
/** 配置表单(视觉规范 §3.4:单列 ≤640px、敏感字段密码框 + 密钥库说明、吸底保存条)
* Wave 2.1:字段按官方配置方法渲染 + 保存后「生效检查」
* Wave 2.2:授权/登录层改为可操作授权流程(AuthPanel) */
export function ConfigForm({ id, onAuthChanged }: { id: string; onAuthChanged?: () => void }) {
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 [verify, setVerify] = useState<ConfigVerifyResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [advancedOpen, setAdvancedOpen] = useState(false);
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;
const authFields = state.fields.filter((f) => f.group === "auth");
const commonFields = state.fields.filter((f) => f.group === "common");
const advancedFields = state.fields.filter((f) => f.group === "advanced");
function renderField(field: ConfigFieldState) {
return (
<Field
key={field.id}
field={field}
value={values[field.id] ?? ""}
saved={sensitiveSaved[field.id] ?? false}
revealed={!!revealed[field.id]}
onToggleReveal={() => setRevealed((r) => ({ ...r, [field.id]: !r[field.id] }))}
onChange={(v) => setValues((x) => ({ ...x, [field.id]: v }))}
/>
);
}
async function onSave() {
setSaving(true);
setSaved(false);
setResult(null);
setVerify(null);
setError(null);
const patch: Record<string, string> = {};
for (const f of state!.fields) {
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);
// 生效检查:写回的配置能否被 CLI 接受(配置文件解析 + CLI 版本探测)
try {
const v = await verifyConfig(id);
setVerify(v);
} catch {
setVerify(null);
}
} catch (e) {
setError(String(e));
} finally {
setSaving(false);
}
}
return (
<div className="config-form">
{!hasFields && <p className="panel-empty"> CLI </p>}
{/* 第一层:授权 / 登录(可操作授权流程 + API Key 类字段) */}
{(state.auth_modes.length > 0 || authFields.length > 0) && (
<section className="config-section">
<h4 className="config-section-title"> / </h4>
{state.auth_modes.length > 0 && (
<AuthPanel id={id} authModes={state.auth_modes} onAuthChanged={onAuthChanged ?? (() => {})} />
)}
{authFields.map(renderField)}
</section>
)}
{/* 第二层:常用配置 */}
{commonFields.length > 0 && (
<section className="config-section">
<h4 className="config-section-title"></h4>
{commonFields.map(renderField)}
</section>
)}
{/* 第三层:高级配置(默认折叠) */}
{advancedFields.length > 0 && (
<section className="config-section">
<button
type="button"
className="config-advanced-toggle"
onClick={() => setAdvancedOpen((v) => !v)}
aria-expanded={advancedOpen}
>
<span className="config-section-title"></span>
<span className="advanced-badge"></span>
<ChevronDown
size={16}
strokeWidth={1.5}
className={advancedOpen ? "advanced-chevron open" : "advanced-chevron"}
aria-hidden="true"
/>
</button>
<p className="advanced-hint">使</p>
{advancedOpen && <div className="advanced-fields">{advancedFields.map(renderField)}</div>}
</section>
)}
{state.files.length > 0 && (
<div className="config-file-info">
<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>
)}
{verify && (
<span className={verify.ok ? "verify-ok" : "verify-fail"}>
{verify.ok ? (
<ShieldCheck size={14} strokeWidth={1.5} aria-hidden="true" />
) : (
<CircleAlert size={14} strokeWidth={1.5} aria-hidden="true" />
)}
{verify.message_zh}
</span>
)}
{result?.backup_path && (
<span className="save-detail">
· {result.backup_path.split(/[\\/]/).pop()}
</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 isEnum = field.field_type === "enum";
const inputType = isPassword ? (revealed ? "text" : "password") : "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>
)}
{isEnum ? (
<select
id={`field-${field.id}`}
className="field-select"
value={value}
onChange={(e) => onChange(e.target.value)}
>
<option value="">使 CLI </option>
{field.options.map((o) => (
<option key={o.value} value={o.value}>
{o.label_zh}{o.value}
</option>
))}
</select>
) : (
<input
id={`field-${field.id}`}
type={inputType}
value={value}
placeholder={isPassword ? (saved ? "已保存 · 留空则不变" : "输入 API Key") : ""}
autoComplete="off"
spellCheck={false}
onChange={(e) => onChange(e.target.value)}
/>
)}
{isPassword && (
<button
type="button"
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
? ` · 已加密保存于系统密钥库${field.env_key ? `${field.env_key}` : ""}`
: ` · 保存后写入系统密钥库,绝不明文落盘${field.env_key ? `${field.env_key}` : ""}`}
</span>
)}
{field.docs_url && (
<a className="field-docs-link" href={field.docs_url} target="_blank" rel="noreferrer">
<ExternalLink size={11} strokeWidth={1.5} aria-hidden="true" />
</a>
)}
</div>
</div>
);
}
@@ -0,0 +1,43 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
interface Props {
children: ReactNode;
}
interface State {
error: Error | null;
}
/** 顶层错误边界:任何渲染崩溃都不再整屏黑屏,而是给出可恢复提示 */
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
// 记录到控制台便于排查,不含敏感信息
console.error("[agentdock] render error:", error, info.componentStack);
}
render() {
if (this.state.error) {
return (
<div className="crash-screen">
<h1 className="crash-title"></h1>
<p className="crash-desc"></p>
<p className="crash-detail">{this.state.error.message}</p>
<button
type="button"
className="btn btn-primary"
onClick={() => this.setState({ error: null })}
>
</button>
</div>
);
}
return this.props.children;
}
}
+18 -17
View File
@@ -10,24 +10,25 @@ interface HeaderProps {
export function Header({ title, warningCount = 0 }: HeaderProps) { export function Header({ title, warningCount = 0 }: HeaderProps) {
return ( return (
<header className="header"> <header className="header">
<h1 className="header-title">{title}</h1> <div className="header-inner">
<div className="header-actions"> <h1 className="header-title">{title}</h1>
<div className="search-box"> <div className="header-actions">
<Search size={16} strokeWidth={1.5} className="search-icon" aria-hidden="true" /> <div className="search-box">
<input <Search size={16} strokeWidth={1.5} className="search-icon" aria-hidden="true" />
type="search" <input
placeholder="搜索 CLI…" type="search"
aria-label="搜索 CLI" placeholder="搜索 CLI"
// 搜索功能属于后续波次,Wave 0 仅为占位 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> </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> </div>
</header> </header>
); );
+16 -4
View File
@@ -11,17 +11,19 @@ interface KpiCardProps {
label: string; label: string;
value: number; value: number;
tone: KpiTone; tone: KpiTone;
/** 副标题:对象点名句式(视觉规范 §3.1.1) */ /** 副标题:对象点名句式(视觉规范 §3.1.1)0 值用「确认句 + 行动」短句 */
subtitle: string; subtitle: string;
/** 0 值行动后缀(如「去目录看看」),仅当存在对应页面动作时出现 */
action?: { label: string; onClick: () => void };
} }
/** /**
* KPI 卡(视觉规范 §3.1.1): * KPI 卡(视觉规范 §3.1.1):
* - 径向渐变玻璃板 + 上缘高光 + --ad-shadow-panel * - 径向渐变玻璃板 + 上缘高光 + --ad-shadow-panel
* - 顶部色条贯通整宽;数值为 0 时色条降灰、数字回 --ad-text-1 * - 顶部色条贯通整宽;数值为 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 color = value > 0 ? TONE_COLOR[tone] : "var(--ad-border)";
const valueColor = value > 0 ? TONE_COLOR[tone] : "var(--ad-text-1)"; const valueColor = value > 0 ? TONE_COLOR[tone] : "var(--ad-text-1)";
return ( return (
@@ -32,7 +34,17 @@ export function KpiCard({ label, value, tone, subtitle }: KpiCardProps) {
{value} {value}
</div> </div>
<div className="kpi-label">{label}</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>
</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>
);
}
@@ -0,0 +1,166 @@
import { useEffect, useState } from "react";
import { Check, Download, ExternalLink, Loader2, ShieldAlert } from "lucide-react";
import { installRuntime, onRuntimeInstall, openRuntimePage, previewRuntimeInstall } from "../ipc";
import type { RuntimeInstallEvent, RuntimeSource } from "../ipc/types";
import { Modal } from "./Modal";
const RUNTIME_LABELS: Record<string, string> = {
node: "Node.js",
python: "Python",
git: "Git",
winget: "winget",
uv: "uv",
};
/** 本机环境运行时一键安装弹窗(Wave 2.2 Req 3):
* 展示官方来源/体积/权限 → 确认后下载官方安装包并打开安装向导 → 失败兜底打开官网下载页。 */
export function RuntimeInstallModal({ runtime, onClose }: { runtime: string; onClose: () => void }) {
const [source, setSource] = useState<RuntimeSource | null>(null);
const [phase, setPhase] = useState<"preview" | "running" | "done" | "error" | "opened">("preview");
const [message, setMessage] = useState("");
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
previewRuntimeInstall(runtime)
.then((s) => {
if (!cancelled) setSource(s);
})
.catch((e) => {
if (!cancelled) setError(String(e));
});
return () => {
cancelled = true;
};
}, [runtime]);
useEffect(() => {
let unlisten: (() => void) | undefined;
onRuntimeInstall((ev: RuntimeInstallEvent) => {
if (ev.runtime !== runtime) return;
setMessage(ev.message);
if (ev.phase === "done") setPhase("done");
else if (ev.phase === "opened_page") setPhase("opened");
else if (ev.phase === "error") {
setPhase("error");
setError(ev.message);
} else {
setPhase("running");
}
}).then((fn) => {
unlisten = fn;
});
return () => unlisten?.();
}, [runtime]);
function start() {
setPhase("running");
setMessage("");
setError(null);
void installRuntime(runtime);
}
function openPage() {
void openRuntimePage(runtime);
setPhase("opened");
}
const label = RUNTIME_LABELS[runtime] ?? runtime;
return (
<Modal title={`安装 ${label}`} onClose={onClose} footer={<Footer phase={phase} source={source} onStart={start} onOpenPage={openPage} onClose={onClose} />}>
{error && phase === "preview" && !source ? (
<p className="panel-empty">{error}</p>
) : !source ? (
<p className="panel-empty"></p>
) : (
<div className="runtime-install">
<div className="runtime-source">
<div className="confirm-label"></div>
<p className="confirm-text">{source.source_label}</p>
{source.download_url && (
<p className="runtime-url">
<span className="confirm-label"> </span>
<span className="runtime-url-text">{source.download_url}</span>
</p>
)}
</div>
<div className="runtime-meta">
<div>
<span className="confirm-label"> </span>
<span className="confirm-text">{source.size_approx}</span>
</div>
<div>
<span className="confirm-label"> </span>
<span className="confirm-text">{source.elevate_needed ? "需要管理员权限(安装向导会弹 UAC 确认)" : "无需管理员权限"}</span>
</div>
</div>
{(phase === "running" || phase === "done") && (
<div className="runtime-progress">
{phase === "running" && (
<span className="auth-waiting">
<Loader2 size={14} strokeWidth={1.5} className="spin" aria-hidden="true" />
{message || "处理中…"}
</span>
)}
{phase === "done" && (
<span className="auth-done">
<Check size={14} strokeWidth={1.5} aria-hidden="true" /> {message || "安装向导已打开"}
</span>
)}
</div>
)}
{phase === "error" && error && (
<div className="runtime-error">
<ShieldAlert size={14} strokeWidth={1.5} aria-hidden="true" />
<span>{error}</span>
</div>
)}
<p className="runtime-note">
</p>
</div>
)}
</Modal>
);
}
function Footer({
phase,
source,
onStart,
onOpenPage,
onClose,
}: {
phase: "preview" | "running" | "done" | "error" | "opened";
source: RuntimeSource | null;
onStart: () => void;
onOpenPage: () => void;
onClose: () => void;
}) {
const running = phase === "running";
const finished = phase === "done" || phase === "opened";
const failed = phase === "error";
const canDirect = source?.direct_installable && source?.download_url;
return (
<>
{(failed || !canDirect) && (
<button type="button" className="btn btn-secondary" onClick={onOpenPage}>
<ExternalLink size={14} strokeWidth={1.5} aria-hidden="true" />
</button>
)}
{finished ? (
<button type="button" className="btn btn-primary" onClick={onClose}>
</button>
) : (
<button type="button" className="btn btn-primary" onClick={onStart} disabled={running || !canDirect}>
<Download size={14} strokeWidth={1.5} aria-hidden="true" /> {running ? "下载中…" : "下载并打开安装向导"}
</button>
)}
</>
);
}
+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 };
}
+42
View File
@@ -0,0 +1,42 @@
import { useCallback, useEffect, useState } from "react";
import { detectCliAll } from "../ipc";
import type { DetectResult } from "../ipc/types";
export interface UseDetectAllResult {
detectMap: Record<string, DetectResult>;
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
}
// 检测结果本地缓存(Wave 2.2 Req 4):切换页面回来先用缓存立即渲染,
// 后台静默刷新有变化再更新,不再每次重检重闪。
let cachedMap: Record<string, DetectResult> | null = null;
/** 批量检测全部 CLI(总览/目录/我的 CLI 接真机状态) */
export function useDetectAll(): UseDetectAllResult {
const [detectMap, setDetectMap] = useState<Record<string, DetectResult>>(cachedMap ?? {});
const [loading, setLoading] = useState(cachedMap == null);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
try {
const list = await detectCliAll();
const map: Record<string, DetectResult> = {};
for (const d of list) map[d.cli_id] = d;
cachedMap = map;
setDetectMap(map);
setError(null);
} catch (err) {
setError(String(err));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
return { detectMap, loading, error, refresh };
}
+7 -3
View File
@@ -8,10 +8,13 @@ export interface UseEnvResult {
error: string | null; error: string | null;
} }
/** 加载本机环境检测结果(一次性的只读快照) */ // 本机环境本地缓存(Wave 2.2 Req 4):切换页面先用缓存立即渲染,后台静默刷新。
let cachedEnv: PlatformEnv | null = null;
/** 加载本机环境检测结果(缓存优先 + 后台刷新) */
export function useEnv(): UseEnvResult { export function useEnv(): UseEnvResult {
const [env, setEnv] = useState<PlatformEnv | null>(null); const [env, setEnv] = useState<PlatformEnv | null>(cachedEnv);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(cachedEnv == null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
@@ -19,6 +22,7 @@ export function useEnv(): UseEnvResult {
detectEnv() detectEnv()
.then((e) => { .then((e) => {
if (!cancelled) { if (!cancelled) {
cachedEnv = e;
setEnv(e); setEnv(e);
setLoading(false); setLoading(false);
} }
+269 -7
View File
@@ -1,6 +1,23 @@
// 类型化 invoke 封装(唯一前端 → Rust 入口,架构 §2「界面层禁直接拼命令」) // 类型化 invoke 封装(唯一前端 → Rust 入口,架构 §2「界面层禁直接拼命令」)
import { invoke } from "@tauri-apps/api/core"; 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,
AuthFlowEvent,
AuthStatus,
CatalogEntry,
CliAction,
CliActionEvent,
ConfigFormState,
ConfigVerifyResult,
DetectResult,
DiagnosticReport,
DryRunPlan,
PlatformEnv,
RuntimeInstallEvent,
RuntimeSource,
WriteResult,
} from "./types";
/** 是否运行在 Tauri 环境(否则走 mock,便于纯浏览器联调 UI) */ /** 是否运行在 Tauri 环境(否则走 mock,便于纯浏览器联调 UI) */
function isTauri(): boolean { function isTauri(): boolean {
@@ -8,20 +25,139 @@ function isTauri(): boolean {
} }
export async function detectEnv(): Promise<PlatformEnv> { export async function detectEnv(): Promise<PlatformEnv> {
if (!isTauri()) { if (!isTauri()) return mockPlatformEnv();
return mockPlatformEnv();
}
return invoke<PlatformEnv>("detectEnv"); return invoke<PlatformEnv>("detectEnv");
} }
export async function listCatalog(): Promise<CatalogEntry[]> { export async function listCatalog(): Promise<CatalogEntry[]> {
if (!isTauri()) { if (!isTauri()) return mockCatalog();
return mockCatalog();
}
return invoke<CatalogEntry[]>("listCatalog"); return invoke<CatalogEntry[]>("listCatalog");
} }
// ---- Wave 2CLI 全链路 ----
export async function getAdapter(id: string): Promise<Adapter> {
if (!isTauri()) return mockAdapter(id);
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 detectCliAll(): Promise<DetectResult[]> {
if (!isTauri()) return mockCatalog().map((c) => mockDetect(c.id));
return invoke<DetectResult[]>("detectCliAll");
}
export async function previewAction(
id: string,
action: CliAction,
channel?: string,
): Promise<DryRunPlan> {
if (!isTauri()) return mockDryRun(id, action);
return invoke<DryRunPlan>("previewAction", { id, action, channel });
}
export async function runAction(id: string, action: CliAction, channel?: string): Promise<void> {
if (!isTauri()) return;
return invoke<void>("runAction", { id, action, channel });
}
export async function readConfig(id: string): Promise<ConfigFormState> {
if (!isTauri()) return mockConfig(id);
return invoke<ConfigFormState>("readConfig", { id });
}
export async function writeConfig(
id: string,
patch: Record<string, string>,
): Promise<WriteResult> {
if (!isTauri()) return { backup_path: null, written_file: "~/.codex/config.toml", written_fields: Object.keys(patch), errors: [] };
return invoke<WriteResult>("writeConfig", { id, patch });
}
export async function verifyConfig(id: string): Promise<ConfigVerifyResult> {
if (!isTauri()) {
return {
cli_id: id,
ok: true,
level: "config_parsed",
message_zh: "配置文件已写入且可解析;CLI 未安装,未做 CLI 实际校验(以官方文档为准)。",
detail: null,
};
}
return invoke<ConfigVerifyResult>("verifyConfig", { id });
}
export async function authStatus(id: string): Promise<AuthStatus> {
if (!isTauri()) return mockAuth(id);
return invoke<AuthStatus>("authStatus", { id });
}
export async function diagnose(id: string): Promise<DiagnosticReport> {
if (!isTauri()) return { cli_id: id, findings: [] };
return invoke<DiagnosticReport>("diagnose", { id });
}
export async function diagnoseAll(): Promise<DiagnosticReport[]> {
if (!isTauri()) return [];
return invoke<DiagnosticReport[]>("diagnoseAll");
}
/** 软件内授权:启动指定模式的授权流程(事件经 cli-auth-event 流式回传)。 */
export async function authorize(id: string, mode: string): Promise<void> {
if (!isTauri()) return;
return invoke<void>("authorize", { id, mode });
}
export async function cancelAuthorize(id: string, mode: string): Promise<void> {
if (!isTauri()) return;
return invoke<void>("cancelAuthorize", { id, mode });
}
/** 订阅授权流程事件(cli-auth-event)。 */
export async function onCliAuth(
handler: (payload: AuthFlowEvent) => void,
): Promise<UnlistenFn> {
if (!isTauri()) return () => {};
return listen<AuthFlowEvent>("cli-auth-event", (e) => handler(e.payload));
}
/** 本机环境运行时安装(预览/一键安装/官网兜底)。 */
export async function previewRuntimeInstall(runtime: string): Promise<RuntimeSource> {
if (!isTauri()) return mockRuntimeSource(runtime);
return invoke<RuntimeSource>("previewRuntimeInstall", { runtime });
}
export async function installRuntime(runtime: string): Promise<void> {
if (!isTauri()) return;
return invoke<void>("installRuntime", { runtime });
}
export async function openRuntimePage(runtime: string): Promise<void> {
if (!isTauri()) return;
return invoke<void>("openRuntimePage", { runtime });
}
export async function onRuntimeInstall(
handler: (payload: RuntimeInstallEvent) => void,
): Promise<UnlistenFn> {
if (!isTauri()) return () => {};
return listen<RuntimeInstallEvent>("runtime-install-event", (e) => handler(e.payload));
}
/** 订阅 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 内时生效,不影响真实数据) ---- // ---- 浏览器联调用 mock(仅当不在 Tauri 内时生效,不影响真实数据) ----
const mockCatalog = (): CatalogEntry[] => [ const mockCatalog = (): CatalogEntry[] => [
{ id: "codex", name: "Codex CLI", name_zh: "Codex CLI", vendor: "OpenAI", status: "available" }, { 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" }, { id: "claude-code", name: "Claude Code", name_zh: "Claude Code", vendor: "Anthropic", status: "available" },
@@ -39,10 +175,29 @@ const mockCatalog = (): CatalogEntry[] => [
{ id: "warp", name: "Warp Agent CLI", name_zh: "Warp Agent CLI", vendor: "Warp", status: "available" }, { 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 => ({ const mockPlatformEnv = (): PlatformEnv => ({
os: "windows", os: "windows",
os_version: "Windows 11 24H2 (Build 26100)", os_version: "Windows 11 24H2 (Build 26100)",
arch: "x86_64", arch: "x86_64",
distro: null,
distro_version: null,
shells: { powershell_version: "5.1.26100.1", pwsh_version: null, bash_available: true }, shells: { powershell_version: "5.1.26100.1", pwsh_version: null, bash_available: true },
runtimes: { runtimes: {
node: { status: "installed", version: "24.18.0", path: "C:\\Program Files\\nodejs\\node.exe" }, node: { status: "installed", version: "24.18.0", path: "C:\\Program Files\\nodejs\\node.exe" },
@@ -56,3 +211,110 @@ const mockPlatformEnv = (): PlatformEnv => ({
path_entries: ["C:\\Windows\\system32", "C:\\Windows", "C:\\Program Files\\nodejs"], path_entries: ["C:\\Windows\\system32", "C:\\Windows", "C:\\Program Files\\nodejs"],
capabilities: { keyring: "ok", can_elevate: false }, capabilities: { keyring: "ok", can_elevate: false },
}); });
const mockAdapter = (id: string): Adapter => ({
id,
name: "Codex CLI",
name_zh: "Codex CLI",
vendor: "OpenAI",
status: "available",
adapter_version: "1.1.0",
license: "Apache-2.0",
platforms: {
windows: { architectures: ["x64"], notes: "原生支持(PowerShell 脚本安装)" },
linux: { distributions: ["ubuntu"], architectures: ["x64"] },
},
official: {
homepage: "https://github.com/openai/codex",
docs: "https://learn.chatgpt.com/docs/codex/cli",
allowed_hosts: ["chatgpt.com"],
},
runtime_deps: [],
install: {
preferred: "npm",
channels: [
{ id: "npm", platforms: ["windows", "linux"], command: ["npm", "install", "-g", "@openai/codex"], package: "@openai/codex", elevate: "never", post_checks: ["detect"] },
],
},
detect: { executable: "codex", version_args: ["--version"], version_regex: "^codex-cli (\\d+\\.\\d+\\.\\d+)", version_unconfirmed: true },
update: { method: "npm_update", command: ["npm", "update", "-g", "@openai/codex"] },
uninstall: { method: "npm_uninstall", command: ["npm", "uninstall", "-g", "@openai/codex"], keep_config_default: true },
authorization: {
modes: [
{ mode: "browser_oauth", command: ["codex", "login"], status_command: ["codex", "login", "status"], notes_zh: "浏览器登录 ChatGPT 账号" },
{ mode: "device_code", command: ["codex", "login", "--device-auth"], notes_zh: "设备码登录" },
{ mode: "api_key", command: ["codex", "login", "--with-api-key"], env_keys: ["OPENAI_API_KEY"], notes_zh: "API Key 经 stdin 注入" },
],
},
configuration: {
files: [{ path: "~/.codex/config.toml", format: "toml", scope: "user" }],
environment: [{ key: "OPENAI_API_KEY", sensitive: true, maps_to_field: "api_key" }],
fields: [
{ id: "model", label_zh: "默认模型", help_zh: "官方键 model,如 gpt-5.6-terra", type: "string", storage: "file", docs_url: "https://learn.chatgpt.com/docs/config-file/config-basic" },
{ id: "approval_policy", label_zh: "审批策略", help_zh: "官方键 approval_policy", type: "enum", storage: "file", docs_url: "https://learn.chatgpt.com/docs/config-file/config-basic", options: [{ value: "untrusted", label_zh: "不信任(全部确认)" }, { value: "on-failure", label_zh: "失败时确认" }, { value: "never", label_zh: "永不确认" }] },
{ id: "openai_base_url", label_zh: "Base URL", help_zh: "官方简化键 openai_base_url", type: "url", storage: "file", docs_url: "https://learn.chatgpt.com/docs/config-file/config-advanced" },
{ id: "api_key", label_zh: "API Key", help_zh: "存入系统密钥库(对应 OPENAI_API_KEY", sensitive: true, type: "string", storage: "keyring", docs_url: "https://learn.chatgpt.com/docs/config-file/config-advanced" },
],
},
documentation: {
quickstart_zh: "安装后运行 `codex` 登录,或用 `codex exec \"任务\"` 无头执行。",
install_zh: "推荐 `npm install -g @openai/codex`;也可走官方 PowerShell 脚本或 GitHub Releases 二进制。",
auth_zh: "三种授权:浏览器登录、设备码、API Key。",
commands: [
{ cmd: "codex", desc_zh: "启动交互式会话" },
{ cmd: "codex exec \"提示词\"", desc_zh: "无头单次执行" },
{ cmd: "codex exec --json \"提示词\"", desc_zh: "JSONL 事件流输出" },
{ cmd: "codex exec --output-schema <schema>", desc_zh: "约束输出 JSON Schema" },
{ cmd: "codex exec -o 文件", desc_zh: "输出写入文件" },
{ cmd: "codex exec --sandbox", desc_zh: "沙箱执行" },
{ cmd: "codex login", desc_zh: "浏览器登录" },
{ cmd: "codex login status", desc_zh: "查询登录状态" },
],
params: [
{ param: "--json", desc_zh: "JSONL 事件流输出" },
{ param: "--output-schema", desc_zh: "约束输出 JSON Schema" },
{ param: "-o / --output", desc_zh: "输出写入文件" },
{ param: "--ephemeral", desc_zh: "临时会话" },
{ param: "--sandbox", desc_zh: "沙箱执行" },
],
updated_at: "2026-08-25",
risks_zh: ["--version 官方文档未确认,适配器已标 version_unconfirmed 并实测兜底"],
},
});
const mockConfig = (id: string): ConfigFormState => ({
cli_id: id,
files: [{ path: "~/.codex/config.toml", format: "toml", scope: "user", exists: false, parse_ok: true, error: null }],
fields: [
{ id: "model", label_zh: "默认模型", help_zh: "官方键 model,如 gpt-5.6-terra", required: false, sensitive: false, field_type: "string", storage: "file", value: null, has_value: false, docs_url: "https://learn.chatgpt.com/docs/config-file/config-basic", options: [], env_key: null, group: "common" },
{ id: "approval_policy", label_zh: "审批策略", help_zh: "官方键 approval_policy", required: false, sensitive: false, field_type: "enum", storage: "file", value: null, has_value: false, docs_url: "https://learn.chatgpt.com/docs/config-file/config-basic", options: [{ value: "untrusted", label_zh: "不信任(全部确认)" }, { value: "on-failure", label_zh: "失败时确认" }, { value: "never", label_zh: "永不确认" }], env_key: null, group: "advanced" },
{ id: "openai_base_url", label_zh: "Base URL", help_zh: "官方简化键 openai_base_url", required: false, sensitive: false, field_type: "url", storage: "file", value: null, has_value: false, docs_url: "https://learn.chatgpt.com/docs/config-file/config-advanced", options: [], env_key: null, group: "advanced" },
{ id: "api_key", label_zh: "API Key", help_zh: "存入系统密钥库(对应 OPENAI_API_KEY", required: false, sensitive: true, field_type: "string", storage: "keyring", value: null, has_value: false, docs_url: "https://learn.chatgpt.com/docs/config-file/config-advanced", options: [], env_key: "OPENAI_API_KEY", group: "auth" },
],
environment: [{ key: "OPENAI_API_KEY", sensitive: true, maps_to_field: "api_key" }],
auth_modes: [
{ mode: "browser_oauth", notes_zh: "浏览器登录 ChatGPT 账号(需订阅计划)", command: ["codex", "login"] },
{ mode: "device_code", notes_zh: "设备码登录,不弹浏览器", command: ["codex", "login", "--device-auth"] },
{ mode: "api_key", notes_zh: "API Key 从系统密钥库读取,经 stdin 注入", command: [] },
],
});
const mockDryRun = (_id: string, action: CliAction): DryRunPlan => ({
commands: action === "install" ? [["npm", "install", "-g", "@openai/codex"]] : [["npm", "uninstall", "-g", "@openai/codex"]],
elevate: false,
elevate_reason_zh: null,
affected_files: ["~/.codex/config.toml"],
rollback_zh: action === "install" ? "可执行卸载命令回退;配置文件默认保留。" : "卸载默认保留配置,可重新安装恢复。",
});
const mockRuntimeSource = (runtime: string): RuntimeSource => ({
runtime,
label_zh: runtime === "node" ? "Node.js" : runtime === "python" ? "Python" : "Git",
download_url: "https://nodejs.org/dist/v22.14.0/node-v22.14.0-x64.msi",
download_page: "https://nodejs.org/en/download",
size_approx: "约 31 MB",
elevate_needed: true,
allowed_hosts: ["nodejs.org"],
direct_installable: true,
source_label: "官方来源",
});
+256 -1
View File
@@ -1,10 +1,12 @@
// IPC 契约类型(对齐架构 §5 PlatformEnv / listCatalog // IPC 契约类型(对齐架构 §2「关键 IPC 契约」与 core/types.rs
// 字段命名与 Rust 侧 serde 输出一致(snake_case)。 // 字段命名与 Rust 侧 serde 输出一致(snake_case)。
export interface PlatformEnv { export interface PlatformEnv {
os: string; os: string;
os_version: string; os_version: string;
arch: string; arch: string;
distro: string | null;
distro_version: string | null;
shells: Shells; shells: Shells;
runtimes: Runtimes; runtimes: Runtimes;
path_entries: string[]; path_entries: string[];
@@ -31,6 +33,7 @@ export type RuntimeStatus =
| "installed" | "installed"
| "not_installed" | "not_installed"
| "not_in_path" | "not_in_path"
| "permission_denied"
| "exec_failed" | "exec_failed"
| "version_unparseable"; | "version_unparseable";
@@ -52,3 +55,255 @@ export interface CatalogEntry {
vendor: string; vendor: string;
status: 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;
docs_url: string | null;
options: ConfigFieldOption[];
env_key: string | null;
group: "auth" | "common" | "advanced" | string;
}
export interface ConfigFieldOption {
value: string;
label_zh: string;
}
export interface ConfigFileState {
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[];
auth_modes: AuthModeInfo[];
}
export interface AuthModeInfo {
mode: string;
notes_zh: string | null;
command: string[];
}
export interface WriteResult {
backup_path: string | null;
written_file: string | null;
written_fields: string[];
errors: string[];
}
export interface ConfigVerifyResult {
cli_id: string;
ok: boolean;
level: "cli_accepted" | "config_parsed" | "config_parse_failed" | "not_installed";
message_zh: string;
detail: string | null;
}
export interface DryRunPlan {
commands: string[][];
elevate: boolean;
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;
phase: string | null;
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[];
}
// ---- Wave 2.2:错误人话化 / 授权流程 / 运行时安装 ----
export interface ErrorHint {
code: string;
friendly_zh: string;
raw: string;
missing_runtime: string | null;
}
export type AuthFlowKind = "started" | "line" | "device_code" | "waiting" | "done" | "error" | "cancelled";
export interface AuthFlowEvent {
cli_id: string;
mode: string;
kind: AuthFlowKind;
message: string;
user_code: string | null;
verification_url: string | null;
authorized: boolean | null;
}
export interface RuntimeSource {
runtime: string;
label_zh: string;
download_url: string | null;
download_page: string;
size_approx: string;
elevate_needed: boolean;
allowed_hosts: string[];
direct_installable: boolean;
source_label: string;
}
export type RuntimeInstallPhase =
| "validate"
| "download"
| "open"
| "done"
| "error"
| "opened_page";
export interface RuntimeInstallEvent {
runtime: string;
phase: RuntimeInstallPhase;
message: string;
fallback?: boolean;
}
// ---- 适配器完整定义(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;
options?: { value: string; label_zh: string }[];
group?: string | null;
}[];
} | null;
documentation?: {
quickstart_zh?: string | null;
install_zh?: string | null;
auth_zh?: string | null;
commands?: { cmd: string; desc_zh?: string | null }[];
params?: { param: string; desc_zh?: string | null }[];
updated_at?: string | null;
risks_zh?: string[];
} | null;
}
+4 -1
View File
@@ -1,6 +1,7 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import App from "./App"; import App from "./App";
import { ErrorBoundary } from "./components/ErrorBoundary";
// 设计 Token(视觉规范 v1.2,唯一品味依据) // 设计 Token(视觉规范 v1.2,唯一品味依据)
import "./tokens/color.css"; import "./tokens/color.css";
@@ -25,6 +26,8 @@ applyFxTier();
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<App /> <ErrorBoundary>
<App />
</ErrorBoundary>
</React.StrictMode>, </React.StrictMode>,
); );
+9 -4
View File
@@ -1,17 +1,22 @@
import { Archive } from "lucide-react"; import { Archive } from "lucide-react";
/** 备份与迁移(PRD §7):创建备份、查看内容、恢复。Wave 0 占位。 */ /** 备份与迁移(PRD §7):创建备份、查看内容、恢复。空态按 v1.3 §3.7。 */
export function BackupPage() { export function BackupPage() {
return ( return (
<div className="placeholder-page"> <div className="placeholder-page">
<div className="placeholder-icon"> <div className="placeholder-icon">
<Archive size={40} strokeWidth={1.5} aria-hidden="true" /> <Archive size={40} strokeWidth={1.5} aria-hidden="true" />
</div> </div>
<h2 className="placeholder-title"></h2> <h2 className="placeholder-title"></h2>
<p className="placeholder-desc"> <p className="placeholder-desc">
Wave 4 CLI
</p> </p>
<button type="button" className="btn btn-secondary" disabled> <button
type="button"
className="btn btn-secondary"
disabled
title="安装 CLI 后才能创建备份"
>
</button> </button>
</div> </div>
+49 -22
View File
@@ -1,9 +1,10 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Search } from "lucide-react"; import { Search } from "lucide-react";
import { useCatalog } from "../hooks/useCatalog"; import { useCatalog } from "../hooks/useCatalog";
import { useDetectAll } from "../hooks/useDetectAll";
import { StatusBadge } from "../components/StatusBadge"; import { StatusBadge } from "../components/StatusBadge";
import { MonoChip } from "../components/MonoChip";
import { CliMonogram } from "../components/CliMonogram"; import { CliMonogram } from "../components/CliMonogram";
import { MonoChip } from "../components/MonoChip";
type StatusFilter = "all" | "installed" | "uninstalled" | "update"; type StatusFilter = "all" | "installed" | "uninstalled" | "update";
type PlatformFilter = "all" | "windows" | "linux"; type PlatformFilter = "all" | "windows" | "linux";
@@ -21,9 +22,10 @@ const PLATFORM_FILTERS: { key: PlatformFilter; label: string }[] = [
{ key: "linux", label: "Linux" }, { key: "linux", label: "Linux" },
]; ];
/** CLI 目录页(视觉规范 §3.2 + PRD §7):搜索 + 筛选 + 14 张卡片(数据来自 catalog.yaml */ /** CLI 目录页(视觉规范 §3.2 + PRD §7):搜索 + 筛选 + 14 张卡片(数据来自 catalog.yaml + 真机 detect */
export function CatalogPage() { export function CatalogPage({ onOpenDetail }: { onOpenDetail: (id: string) => void }) {
const { entries, loading, error } = useCatalog(); const { entries, loading, error } = useCatalog();
const { detectMap, loading: detecting } = useDetectAll();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [status, setStatus] = useState<StatusFilter>("all"); const [status, setStatus] = useState<StatusFilter>("all");
const [platform, setPlatform] = useState<PlatformFilter>("all"); const [platform, setPlatform] = useState<PlatformFilter>("all");
@@ -35,14 +37,17 @@ export function CatalogPage() {
const haystack = `${e.name_zh} ${e.name} ${e.vendor} ${e.id}`.toLowerCase(); const haystack = `${e.name_zh} ${e.name} ${e.vendor} ${e.id}`.toLowerCase();
if (!haystack.includes(q)) return false; if (!haystack.includes(q)) return false;
} }
// Wave 0:14 个均为未安装;已安装/可更新筛选结果为空(诚实占位) const installed = detectMap[e.id]?.status === "installed";
if (status === "installed") return false; // 状态筛选:接真机 detect 结果
if (status === "installed" && !installed) return false;
if (status === "uninstalled" && installed) return false;
// 可更新:Wave 2.1 不检测可更新,恒为空(诚实)
if (status === "update") return false; if (status === "update") return false;
// 平台:全部 14 个均支持 Windows + Ubuntu(调研底稿结论),筛选不改变集合 // 平台:全部 14 个均支持 Windows + Ubuntu(调研底稿结论),筛选不改变集合
if (platform === "windows" || platform === "linux") return true; if (platform === "windows" || platform === "linux") return true;
return true; return true;
}); });
}, [entries, query, status, platform]); }, [entries, query, status, platform, detectMap]);
return ( return (
<div className="catalog"> <div className="catalog">
@@ -87,23 +92,45 @@ export function CatalogPage() {
{loading && !error && <p className="panel-empty"></p>} {loading && !error && <p className="panel-empty"></p>}
<div className="catalog-grid"> <div className="catalog-grid">
{filtered.map((entry) => ( {filtered.map((entry) => {
<article className="catalog-card" key={entry.id}> const detect = detectMap[entry.id];
<div className="catalog-card-top"> const installed = detect?.status === "installed";
<CliMonogram name={entry.name} size={40} /> return (
<div className="catalog-card-head"> <article
<div className="catalog-card-name">{entry.name_zh}</div> className="catalog-card"
<div className="catalog-card-vendor">{entry.vendor}</div> key={entry.id}
role="button"
tabIndex={0}
onClick={() => onOpenDetail(entry.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpenDetail(entry.id);
}
}}
>
<div className="catalog-card-top">
<CliMonogram id={entry.id} name={entry.name} size={40} />
<div className="catalog-card-head">
<div className="catalog-card-name">{entry.name_zh}</div>
<div className="catalog-card-vendor">{entry.vendor}</div>
</div>
</div> </div>
</div> <p className="catalog-card-desc"> · </p>
<p className="catalog-card-desc"> · Wave 1 </p> <div className="catalog-card-bottom">
<div className="catalog-card-bottom"> {installed ? (
<StatusBadge kind="uninstalled" label="未安装" /> <span className="catalog-version">
<span className="catalog-platforms">Windows · Linux</span> <StatusBadge kind="installed" label="已安装" />
<MonoChip>v1</MonoChip> {detect?.version && <MonoChip>{detect.version}</MonoChip>}
</div> </span>
</article> ) : (
))} <StatusBadge kind="uninstalled" label={detecting ? "检测中…" : "未安装"} />
)}
<span className="catalog-platforms">Windows · Linux</span>
</div>
</article>
);
})}
{!loading && filtered.length === 0 && ( {!loading && filtered.length === 0 && (
<p className="panel-empty catalog-empty"> CLI</p> <p className="panel-empty catalog-empty"> CLI</p>
)} )}
+760
View File
@@ -0,0 +1,760 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import {
ArrowLeft,
BookOpen,
Check,
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,
ErrorHint,
} 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: "诊断" },
];
type RunPhase = "prepare" | "exec" | "verify";
type RunOutcome =
| { status: "success"; action: CliAction; detect: DetectResult | null }
| { status: "failed"; action: CliAction; message: string; hint: ErrorHint | null };
/** 授权状态灯(§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,
onInstallRuntime,
}: {
id: string;
onBack: () => void;
onInstallRuntime?: (runtime: string) => 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 [phase, setPhase] = useState<RunPhase>("prepare");
const [outcome, setOutcome] = useState<RunOutcome | null>(null);
const currentActionRef = useRef<CliAction>("install");
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);
setPhase("verify");
const d = (ev.data as DetectResult | null) ?? null;
setOutcome({ status: "success", action: currentActionRef.current, detect: d });
void refresh();
return;
}
if (ev.kind === "error") {
setRunning(false);
const hint = (ev.data as ErrorHint | null) ?? null;
setOutcome({ status: "failed", action: currentActionRef.current, message: ev.message, hint });
return;
}
if (ev.phase) setPhase(ev.phase as RunPhase);
setLog((l) => [...l, ev]);
}).then((fn) => {
unlisten = fn;
});
return () => unlisten?.();
}, [id, refresh]);
async function openConfirm(action: CliAction) {
const plan = await previewAction(id, action);
setConfirm({ action, plan });
}
async function confirmRun() {
if (!confirm) return;
const action = confirm.action;
currentActionRef.current = action;
setConfirm(null);
setRunning(true);
setOutcome(null);
setPhase("prepare");
setLog([]);
setRunTitle(action === "install" ? "正在安装" : action === "uninstall" ? "正在卸载" : "正在执行");
await runAction(id, action);
}
function closeRun() {
setRunning(false);
setOutcome(null);
setLog([]);
}
if (loading) {
return <p className="panel-empty"></p>;
}
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} auth={auth} />}
{tab === "config" && <ConfigForm id={id} onAuthChanged={refresh} />}
{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>
)}
{/* 流式执行日志弹窗(安装/卸载全程可见:步骤进度 + stdout/stderr + 终态) */}
{(running || outcome) && (
<Modal
title={outcome ? `${runTitle} ${adapter.name_zh} · ${outcome.status === "success" ? "完成" : "失败"}` : `${runTitle} ${adapter.name_zh}`}
onClose={running ? undefined : closeRun}
footer={
running ? null : (
<button type="button" className="btn btn-primary" onClick={closeRun}>
</button>
)
}
>
<RunBody phase={phase} log={log} outcome={outcome} onInstallRuntime={onInstallRuntime} />
</Modal>
)}
</div>
);
}
/** 执行进度三阶段(准备 / 执行 / 复检) */
function PhaseSteps({ phase }: { phase: RunPhase }) {
const steps: { key: RunPhase; label: string }[] = [
{ key: "prepare", label: "准备" },
{ key: "exec", label: "执行" },
{ key: "verify", label: "复检" },
];
const order: Record<RunPhase, number> = { prepare: 0, exec: 1, verify: 2 };
const cur = order[phase];
return (
<div className="run-phases" aria-label="安装进度">
{steps.map((s, i) => (
<div key={s.key} className={`run-phase ${i < cur ? "done" : i === cur ? "active" : ""}`}>
<span className="run-phase-dot">{i < cur ? "✓" : i + 1}</span>
<span className="run-phase-label">{s.label}</span>
</div>
))}
</div>
);
}
function RunBody({
phase,
log,
outcome,
onInstallRuntime,
}: {
phase: RunPhase;
log: ActionEvent[];
outcome: RunOutcome | null;
onInstallRuntime?: (runtime: string) => void;
}) {
return (
<div className="run-body">
<PhaseSteps phase={phase} />
<div className="run-log" aria-live="polite">
{log.length === 0 && !outcome && (
<div className="run-log-wait">
<Loader2 size={16} strokeWidth={1.5} className="spin" aria-hidden="true" />
</div>
)}
{log.map((l, i) => (
<div key={i} className={`run-log-line ${l.kind}`}>
{l.kind === "step" ? <ClipboardList size={13} strokeWidth={1.5} aria-hidden="true" /> : null}
{l.kind === "stdout" ? <Terminal size={13} strokeWidth={1.5} aria-hidden="true" /> : null}
{l.kind === "stderr" ? <ShieldAlert size={13} strokeWidth={1.5} aria-hidden="true" /> : null}
<span>{l.message}</span>
</div>
))}
<div ref={useAutoScroll(log)} />
</div>
{outcome?.status === "success" && (
<div className="run-result success">
<Check size={14} strokeWidth={1.5} aria-hidden="true" />
<span>
{outcome.action === "install" ? "安装成功" : outcome.action === "uninstall" ? "卸载完成" : "执行完成"}
</span>
{outcome.detect?.status === "installed" && (
<span className="run-result-version">
{outcome.detect.version ?? ""}
{outcome.detect.executable ? ` · ${outcome.detect.executable}` : ""}
</span>
)}
</div>
)}
{outcome?.status === "failed" && (
<FailureResult outcome={outcome} onInstallRuntime={onInstallRuntime} />
)}
</div>
);
}
/** 失败态:默认展示人话版建议;原始报错折叠保留;缺失运行时给内联「去安装」按钮(联动本机环境一键装) */
function FailureResult({
outcome,
onInstallRuntime,
}: {
outcome: { status: "failed"; action: CliAction; message: string; hint: ErrorHint | null };
onInstallRuntime?: (runtime: string) => void;
}) {
const [showRaw, setShowRaw] = useState(false);
const hint = outcome.hint;
return (
<div className="run-result failed run-failure">
<div className="run-failure-head">
<ShieldAlert size={14} strokeWidth={1.5} aria-hidden="true" />
<span>{hint?.friendly_zh ?? outcome.message}</span>
</div>
{hint?.missing_runtime && onInstallRuntime && (
<button
type="button"
className="btn btn-secondary run-failure-action"
onClick={() => onInstallRuntime(hint.missing_runtime!)}
>
{runtimeLabel(hint.missing_runtime)}
</button>
)}
{hint?.raw && (
<>
<button
type="button"
className="link-btn run-failure-toggle"
onClick={() => setShowRaw((v) => !v)}
>
{showRaw ? "收起" : "展开"}
</button>
{showRaw && <pre className="run-failure-raw">{hint.raw}</pre>}
</>
)}
</div>
);
}
function runtimeLabel(runtime: string): string {
switch (runtime) {
case "node":
return "Node.js";
case "python":
return "Python";
case "git":
return "Git";
case "winget":
return "winget";
case "uv":
return "uv";
default:
return runtime;
}
}
/** 自动滚动到底部(把 ref 挂在日志末尾) */
function useAutoScroll(dep: unknown) {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
ref.current?.scrollIntoView({ behavior: "smooth" });
}, [dep]);
return ref;
}
function ConfirmBody({ plan, action }: { plan: DryRunPlan; action: CliAction }) {
return (
<div className="confirm-body">
<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,
auth,
}: {
adapter: Adapter;
detect: DetectResult | null;
auth: AuthStatus | null;
}) {
const channels = adapter.install?.channels ?? [];
const authModes = adapter.authorization?.modes ?? [];
const platforms: string[] = [];
if (adapter.platforms?.windows) platforms.push("Windows");
if (adapter.platforms?.linux) platforms.push("Ubuntu");
const rows: { label: string; value: ReactNode }[] = [
{ label: "厂商", value: adapter.vendor },
{ label: "版本", value: detect?.version ? <MonoChip>{detect.version}</MonoChip> : detectLabel(detect) },
{ label: "路径", value: detect?.executable ? <MonoChip>{detect.executable}</MonoChip> : "—" },
{ label: "授权状态", value: auth ? auth.detail_zh : "—" },
{ label: "支持平台", value: platforms.length > 0 ? platforms.join(" · ") : "—" },
{
label: "授权方式",
value: authModes.length > 0 ? authModes.map((m) => authModeLabel(m.mode)).join(" / ") : "—",
},
{ label: "适配器版本", value: adapter.adapter_version ?? "—" },
];
return (
<div className="detail-overview">
<div className="detail-block">
<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>
<dl className="detail-meta">
{rows.map((r) => (
<div key={r.label} className="detail-meta-row">
<dt className="detail-meta-label">{r.label}</dt>
<dd className="detail-meta-value">{r.value}</dd>
</div>
))}
</dl>
</div>
<div className="detail-block">
<h3 className="detail-block-title"></h3>
{channels.length === 0 ? (
<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>
<div className="detail-block">
<h3 className="detail-block-title"></h3>
<div className="detail-links">
{adapter.official?.homepage && (
<a className="detail-link" href={adapter.official.homepage} target="_blank" rel="noreferrer">
</a>
)}
{adapter.official?.docs && (
<a className="detail-link" href={adapter.official.docs} target="_blank" rel="noreferrer">
</a>
)}
</div>
</div>
{detect && detect.status !== "installed" && detect.status !== "not_installed" && (
<div className="detail-block detail-note">
<CircleHelp size={14} strokeWidth={1.5} aria-hidden="true" />
{detectLabel(detect)} PATH
</div>
)}
</div>
);
}
function authModeLabel(mode: string): string {
switch (mode) {
case "browser_oauth":
return "浏览器授权";
case "device_code":
return "设备码";
case "api_key":
return "API Key";
case "local_tui":
return "本机终端授权";
default:
return mode;
}
}
function DocsTab({ adapter }: { adapter: Adapter }) {
const doc = adapter.documentation;
const docUrl = adapter.official?.docs;
return (
<div className="detail-docs">
{doc?.quickstart_zh && (
<div className="detail-block">
<h3 className="detail-block-title"></h3>
<p className="detail-block-text">{doc.quickstart_zh}</p>
</div>
)}
{doc?.install_zh && (
<div className="detail-block">
<h3 className="detail-block-title"></h3>
<p className="detail-block-text">{doc.install_zh}</p>
</div>
)}
{doc?.auth_zh && (
<div className="detail-block">
<h3 className="detail-block-title"></h3>
<p className="detail-block-text">{doc.auth_zh}</p>
</div>
)}
<div className="detail-block">
<h3 className="detail-block-title">
<BookOpen size={14} strokeWidth={1.5} aria-hidden="true" />
</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?.params ?? []).length > 0 && (
<div className="detail-block">
<h3 className="detail-block-title"></h3>
<ul className="detail-commands">
{doc!.params!.map((p) => (
<li key={p.param} className="detail-command">
<MonoChip>{p.param}</MonoChip>
{p.desc_zh && <span className="detail-command-desc">{p.desc_zh}</span>}
</li>
))}
</ul>
</div>
)}
{(doc?.risks_zh ?? []).length > 0 && (
<div className="detail-block detail-note">
<ShieldAlert size={14} strokeWidth={1.5} aria-hidden="true" />
<div>
{doc!.risks_zh!.map((r) => (
<p key={r}>{r}</p>
))}
</div>
</div>
)}
<div className="detail-block detail-doc-footer">
{docUrl && (
<a className="detail-link" href={docUrl} target="_blank" rel="noreferrer">
</a>
)}
<span className="detail-doc-meta">
{adapter.adapter_version ?? "—"} · {doc?.updated_at ?? "—"}
</span>
</div>
</div>
);
}
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 = report.findings;
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 { SlidersHorizontal } from "lucide-react";
import type { PageKey } from "../components/Sidebar";
/** 配置中心(PRD §7):按 CLI 展示中文配置表单。Wave 0 占位。 */ /** 配置中心(PRD §7):按 CLI 展示中文配置表单。空态按 v1.3 §3.7。 */
export function ConfigCenterPage() { export function ConfigCenterPage({ onNavigate }: { onNavigate: (p: PageKey) => void }) {
return ( return (
<div className="placeholder-page"> <div className="placeholder-page">
<div className="placeholder-icon"> <div className="placeholder-icon">
<SlidersHorizontal size={40} strokeWidth={1.5} aria-hidden="true" /> <SlidersHorizontal size={40} strokeWidth={1.5} aria-hidden="true" />
</div> </div>
<h2 className="placeholder-title"></h2> <h2 className="placeholder-title"> CLI</h2>
<p className="placeholder-desc"> <p className="placeholder-desc">
Wave 2 使 CLI
</p> </p>
<button type="button" className="btn btn-secondary" disabled> <button type="button" className="btn btn-secondary" onClick={() => onNavigate("catalog")}>
CLI CLI
</button> </button>
</div> </div>
+101 -13
View File
@@ -1,19 +1,107 @@
import { SquareTerminal } from "lucide-react"; import { useEffect, useState } from "react";
import { ChevronRight, SquareTerminal } from "lucide-react";
import { useCatalog } from "../hooks/useCatalog";
import { useDetectAll } from "../hooks/useDetectAll";
import { authStatus } from "../ipc";
import { CliMonogram } from "../components/CliMonogram";
import { MonoChip } from "../components/MonoChip";
import type { AuthStatus } from "../ipc/types";
import type { PageKey } from "../components/Sidebar";
/** 我的 CLI(PRD §7):版本 / 路径 / 授权状态。Wave 0 尚无已安装 CLI,展示空态。 */ /** 单个已安装 CLI 的授权状态(懒加载) */
export function MyCliPage() { function AuthBadge({ id }: { id: string }) {
const [auth, setAuth] = useState<AuthStatus | null>(null);
useEffect(() => {
let cancelled = false;
authStatus(id)
.then((a) => {
if (!cancelled) setAuth(a);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [id]);
if (!auth) return <span className="mycli-auth"></span>;
const ok = auth.status === "authorized";
return ( return (
<div className="placeholder-page"> <span className="mycli-auth">
<div className="placeholder-icon"> <span className={ok ? "status-dot ok" : "status-dot unknown"} aria-hidden="true" />
<SquareTerminal size={40} strokeWidth={1.5} aria-hidden="true" /> {ok ? "已授权" : "未授权"}
</span>
);
}
/** 我的 CLI(PRD §7):展示已安装列表(版本 / 路径 / 授权状态)。空态按 v1.3 §3.7。 */
export function MyCliPage({
onNavigate,
onOpenDetail,
}: {
onNavigate: (p: PageKey) => void;
onOpenDetail: (id: string) => void;
}) {
const { entries } = useCatalog();
const { detectMap, loading } = useDetectAll();
const installed = entries.filter((e) => detectMap[e.id]?.status === "installed");
if (loading) {
return <p className="panel-empty"> CLI</p>;
}
if (installed.length === 0) {
return (
<div className="placeholder-page">
<div className="placeholder-icon">
<SquareTerminal size={40} strokeWidth={1.5} aria-hidden="true" />
</div>
<h2 className="placeholder-title"> CLI</h2>
<p className="placeholder-desc">
CLI
</p>
<button type="button" className="btn btn-primary" onClick={() => onNavigate("catalog")}>
CLI
</button>
</div>
);
}
return (
<div className="mycli">
<div className="panel mycli-list">
<div className="panel-head">
<h2 className="panel-title"> CLI{installed.length}</h2>
</div>
{installed.map((entry) => {
const d = detectMap[entry.id];
return (
<button
key={entry.id}
type="button"
className="mycli-row"
onClick={() => onOpenDetail(entry.id)}
>
<CliMonogram id={entry.id} name={entry.name} size={36} />
<div className="mycli-row-main">
<span className="mycli-row-name">{entry.name_zh}</span>
{d?.version && (
<span className="mycli-row-version">
<MonoChip>{d.version}</MonoChip>
</span>
)}
</div>
{d?.executable && (
<span className="mycli-row-path">
<MonoChip>{d.executable}</MonoChip>
</span>
)}
<AuthBadge id={entry.id} />
<ChevronRight size={16} strokeWidth={1.5} className="mycli-row-arrow" aria-hidden="true" />
</button>
);
})}
</div> </div>
<h2 className="placeholder-title"> CLI</h2>
<p className="placeholder-desc">
Wave 1 CLI
</p>
<button type="button" className="btn btn-primary" disabled>
CLI
</button>
</div> </div>
); );
} }
+481 -174
View File
@@ -1,12 +1,24 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import { Activity, Archive, ChevronRight, Lock, Plus } from "lucide-react"; import { Activity, Archive, Download, Loader2, Lock, Plus, ScanSearch } from "lucide-react";
import { useEnv } from "../hooks/useEnv"; import { useEnv } from "../hooks/useEnv";
import { useCatalog } from "../hooks/useCatalog"; import { useCatalog } from "../hooks/useCatalog";
import { useDetectAll } from "../hooks/useDetectAll";
import { diagnoseAll } from "../ipc";
import type {
CatalogEntry,
DetectResult,
DiagnosticReport,
PlatformEnv,
RuntimeInfo,
RuntimeStatus,
} from "../ipc/types";
import { KpiCard } from "../components/KpiCard"; import { KpiCard } from "../components/KpiCard";
import { MonoChip } from "../components/MonoChip"; import { MonoChip } from "../components/MonoChip";
import { StatusBadge } from "../components/StatusBadge"; import { StatusBadge } from "../components/StatusBadge";
import { CliMonogram } from "../components/CliMonogram"; import { CliMonogram } from "../components/CliMonogram";
import type { PlatformEnv, RuntimeInfo } from "../ipc/types"; import { Modal } from "../components/Modal";
import { RuntimeInstallModal } from "../components/RuntimeInstallModal";
import type { PageKey } from "../components/Sidebar";
/** 平台相关运行时集合(Windows 不看 aptLinux 不看 winget */ /** 平台相关运行时集合(Windows 不看 aptLinux 不看 winget */
const WINDOWS_RUNTIMES = ["node", "npm", "python", "uv", "git", "winget"] as const; const WINDOWS_RUNTIMES = ["node", "npm", "python", "uv", "git", "winget"] as const;
@@ -24,231 +36,526 @@ function runtimeProblems(env: PlatformEnv): { name: string; info: RuntimeInfo }[
return problems; return problems;
} }
function runtimeStatusText(status: string): string { /** 本机环境运行时项四元组(v1.3 §3.1.6):状态点 + 名称 + 版本位 + 行动位 */
function runtimeMeta(status: RuntimeStatus): {
dot: string;
missingText: string | null;
needsInstall: boolean;
} {
switch (status) { switch (status) {
case "not_in_path": case "installed":
return "不在 PATH"; return { dot: "status-dot ok", missingText: null, needsInstall: false };
case "not_installed": case "not_installed":
return "未安装"; return { dot: "status-dot unknown", missingText: "未检测到", needsInstall: true };
case "not_in_path":
return { dot: "status-dot warn breathe", missingText: "不在 PATH", needsInstall: false };
case "permission_denied":
return { dot: "status-dot warn breathe", missingText: "无访问权限", needsInstall: false };
case "exec_failed": case "exec_failed":
return "执行失败"; return { dot: "status-dot warn breathe", missingText: "执行失败", needsInstall: false };
case "version_unparseable": case "version_unparseable":
return "版本未知"; return { dot: "status-dot warn breathe", missingText: "版本未知", needsInstall: false };
default: default:
return "未知"; return { dot: "status-dot unknown", missingText: "未知", needsInstall: false };
} }
} }
function RuntimeItem({ name, info }: { name: string; info: RuntimeInfo }) { function RuntimeItem({
const dotClass = name,
info.status === "installed" info,
? "status-dot ok" onInstall,
: info.status === "exec_failed" }: {
? "status-dot error" name: string;
: "status-dot warn"; info: RuntimeInfo;
onInstall: (runtime: string) => void;
}) {
const meta = runtimeMeta(info.status);
return ( return (
<div className="runtime-item"> <div className="runtime-item">
<span className={dotClass} aria-hidden="true" /> <span className={meta.dot} aria-hidden="true" />
<span className="runtime-name">{name}</span> <span className="runtime-name">{name}</span>
{info.version ? ( {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.needsInstall && (
<button type="button" className="link-btn runtime-action" onClick={() => onInstall(name)}>
<Download size={12} strokeWidth={1.5} aria-hidden="true" />
</button>
)} )}
</div> </div>
); );
} }
function EnvPanel() { function EnvPanel({
const { env, loading, error } = useEnv(); onInstallRuntime,
}: {
onInstallRuntime: (runtime: string) => void;
}) {
const { env, loading } = useEnv();
const [showAllPath, setShowAllPath] = useState(false); const [showAllPath, setShowAllPath] = useState(false);
if (error) { if (!env && loading) {
return (
<section className="panel env-panel">
<div className="panel-head">
<h2 className="panel-title"></h2>
<span className="panel-hint"></span>
</div>
<div className="skeleton-rows">
<div className="skeleton-line" />
<div className="skeleton-line" />
<div className="skeleton-line short" />
</div>
</section>
);
}
if (!env) {
return ( return (
<section className="panel env-panel"> <section className="panel env-panel">
<div className="panel-head"> <div className="panel-head">
<h2 className="panel-title"></h2> <h2 className="panel-title"></h2>
</div> </div>
<p className="panel-empty">{error}</p> <p className="panel-empty"></p>
</section> </section>
); );
} }
const pathPreview = env ? env.path_entries.slice(0, 8) : []; const pathPreview = env.path_entries.slice(0, 8);
return ( return (
<section className="panel env-panel"> <section className="panel env-panel">
<div className="panel-head"> <div className="panel-head">
<h2 className="panel-title"></h2> <h2 className="panel-title"></h2>
{loading && <span className="panel-hint"></span>} {loading && <span className="panel-hint"></span>}
</div> </div>
{env && ( <div className="env-grid">
<> <div className="env-basic">
<div className="env-grid"> <div className="env-row">
<div className="env-basic"> <span className="env-key"></span>
<div className="env-row"> <span className="env-val">{env.os_version}</span>
<span className="env-key"></span>
<span className="env-val">{env.os_version}</span>
</div>
<div className="env-row">
<span className="env-key"></span>
<span className="env-val">{env.arch}</span>
</div>
<div className="env-row">
<span className="env-key">Shell</span>
<span className="env-val">
{env.shells.powershell_version ? (
<>PowerShell <MonoChip>{env.shells.powershell_version}</MonoChip></>
) : null}
{env.shells.pwsh_version ? (
<> · pwsh <MonoChip>{env.shells.pwsh_version}</MonoChip></>
) : null}
{env.shells.bash_available ? " · Bash ✓" : ""}
</span>
</div>
<div className="env-row">
<span className="env-key"></span>
<span className="env-val">
{env.capabilities.keyring === "ok" ? "Credential Manager ✓" : "密钥库缺失"}
</span>
</div>
<div className="env-row">
<span className="env-key"></span>
<span className="env-val">{env.capabilities.can_elevate ? "可用" : "不可用"}</span>
</div>
</div>
<div className="runtime-grid">
{(["node", "npm", "python", "uv", "git", "winget", "apt"] as const).map(
(key) => {
const info = env.runtimes[key];
if (!info) return null;
return <RuntimeItem key={key} name={key} info={info} />;
},
)}
</div>
</div> </div>
<div className="path-block"> <div className="env-row">
<div className="path-head"> <span className="env-key"></span>
<span className="path-title"> <span className="env-val">{env.arch}</span>
PATH{env.path_entries.length}
<MonoChip>{env.path_entries.length}</MonoChip>
</span>
{env.path_entries.length > pathPreview.length && (
<button
type="button"
className="link-btn"
onClick={() => setShowAllPath((v) => !v)}
>
{showAllPath ? "收起" : "展开全部"}
</button>
)}
</div>
{(showAllPath ? env.path_entries : pathPreview).map((entry, idx) => (
<div key={idx} className="path-entry">
{entry}
</div>
))}
</div> </div>
</> <div className="env-row">
)} <span className="env-key">Shell</span>
<span className="env-val">
{env.shells.powershell_version ? (
<>PowerShell <MonoChip>{env.shells.powershell_version}</MonoChip></>
) : null}
{env.shells.pwsh_version ? (
<> · pwsh <MonoChip>{env.shells.pwsh_version}</MonoChip></>
) : null}
{env.shells.bash_available ? " · Bash ✓" : ""}
</span>
</div>
<div className="env-row">
<span className="env-key"></span>
<span className="env-val">
{env.capabilities.keyring === "ok" ? "Credential Manager ✓" : "密钥库缺失"}
</span>
</div>
<div className="env-row">
<span className="env-key"></span>
<span className="env-val">{env.capabilities.can_elevate ? "可用" : "不可用"}</span>
</div>
</div>
<div className="runtime-grid">
{(["node", "npm", "python", "uv", "git", "winget", "apt"] as const).map((key) => {
const info = env.runtimes[key];
if (!info) return null;
return <RuntimeItem key={key} name={key} info={info} onInstall={onInstallRuntime} />;
})}
</div>
</div>
<div className="path-block">
<div className="path-head">
<span className="path-title">
PATH{env.path_entries.length}
<MonoChip>{env.path_entries.length}</MonoChip>
</span>
{env.path_entries.length > pathPreview.length && (
<button
type="button"
className="link-btn"
onClick={() => setShowAllPath((v) => !v)}
>
{showAllPath ? "收起" : "展开全部"}
</button>
)}
</div>
{(showAllPath ? env.path_entries : pathPreview).map((entry, idx) => (
<div key={idx} className="path-entry">
{entry}
</div>
))}
</div>
</section> </section>
); );
} }
/** 总览页(视觉规范 §3.1 + PRD §7):KPI + 最近诊断/快速操作 + CLI 状态网格 + 本机环境 */ /** 首跑空态引导插画(v1.3 §7:等距线框终端 + 集装箱 Dock,1px 线,霓虹点缀) */
export function OverviewPage() { 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>
);
}
/** 检测进行中的骨架屏(Wave 2.2 Req 4:禁止在检测未完成时显示空态文案) */
function OverviewSkeleton() {
return (
<div className="overview-skeleton" role="status" aria-label="正在检测本机 CLI">
<div className="skeleton-notice">
<Loader2 size={16} strokeWidth={1.5} className="spin" aria-hidden="true" />
CLI
</div>
<div className="skeleton-grid">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="skeleton-card">
<div className="skeleton-line avatar" />
<div className="skeleton-line" />
<div className="skeleton-line short" />
</div>
))}
</div>
</div>
);
}
/** 最近诊断卡(v1.3 §3.1.2:无记录时展示空态;「立即诊断」跑全量诊断) */
function DiagPanel({ onDiagnose }: { onDiagnose: () => void }) {
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" onClick={onDiagnose}>
<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,
onDiagnose,
}: {
firstRun: boolean;
onNavigate: (p: PageKey) => void;
onDiagnose: () => 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" onClick={onDiagnose}>
<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,
detect,
compact,
onNavigate,
onOpenDetail,
}: {
entry: CatalogEntry;
detect: DetectResult | undefined;
compact: boolean;
onNavigate: (p: PageKey) => void;
onOpenDetail: (id: string) => void;
}) {
const installed = detect?.status === "installed";
return (
<div className="cli-block" role="button" tabIndex={0} onClick={() => onOpenDetail(entry.id)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onOpenDetail(entry.id); } }}>
<div className="cli-block-top">
<CliMonogram id={entry.id} name={entry.name} size={32} />
<span className="cli-block-name">{entry.name_zh}</span>
<StatusBadge kind={installed ? "installed" : "uninstalled"} label={installed ? "已安装" : "未安装"} />
</div>
<div className="cli-block-version">
{installed ? (
detect?.version ? <MonoChip>{detect.version}</MonoChip> : null
) : (
<span className="version-missing"></span>
)}
</div>
{!compact && (
<div className="cli-block-auth">
{installed ? (
<>
<span className="status-dot ok" aria-hidden="true" />
<span>{detect?.version ? `v${detect.version}` : "已安装"}</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={(e) => { e.stopPropagation(); onNavigate("catalog"); }}>
</button>
</div>
)}
</div>
);
}
interface OverviewProps {
onNavigate: (p: PageKey) => void;
onOpenDetail: (id: string) => void;
pendingRuntime?: string | null;
onRuntimeHandled?: () => void;
}
/** 总览页(视觉规范 §3.1 + PRD §7):首跑空态 / KPI + 诊断/快速操作 + CLI 状态网格 + 本机环境 */
export function OverviewPage({ onNavigate, onOpenDetail, pendingRuntime, onRuntimeHandled }: OverviewProps) {
const { env } = useEnv(); const { env } = useEnv();
const { entries } = useCatalog(); const { entries, loading: catalogLoading } = useCatalog();
const { detectMap, loading: detecting } = useDetectAll();
const [runtimeInstall, setRuntimeInstall] = useState<string | null>(null);
const [diagOpen, setDiagOpen] = useState(false);
const [diagReports, setDiagReports] = useState<DiagnosticReport[] | null>(null);
const [diagBusy, setDiagBusy] = useState(false);
// 外部跳转(详情页错误内联按钮「去安装 Node.js」)
useEffect(() => {
if (pendingRuntime) {
setRuntimeInstall(pendingRuntime);
onRuntimeHandled?.();
}
}, [pendingRuntime, onRuntimeHandled]);
async function runDiagnose() {
setDiagOpen(true);
setDiagBusy(true);
setDiagReports(null);
try {
const reports = await diagnoseAll();
setDiagReports(reports);
} catch (e) {
setDiagReports([{ cli_id: "", findings: [{ rule_id: "diagnose.failed", severity: "error", message_zh: String(e), evidence: null }] }]);
} finally {
setDiagBusy(false);
}
}
const installedCount = entries.filter((e) => detectMap[e.id]?.status === "installed").length;
// 检测未完成(首次、无缓存)时不得判空态
const ready = !detecting && !catalogLoading;
const firstRun = ready && installedCount === 0;
const warningCount = env ? runtimeProblems(env).length : 0; const warningCount = env ? runtimeProblems(env).length : 0;
const warningNames = env ? runtimeProblems(env).map((p) => p.name) : []; const warningNames = env ? runtimeProblems(env).map((p) => p.name) : [];
return ( return (
<div className="overview"> <div className="overview">
{/* ① KPI 一排 4 张 */} {/* ① KPI 一排 4 张(首跑空态隐藏,v1.3 §3.1.5) */}
<section className="kpi-grid" aria-label="概览指标"> {ready && !firstRun && (
<KpiCard label="已安装" value={0} tone="primary" subtitle="暂无已安装 CLI" /> <section className="kpi-grid" aria-label="概览指标">
<KpiCard label="待授权" value={0} tone="attention" subtitle="暂无待授权项" /> <KpiCard label="已安装" value={installedCount} tone="primary" subtitle="暂无安装" action={{ label: "去目录看看", onClick: () => onNavigate("catalog") }} />
<KpiCard label="可更新" value={0} tone="accent" subtitle="暂无可用更新" /> <KpiCard label="待授权" value={0} tone="attention" subtitle="暂无待授权" />
<KpiCard <KpiCard label="可更新" value={0} tone="accent" subtitle="全部已是最新" />
label="环境异常" <KpiCard
value={warningCount} label="环境异常"
tone="warning" value={warningCount}
subtitle={warningCount > 0 ? warningNames.join(" · ") : "环境正常"} tone="warning"
/> subtitle={warningCount > 0 ? warningNames.join(" · ") : "环境正常"}
</section> />
</section>
)}
{/* ② 中段:左 2/3 最近诊断 + 右 1/3 快速操作 */} {/* ② 中段:诊断/引导 + 快速操作(超宽三栏见 global.css) */}
<section className="overview-mid"> {ready ? (
<div className="panel diag-panel"> <section className="overview-mid">
{firstRun ? <Onboarding onNavigate={onNavigate} /> : <DiagPanel onDiagnose={runDiagnose} />}
<QuickActions firstRun={firstRun} onNavigate={onNavigate} onDiagnose={runDiagnose} />
</section>
) : (
<section className="overview-mid">
<OverviewSkeleton />
<QuickActions firstRun onNavigate={onNavigate} onDiagnose={runDiagnose} />
</section>
)}
{/* ③ 下段:CLI 状态网格通栏(首跑精简:可安装列表 + 安装入口) */}
{!ready ? (
<OverviewSkeleton />
) : (
<section className="panel cli-status-panel">
<div className="panel-head"> <div className="panel-head">
<h2 className="panel-title"></h2> <h2 className="panel-title">
<button type="button" className="link-btn"> {firstRun ? `可安装的 CLI${entries.length}` : "CLI 状态"}
<ChevronRight size={14} strokeWidth={1.5} aria-hidden="true" /> </h2>
</button>
</div> </div>
<ul className="diag-list"> <div className="cli-status-grid">
<li className="diag-row"> {entries.map((entry) => (
<span className="status-dot ok" aria-hidden="true" /> <CliBlock
<span className="diag-cli"></span> key={entry.id}
<span className="diag-text"> Wave 1 </span> entry={entry}
<span className="diag-time"></span> detect={detectMap[entry.id]}
</li> compact={firstRun}
</ul> onNavigate={onNavigate}
</div> onOpenDetail={onOpenDetail}
<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> </div>
<p className="safety-note"> </section>
<Lock size={12} strokeWidth={1.5} aria-hidden="true" /> )}
</p>
</div>
</section>
{/* ③ 下段:CLI 状态网格通栏 */} {/* ④ 本机环境(真实检测结果,架构 §5;超宽断点上提为第三栏) */}
<section className="panel cli-status-panel"> <EnvPanel onInstallRuntime={setRuntimeInstall} />
<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>
</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>
))}
</div>
</section>
{/* ④ 本机环境(真实检测结果,架构 §5) */} {/* 运行时一键安装弹窗 */}
<EnvPanel /> {runtimeInstall && (
<RuntimeInstallModal runtime={runtimeInstall} onClose={() => setRuntimeInstall(null)} />
)}
{/* 全量诊断汇总弹窗 */}
{diagOpen && (
<Modal title="诊断结果" onClose={() => setDiagOpen(false)}>
<DiagSummary busy={diagBusy} reports={diagReports} />
</Modal>
)}
</div>
);
}
function DiagSummary({ busy, reports }: { busy: boolean; reports: DiagnosticReport[] | null }) {
if (busy) {
return (
<p className="panel-empty">
<Loader2 size={14} strokeWidth={1.5} className="spin" aria-hidden="true" />
</p>
);
}
if (!reports) return null;
const allFindings = reports.flatMap((r) => r.findings);
const errors = allFindings.filter((f) => f.severity === "error").length;
const warns = allFindings.filter((f) => f.severity === "warn").length;
return (
<div className="diag-summary-all">
<div className="diag-summary">
{allFindings.length === 0 ? (
<>
<span className="status-dot ok" aria-hidden="true" />
<span></span>
</>
) : (
<>
<span className={`status-dot ${errors > 0 ? "error" : "warn"}`} aria-hidden="true" />
<span>
{allFindings.length} {errors} {warns} {allFindings.length - errors - warns}
</span>
</>
)}
</div>
{reports.map((r) =>
r.findings.length === 0 ? null : (
<div key={r.cli_id} className="diag-report-group">
<div className="diag-report-cli">{r.cli_id}</div>
{r.findings.map((f, i) => (
<div key={i} className="diag-finding-row">
<span className={`diag-sev-label ${f.severity}`}>
{f.severity === "error" ? "错误" : f.severity === "warn" ? "警告" : "提示"}
</span>
<span className="diag-finding-row-msg">{f.message_zh}</span>
</div>
))}
</div>
),
)}
</div> </div>
); );
} }
+3 -3
View File
@@ -9,14 +9,14 @@ export function SettingsPage() {
<div className="setting-row"> <div className="setting-row">
<div className="setting-info"> <div className="setting-info">
<div className="setting-label"></div> <div className="setting-label"></div>
<div className="setting-hint"> CLI Wave 1 </div> <div className="setting-hint"> CLI </div>
</div> </div>
<span className="setting-value"></span> <span className="setting-value"></span>
</div> </div>
<div className="setting-row"> <div className="setting-row">
<div className="setting-info"> <div className="setting-info">
<div className="setting-label"></div> <div className="setting-label"></div>
<div className="setting-hint"> AgentDock CLI Wave 4 </div> <div className="setting-hint"> AgentDock CLI </div>
</div> </div>
<span className="setting-value"></span> <span className="setting-value"></span>
</div> </div>
@@ -30,7 +30,7 @@ export function SettingsPage() {
<div className="setting-row"> <div className="setting-row">
<div className="setting-info"> <div className="setting-info">
<div className="setting-label"></div> <div className="setting-label"></div>
<div className="setting-hint">Wave 1 </div> <div className="setting-hint"></div>
</div> </div>
<span className="setting-value"></span> <span className="setting-value"></span>
</div> </div>
File diff suppressed because it is too large Load Diff
+4
View File
@@ -12,6 +12,10 @@
--ad-border: #22304A; /* 常规 1px 分隔线 */ --ad-border: #22304A; /* 常规 1px 分隔线 */
--ad-border-bright: #35507A; /* 悬浮/选中的边框 */ --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: #00E5FF; /* 主操作、链接、聚焦、选中态(青) */
--ad-primary-hover: #4DEFFF; /* 主操作悬浮 */ --ad-primary-hover: #4DEFFF; /* 主操作悬浮 */
+3 -4
View File
@@ -14,6 +14,7 @@
--ad-glow-accent: 0 0 12px rgba(255, 77, 219, 0.35); --ad-glow-accent: 0 0 12px rgba(255, 77, 219, 0.35);
--ad-glow-warning: 0 0 10px rgba(255, 176, 32, 0.28); /* 仅警告 chip / 警告状态灯 */ --ad-glow-warning: 0 0 10px rgba(255, 176, 32, 0.28); /* 仅警告 chip / 警告状态灯 */
--ad-glow-danger: 0 0 12px rgba(255, 77, 94, 0.35); --ad-glow-danger: 0 0 12px rgba(255, 77, 94, 0.35);
--ad-glow-balanced: 0 0 6px rgba(0, 229, 255, 0.3); /* 中档单层辉光(§5 半径减半),tiers.css 引用 */
/* ---- 主按钮(§2.6v1.2 hover 双层扩散辉光)---- */ /* ---- 主按钮(§2.6v1.2 hover 双层扩散辉光)---- */
--ad-btn-primary-bg: linear-gradient(180deg, #33D9EE 0%, #06BCD9 55%, #049EBB 100%); --ad-btn-primary-bg: linear-gradient(180deg, #33D9EE 0%, #06BCD9 55%, #049EBB 100%);
@@ -29,10 +30,8 @@
--ad-btn-accent-glow-hover: 0 0 14px rgba(255, 77, 219, 0.30), --ad-btn-accent-glow-hover: 0 0 14px rgba(255, 77, 219, 0.30),
0 0 36px rgba(255, 77, 219, 0.12); 0 0 36px rgba(255, 77, 219, 0.12);
/* ---- 背景网格(§3.0:要么可见要么不做---- */ /* ---- 背景氛围(v1.3 §3.0:网格移除,降级为静态深空渐变,三档通用---- */
--ad-grid-color: rgba(0, 229, 255, 0.05); /* 透明度固定 5%,施工员不许再调 */ --ad-bg-gradient: radial-gradient(120% 90% at 50% 0%, #0D1420 0%, #070B14 70%);
--ad-grid-size: 40px;
--ad-grid-mask: radial-gradient(120% 90% at 50% 40%, #000 30%, transparent 75%);
/* ---- 弹窗遮罩(§3.6---- */ /* ---- 弹窗遮罩(§3.6---- */
--ad-modal-mask: rgba(4, 7, 13, 0.7); --ad-modal-mask: rgba(4, 7, 13, 0.7);
+5
View File
@@ -13,6 +13,11 @@
--ad-space-6: 24px; --ad-space-6: 24px;
--ad-space-8: 32px; --ad-space-8: 32px;
--ad-space-10: 40px; --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 */ --ad-radius-s: 4px; /* 标签、小按钮、chip */
+1 -13
View File
@@ -31,19 +31,7 @@
/* ---- 中档:只保留单层辉光(半径减半),循环动画停用 ---- */ /* ---- 中档:只保留单层辉光(半径减半),循环动画停用 ---- */
[data-fx-tier="balanced"] .fx-multilayer-glow { [data-fx-tier="balanced"] .fx-multilayer-glow {
box-shadow: 0 0 6px rgba(0, 229, 255, 0.3); 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:仅警告/错误/授权可能过期呼吸;低档停用) */ /* 状态点呼吸(§4.5:仅警告/错误/授权可能过期呼吸;低档停用) */
+89 -31
View File
@@ -1,7 +1,8 @@
//! 目录索引与占位条目加载(Wave 0 //! 目录索引与条目加载(Wave 1
//! //!
//! 从 `adapters/catalog.yaml` 读取索引,再逐个读取 `tools/*.yaml` //! 从 `adapters/catalog.yaml` 读取索引,再逐个读取 `tools/*.yaml`对每个工具
//! 返回目录条目。仅消费 `id / name / name_zh / vendor / status` 五个字段 //! 做完整 schema 校验(含危险命令拒载、版本号校验),最后返回 UI 侧的五字段
//! `CatalogEntry` 视图(`load_catalog`)或完整 `Adapter``load_adapters`)。
use std::fs; use std::fs;
use std::path::Path; use std::path::Path;
@@ -9,6 +10,7 @@ use std::path::Path;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::error::AdapterError; use crate::error::AdapterError;
use crate::schema::{Adapter, parse_adapter};
/// 目录索引(adapters/catalog.yaml /// 目录索引(adapters/catalog.yaml
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
@@ -24,7 +26,7 @@ pub struct CatalogRef {
pub file: String, pub file: String,
} }
/// 目录条目(占位 schema,Wave 0 仅五字段;完整字段见架构 §3.1 /// 目录条目(五字段视图,供 UI 展示
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct CatalogEntry { pub struct CatalogEntry {
pub id: String, pub id: String,
@@ -36,40 +38,58 @@ pub struct CatalogEntry {
pub status: String, pub status: String,
} }
/// 加载目录索引与全部工具占位 YAML impl From<Adapter> for CatalogEntry {
pub fn load_catalog<P: AsRef<Path>>(adapters_dir: P) -> Result<Vec<CatalogEntry>, AdapterError> { fn from(a: Adapter) -> Self {
CatalogEntry {
id: a.id,
name: a.name,
name_zh: a.name_zh,
vendor: a.vendor,
status: a.status,
}
}
}
/// 读取目录索引。
fn read_index<P: AsRef<Path>>(adapters_dir: P) -> Result<Catalog, AdapterError> {
let dir = adapters_dir.as_ref(); let dir = adapters_dir.as_ref();
let catalog_text = fs::read_to_string(dir.join("catalog.yaml")) let catalog_text = fs::read_to_string(dir.join("catalog.yaml"))
.map_err(|e| AdapterError::Io(format!("读取 catalog.yaml 失败: {e}")))?; .map_err(|e| AdapterError::Io(format!("读取 catalog.yaml 失败: {e}")))?;
let catalog: Catalog = serde_yaml::from_str(&catalog_text) serde_yaml::from_str(&catalog_text)
.map_err(|e| AdapterError::Parse(format!("解析 catalog.yaml 失败: {e}")))?; .map_err(|e| AdapterError::Parse(format!("解析 catalog.yaml 失败: {e}")))
}
let mut entries = Vec::with_capacity(catalog.tools.len()); /// 加载并校验全部工具适配器(完整 schema)。
pub fn load_adapters<P: AsRef<Path>>(adapters_dir: P) -> Result<Vec<Adapter>, AdapterError> {
let dir = adapters_dir.as_ref();
let catalog = read_index(dir)?;
let mut adapters = Vec::with_capacity(catalog.tools.len());
for r in &catalog.tools { for r in &catalog.tools {
let text = fs::read_to_string(dir.join(&r.file)) let text = fs::read_to_string(dir.join(&r.file))
.map_err(|e| AdapterError::Io(format!("读取 {} 失败: {e}", r.file)))?; .map_err(|e| AdapterError::Io(format!("读取 {} 失败: {e}", r.file)))?;
let entry: CatalogEntry = serde_yaml::from_str(&text) let adapter = parse_adapter(&text)
.map_err(|e| AdapterError::Parse(format!("解析 {} 失败: {e}", r.file)))?; .map_err(|e| AdapterError::Parse(format!("{} 校验未通过: {e}", r.file)))?;
if entry.id != r.id { if adapter.id != r.id {
return Err(AdapterError::Parse(format!( return Err(AdapterError::Validation(format!(
"索引 id 与文件内 id 不一致: 索引={} 文件={}", "索引 id 与文件内 id 不一致: 索引={} 文件={}",
r.id, entry.id r.id, adapter.id
))); )));
} }
if entry.status != "available" && entry.status != "watch" { adapters.push(adapter);
return Err(AdapterError::Parse(format!(
"{} 的 status 非法: {}(应为 available | watch",
entry.id, entry.status
)));
}
entries.push(entry);
} }
Ok(entries) Ok(adapters)
}
/// 加载目录并返回 UI 五字段视图(内部已做完整 schema 校验)。
pub fn load_catalog<P: AsRef<Path>>(adapters_dir: P) -> Result<Vec<CatalogEntry>, AdapterError> {
Ok(load_adapters(adapters_dir)?.into_iter().map(CatalogEntry::from).collect())
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::error::AdapterError;
#[test] #[test]
fn parses_minimal_tool_yaml() { fn parses_minimal_tool_yaml() {
@@ -81,19 +101,11 @@ mod tests {
assert_eq!(entry.status, "available"); assert_eq!(entry.status, "available");
} }
#[test]
fn rejects_invalid_status() {
let yaml = "id: x\nname: X\nname_zh: X\nvendor: V\nstatus: unknown\n";
let entry: Result<CatalogEntry, _> = serde_yaml::from_str(yaml);
// 解析本身成功,非法 status 由 load_catalog 校验;此处确认 schema 字段可读
assert!(entry.is_ok());
}
#[test] #[test]
fn loads_real_catalog_from_repo() { fn loads_real_catalog_from_repo() {
// 以真实 adapters/ 目录做集成测试(相对本 crate 位于 ../../adapters // 以真实 adapters/ 目录做集成测试(相对本 crate 位于 ../../adapters
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../adapters"); let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../adapters");
let entries = load_catalog(&dir).expect("真实目录应可加载"); let entries = load_catalog(&dir).expect("真实目录应可加载并通过校验");
assert_eq!(entries.len(), 14, "第一批应为 14 个工具"); assert_eq!(entries.len(), 14, "第一批应为 14 个工具");
for e in &entries { for e in &entries {
assert!(!e.id.is_empty()); assert!(!e.id.is_empty());
@@ -107,4 +119,50 @@ mod tests {
assert!(ids.contains(&want), "目录应包含 {want}"); assert!(ids.contains(&want), "目录应包含 {want}");
} }
} }
/// 加载器拒载危险适配器(含 shell 元字符)并给中文错误。
#[test]
fn loader_rejects_dangerous_adapter() {
let dir = std::env::temp_dir().join(format!("agentdock-adapters-danger-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(dir.join("tools")).unwrap();
fs::write(
dir.join("catalog.yaml"),
"catalog_version: 1\ntools:\n - id: evil\n file: tools/evil.yaml\n",
)
.unwrap();
fs::write(
dir.join("tools/evil.yaml"),
"id: evil\nname: Evil\nname_zh: Evil\nvendor: X\nstatus: available\ninstall:\n channels:\n - id: official_script\n command: [\"curl\", \"x | sh\"]\n",
)
.unwrap();
match load_adapters(&dir) {
Err(AdapterError::Parse(m)) => assert!(m.contains('|'), "错误应含元字符: {m}"),
other => panic!("应因危险命令拒载,实际 {other:?}"),
}
let _ = fs::remove_dir_all(&dir);
}
/// 加载器校验版本号:非法 adapter_version 拒载。
#[test]
fn loader_rejects_bad_version() {
let dir = std::env::temp_dir().join(format!("agentdock-adapters-ver-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(dir.join("tools")).unwrap();
fs::write(
dir.join("catalog.yaml"),
"catalog_version: 1\ntools:\n - id: bad\n file: tools/bad.yaml\n",
)
.unwrap();
fs::write(
dir.join("tools/bad.yaml"),
"id: bad\nname: Bad\nname_zh: Bad\nvendor: X\nstatus: available\nadapter_version: not-semver\n",
)
.unwrap();
assert!(matches!(load_adapters(&dir), Err(AdapterError::Parse(_))));
let _ = fs::remove_dir_all(&dir);
}
} }
+56
View File
@@ -0,0 +1,56 @@
//! 危险命令检测(架构 §4.2)
//!
//! 默认禁止 shell 拼接:管道、重定向、`$()`、反引号、`&&` 链、`;`、`\`。
//! 适配器声明里的所有 `command` argv 都必须在加载期通过本检测,
//! 否则整条适配器拒载并给出中文错误。
/// shell 元字符集合(架构 §4.2`(` `)` 覆盖 `$()`,反引号覆盖命令替换)
const SHELL_METACHARS: &[char] = &['|', '&', ';', '$', '\\', '>', '<', '(', ')', '`'];
/// 返回第一个命中的 shell 元字符;无则返回 None。
pub fn first_shell_metachar(s: &str) -> Option<char> {
s.chars().find(|c| SHELL_METACHARS.contains(c))
}
/// 判断一个字符串是否含 shell 元字符。
pub fn contains_shell_metachar(s: &str) -> bool {
first_shell_metachar(s).is_some()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_common_metachars() {
for (sample, expected) in [
("curl | sh", Some('|')),
("a && b", Some('&')),
("a; rm -rf /", Some(';')),
("$(id)", Some('$')),
("echo `whoami`", Some('`')),
("ls > out", Some('>')),
("cat < in", Some('<')),
("a\\b", Some('\\')),
("echo (x)", Some('(')),
("echo )", Some(')')),
] {
assert_eq!(first_shell_metachar(sample), expected, "样本 {sample:?}");
}
}
#[test]
fn allows_plain_argv() {
for sample in [
"npm",
"install",
"-g",
"@openai/codex",
"codex",
"--version",
"https://example.com/install.ps1",
] {
assert!(!contains_shell_metachar(sample), "普通参数 {sample:?} 不应被判危险");
}
}
}
+9
View File
@@ -6,6 +6,12 @@ use std::fmt;
pub enum AdapterError { pub enum AdapterError {
Io(String), Io(String),
Parse(String), Parse(String),
/// schema / 语义校验失败(中文)
Validation(String),
/// 命令含 shell 元字符等危险输入(中文)
DangerousCommand(String),
/// 尚未实现的能力(Wave 2 起逐波落地)
NotImplemented(String),
} }
impl fmt::Display for AdapterError { impl fmt::Display for AdapterError {
@@ -13,6 +19,9 @@ impl fmt::Display for AdapterError {
match self { match self {
AdapterError::Io(m) => write!(f, "IO: {m}"), AdapterError::Io(m) => write!(f, "IO: {m}"),
AdapterError::Parse(m) => write!(f, "Parse: {m}"), AdapterError::Parse(m) => write!(f, "Parse: {m}"),
AdapterError::Validation(m) => write!(f, "校验失败: {m}"),
AdapterError::DangerousCommand(m) => write!(f, "危险命令: {m}"),
AdapterError::NotImplemented(m) => write!(f, "未实现: {m}"),
} }
} }
} }
+292
View File
@@ -0,0 +1,292 @@
//! 统一执行器接口与 dry-run(架构 §3.2
//!
//! `AdapterExecutor` 是各适配器的统一操作面;本波只落地骨架与
//! `preview_action`(返回 `DryRunPlan`),真实安装/检测/配置/授权/诊断
//! 命令的执行在 Wave 2 起由具体实现补全。所有 command 一律走 `agentdock-exec`
//! 的 argv 数组执行,禁止 shell 拼接。
use serde::{Deserialize, Serialize};
use crate::error::AdapterError;
use crate::schema::Adapter;
/// 动作类型(对应 IPC 契约 previewAction/runAction 的 action
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AdapterAction {
Install,
Update,
Uninstall,
WriteConfig,
Authorize,
Repair,
}
impl AdapterAction {
/// 动作的中文名(用于 dry-run 说明与 UI
pub fn label_zh(&self) -> &'static str {
match self {
AdapterAction::Install => "安装",
AdapterAction::Update => "更新",
AdapterAction::Uninstall => "卸载",
AdapterAction::WriteConfig => "写配置",
AdapterAction::Authorize => "授权",
AdapterAction::Repair => "修复",
}
}
}
/// 干燥运行计划:命令 argv、权限、影响文件、回滚说明(架构 §3.2 dry_run
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DryRunPlan {
/// 将执行的命令(argv 数组,禁止 shell)
pub commands: Vec<Vec<String>>,
/// 是否需要权限提升
pub elevate: bool,
/// 权限提升说明(中文),需要提升时必填
pub elevate_reason_zh: Option<String>,
/// 将影响/写入的文件路径
pub affected_files: Vec<String>,
/// 回滚说明(中文)
pub rollback_zh: String,
}
/// 统一执行器接口(架构 §3.2)
///
/// 除 `dry_run` 外,其余方法为骨架:默认返回 `NotImplemented`,由 Wave 2 起
/// 各适配器实现补全。任何实现都不得绕过 `agentdock-exec` 拼接 shell。
pub trait AdapterExecutor {
/// 返回适配器定义
fn adapter(&self) -> &Adapter;
/// 干燥运行:不真正执行,返回将执行的命令与影响面
fn dry_run(&self, action: AdapterAction) -> Result<DryRunPlan, AdapterError> {
preview_action(self.adapter(), action)
}
fn detect(&self) -> Result<(), AdapterError> {
Err(AdapterError::NotImplemented("detect 自 Wave 2 起实现".into()))
}
fn install(&self) -> Result<(), AdapterError> {
Err(AdapterError::NotImplemented("install 自 Wave 2 起实现".into()))
}
fn update(&self) -> Result<(), AdapterError> {
Err(AdapterError::NotImplemented("update 自 Wave 2 起实现".into()))
}
fn uninstall(&self) -> Result<(), AdapterError> {
Err(AdapterError::NotImplemented("uninstall 自 Wave 2 起实现".into()))
}
fn read_config(&self) -> Result<(), AdapterError> {
Err(AdapterError::NotImplemented("read_config 自 Wave 2 起实现".into()))
}
fn write_config(&self, _patch: &str) -> Result<(), AdapterError> {
Err(AdapterError::NotImplemented("write_config 自 Wave 2 起实现".into()))
}
fn authorization_status(&self) -> Result<(), AdapterError> {
Err(AdapterError::NotImplemented("authorization_status 自 Wave 2 起实现".into()))
}
fn authorize(&self, _mode: &str) -> Result<(), AdapterError> {
Err(AdapterError::NotImplemented("authorize 自 Wave 2 起实现".into()))
}
fn diagnose(&self) -> Result<(), AdapterError> {
Err(AdapterError::NotImplemented("diagnose 自 Wave 2 起实现".into()))
}
}
/// 根据适配器声明 + 动作生成干燥运行计划(不执行)。
pub fn preview_action(adapter: &Adapter, action: AdapterAction) -> Result<DryRunPlan, AdapterError> {
let mut commands: Vec<Vec<String>> = Vec::new();
let mut elevate = false;
let mut elevate_reason_zh: Option<String> = None;
let mut affected_files: Vec<String> = Vec::new();
// 影响文件:配置文件路径(写配置/授权/修复都会触碰)
if let Some(cfg) = &adapter.configuration {
for f in &cfg.files {
affected_files.push(f.path.clone());
}
}
match action {
AdapterAction::Install => {
if let Some(install) = &adapter.install {
if let Some(channel) = pick_channel(install) {
if !channel.command.is_empty() {
commands.push(channel.command.clone());
}
elevate = channel_elevates(channel);
elevate_reason_zh = channel.elevate_reason_zh.clone();
if let Some(script) = &channel.script {
if let Some(url) = &script.url {
affected_files.push(format!("下载脚本: {url}"));
}
}
}
}
}
AdapterAction::Update => {
if let Some(update) = &adapter.update {
if !update.command.is_empty() {
commands.push(update.command.clone());
}
}
}
AdapterAction::Uninstall => {
if let Some(uninstall) = &adapter.uninstall {
if !uninstall.command.is_empty() {
commands.push(uninstall.command.clone());
}
}
}
AdapterAction::Authorize => {
if let Some(auth) = &adapter.authorization {
if let Some(mode) = auth.modes.first() {
if !mode.command.is_empty() {
commands.push(mode.command.clone());
}
}
}
}
AdapterAction::WriteConfig => {
// 写配置无外部命令,仅落盘配置文件
}
AdapterAction::Repair => {
// 修复由诊断规则驱动,本波仅占位
}
}
let rollback_zh = rollback_note(adapter, action);
Ok(DryRunPlan {
commands,
elevate,
elevate_reason_zh,
affected_files,
rollback_zh,
})
}
/// 取首选渠道,无 preferred 时回退到第一个声明了命令的渠道。
fn pick_channel(install: &crate::schema::Install) -> Option<&crate::schema::Channel> {
if let Some(preferred) = &install.preferred {
if let Some(ch) = install.channels.iter().find(|c| &c.id == preferred) {
return Some(ch);
}
}
install.channels.first()
}
/// 渠道是否需要权限提升(never → false,其余 true)。
fn channel_elevates(channel: &crate::schema::Channel) -> bool {
matches!(channel.elevate.as_deref(), Some("if_needed") | Some("required"))
}
/// 生成中文回滚说明。
fn rollback_note(adapter: &Adapter, action: AdapterAction) -> String {
match action {
AdapterAction::Install | AdapterAction::Update => {
format!("如需回退,可重新运行卸载({});已保留原配置文件不动。", adapter.id)
}
AdapterAction::Uninstall => {
format!("卸载默认保留配置文件({});如需彻底移除请手动删除配置文件。", adapter.id)
}
AdapterAction::WriteConfig => {
format!("写配置前会自动备份原文件为 .bak.<时间戳>,可随时恢复。")
}
AdapterAction::Authorize => {
format!("授权信息仅写入系统密钥库,不落盘;如需撤销可删除对应密钥条目。")
}
AdapterAction::Repair => {
format!("修复动作前会生成干燥运行计划,确认后才执行。")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::schema::parse_adapter;
#[test]
fn preview_install_builds_argv_plan() {
let adapter = parse_adapter(
r#"
id: claude-code
name: Claude Code
name_zh: Claude Code
vendor: Anthropic
status: available
install:
preferred: npm
channels:
- id: npm
platforms: [windows]
command: [npm, install, -g, "@anthropic-ai/claude-code"]
elevate: never
configuration:
files:
- path: "~/.claude/settings.json"
format: json
"#,
)
.unwrap();
let plan = preview_action(&adapter, AdapterAction::Install).unwrap();
assert_eq!(plan.commands, vec![vec!["npm", "install", "-g", "@anthropic-ai/claude-code"]]);
assert!(!plan.elevate);
assert!(plan.affected_files.iter().any(|f| f.contains("settings.json")));
assert!(!plan.rollback_zh.is_empty());
}
#[test]
fn preview_elevates_when_required() {
let adapter = parse_adapter(
r#"
id: crush
name: Crush
name_zh: Crush
vendor: Charm
status: available
install:
preferred: apt
channels:
- id: apt
command: [apt, install, crush]
elevate: required
elevate_reason_zh: "需要管理员权限写入系统目录"
"#,
)
.unwrap();
let plan = preview_action(&adapter, AdapterAction::Install).unwrap();
assert!(plan.elevate);
assert_eq!(plan.elevate_reason_zh.as_deref(), Some("需要管理员权限写入系统目录"));
}
#[test]
fn action_label_zh_is_chinese() {
assert_eq!(AdapterAction::Install.label_zh(), "安装");
assert_eq!(AdapterAction::WriteConfig.label_zh(), "写配置");
}
#[test]
fn wave2_methods_return_not_implemented() {
let adapter = parse_adapter("id: x\nname: X\nname_zh: X\nvendor: V\nstatus: available\n").unwrap();
struct NoopExecutor(Adapter);
impl AdapterExecutor for NoopExecutor {
fn adapter(&self) -> &Adapter {
&self.0
}
}
let ex = NoopExecutor(adapter);
assert!(matches!(ex.detect(), Err(AdapterError::NotImplemented(_))));
assert!(matches!(ex.install(), Err(AdapterError::NotImplemented(_))));
}
}
+9 -3
View File
@@ -1,11 +1,17 @@
//! agentdock-adapter —— 适配器层 //! agentdock-adapter —— 适配器层
//! //!
//! 负责适配器 schema 加载、版本校验、dry-run 与执行器接口(架构 §3)。 //! 负责适配器 schema 加载、版本校验、dry-run 与执行器接口(架构 §3)。
//! Wave 0:仅落地目录索引与占位条目加载(`catalog` 模块); //! Wave 1:完整 schema 定义与校验(含危险命令拒载)、统一执行器骨架、
//! 完整 schema 校验、执行器接口随 Wave 1 实现 //! dry-run 计划(`preview_action`)。真实安装/检测/配置命令 Wave 2 起落地
pub mod catalog; pub mod catalog;
pub mod danger;
pub mod error; pub mod error;
pub mod executor;
pub mod schema;
pub use catalog::{Catalog, CatalogEntry, CatalogRef, load_catalog}; pub use catalog::{Catalog, CatalogEntry, CatalogRef, load_adapters, load_catalog};
pub use danger::{contains_shell_metachar, first_shell_metachar};
pub use error::AdapterError; pub use error::AdapterError;
pub use executor::{AdapterAction, AdapterExecutor, DryRunPlan, preview_action};
pub use schema::{Adapter, parse_adapter};
+586
View File
@@ -0,0 +1,586 @@
//! 适配器完整 schema(对齐架构 §3.1)
//!
//! 与 `adapters/schema/adapter.schema.json` 同源:JSON Schema 是声明式契约,
//! 本文件用 Rust 强类型结构体做运行时校验(deny_unknown_fields + 语义校验),
//! 两者字段一一对应。Wave 1 的 14 个占位 YAML 仅填五字段,其余字段均可选。
use serde::{Deserialize, Serialize};
use crate::danger::first_shell_metachar;
use crate::error::AdapterError;
/// 适配器定义(完整字段,§3.1)
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Adapter {
pub id: String,
pub name: String,
#[serde(rename = "name_zh")]
pub name_zh: String,
pub vendor: String,
/// available | watch
pub status: String,
/// semver,如 1.2.0
#[serde(rename = "adapter_version", default)]
pub adapter_version: Option<String>,
#[serde(default)]
pub license: Option<String>,
#[serde(default)]
pub platforms: Option<Platforms>,
#[serde(default)]
pub official: Option<Official>,
#[serde(rename = "runtime_deps", default)]
pub runtime_deps: Vec<RuntimeDep>,
#[serde(default)]
pub install: Option<Install>,
#[serde(default)]
pub detect: Option<Detect>,
#[serde(default)]
pub update: Option<Update>,
#[serde(default)]
pub uninstall: Option<Uninstall>,
#[serde(default)]
pub authorization: Option<Authorization>,
#[serde(default)]
pub configuration: Option<Configuration>,
#[serde(default)]
pub diagnostics: Vec<Diagnostic>,
#[serde(default)]
pub documentation: Option<Documentation>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Platforms {
#[serde(default)]
pub windows: Option<PlatformWindows>,
#[serde(default)]
pub linux: Option<PlatformLinux>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct PlatformWindows {
#[serde(default)]
pub architectures: Vec<String>,
#[serde(default)]
pub notes: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct PlatformLinux {
#[serde(default)]
pub distributions: Vec<String>,
#[serde(default)]
pub architectures: Vec<String>,
#[serde(rename = "min_ubuntu", default)]
pub min_ubuntu: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Official {
#[serde(default)]
pub homepage: Option<String>,
#[serde(default)]
pub docs: Option<String>,
#[serde(rename = "allowed_hosts", default)]
pub allowed_hosts: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct RuntimeDep {
pub id: String,
#[serde(rename = "semver_range", default)]
pub semver_range: Option<String>,
#[serde(rename = "required_for", default)]
pub required_for: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Install {
#[serde(default)]
pub preferred: Option<String>,
#[serde(default)]
pub channels: Vec<Channel>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Channel {
pub id: String,
#[serde(default)]
pub platforms: Vec<String>,
#[serde(default)]
pub command: Vec<String>,
#[serde(default)]
pub script: Option<Script>,
#[serde(default)]
pub package: Option<String>,
#[serde(default)]
pub elevate: Option<String>,
#[serde(rename = "elevate_reason_zh", default)]
pub elevate_reason_zh: Option<String>,
#[serde(rename = "post_checks", default)]
pub post_checks: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Script {
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub kind: Option<String>,
#[serde(default)]
pub integrity: Option<Integrity>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Integrity {
#[serde(default)]
pub sha256: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Detect {
pub executable: String,
#[serde(rename = "version_args", default)]
pub version_args: Vec<String>,
#[serde(rename = "version_regex", default)]
pub version_regex: Option<String>,
#[serde(rename = "version_unconfirmed", default)]
pub version_unconfirmed: Option<bool>,
#[serde(rename = "path_hints", default)]
pub path_hints: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Update {
#[serde(default)]
pub method: Option<String>,
#[serde(default)]
pub command: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Uninstall {
#[serde(default)]
pub method: Option<String>,
#[serde(default)]
pub command: Vec<String>,
#[serde(rename = "keep_config_default", default = "default_true")]
pub keep_config_default: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Authorization {
#[serde(default)]
pub modes: Vec<AuthMode>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct AuthMode {
pub mode: String,
#[serde(default)]
pub command: Vec<String>,
#[serde(rename = "env_keys", default)]
pub env_keys: Vec<String>,
#[serde(rename = "status_command", default)]
pub status_command: Vec<String>,
#[serde(rename = "notes_zh", default)]
pub notes_zh: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Configuration {
#[serde(default)]
pub files: Vec<ConfigFile>,
#[serde(default)]
pub environment: Vec<EnvMapping>,
#[serde(default)]
pub fields: Vec<ConfigField>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct ConfigFile {
pub path: String,
pub format: String,
#[serde(default)]
pub scope: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct EnvMapping {
pub key: String,
#[serde(default)]
pub sensitive: bool,
#[serde(rename = "maps_to_field", default)]
pub maps_to_field: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct ConfigField {
pub id: String,
#[serde(rename = "label_zh")]
pub label_zh: String,
#[serde(rename = "help_zh", default)]
pub help_zh: Option<String>,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub sensitive: bool,
/// string | url | enum | bool
#[serde(rename = "type")]
pub field_type: String,
/// file | env | keyring
pub storage: String,
#[serde(default)]
pub platforms: Vec<String>,
#[serde(rename = "docs_url", default)]
pub docs_url: Option<String>,
/// enum 类型的可选值(value + 中文名)
#[serde(default)]
pub options: Vec<FieldOption>,
/// 表单分组:auth(授权/登录)| common(常用配置)| advanced(高级配置,默认折叠)
/// 未声明时按 common 处理
#[serde(default)]
pub group: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct FieldOption {
pub value: String,
#[serde(rename = "label_zh")]
pub label_zh: String,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Diagnostic {
#[serde(rename = "rule_id")]
pub rule_id: String,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Documentation {
#[serde(rename = "quickstart_zh", default)]
pub quickstart_zh: Option<String>,
#[serde(rename = "install_zh", default)]
pub install_zh: Option<String>,
#[serde(rename = "auth_zh", default)]
pub auth_zh: Option<String>,
#[serde(default)]
pub commands: Vec<DocCommand>,
#[serde(default)]
pub params: Vec<DocParam>,
#[serde(rename = "updated_at", default)]
pub updated_at: Option<String>,
#[serde(rename = "risks_zh", default)]
pub risks_zh: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct DocCommand {
pub cmd: String,
#[serde(rename = "desc_zh", default)]
pub desc_zh: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct DocParam {
pub param: String,
#[serde(rename = "desc_zh", default)]
pub desc_zh: Option<String>,
}
fn default_true() -> bool {
true
}
/// 校验 id 是否符合稳定 ID 约定:`^[a-z0-9][a-z0-9-]*$`
pub fn is_valid_id(id: &str) -> bool {
let mut chars = id.chars();
match chars.next() {
Some(c) if c.is_ascii_lowercase() || c.is_ascii_digit() => {}
_ => return false,
}
chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
/// 校验 semver 形式(x.y.z,可选 -prerelease / +build),不引入 semver 依赖。
pub fn is_valid_semver(s: &str) -> bool {
let s = s.trim();
if s.is_empty() {
return false;
}
// 拆分 build 元数据(+...
let (no_build, build) = match s.split_once('+') {
Some((a, b)) => (a, Some(b)),
None => (s, None),
};
if let Some(b) = build {
if !is_semver_ident(b) {
return false;
}
}
// 拆分预发布(-...
let (core, pre) = match no_build.split_once('-') {
Some((a, b)) => (a, Some(b)),
None => (no_build, None),
};
let parts: Vec<&str> = core.split('.').collect();
if parts.len() != 3 {
return false;
}
for p in &parts {
if p.is_empty() || !p.chars().all(|c| c.is_ascii_digit()) {
return false;
}
}
if let Some(pre) = pre {
if !is_semver_ident(pre) {
return false;
}
}
true
}
/// semver 标识段(预发布/构建元数据):由字母数字与 `-` 组成,点分隔各段非空。
fn is_semver_ident(s: &str) -> bool {
if s.is_empty() {
return false;
}
s.split('.').all(|seg| !seg.is_empty() && seg.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'))
}
/// 从 YAML 文本解析并校验适配器(解析失败 → Parse,语义/危险命令 → 对应错误)。
pub fn parse_adapter(yaml: &str) -> Result<Adapter, AdapterError> {
let adapter: Adapter = serde_yaml::from_str(yaml).map_err(|e| AdapterError::Parse(e.to_string()))?;
adapter.validate()?;
Ok(adapter)
}
impl Adapter {
/// 语义校验:id / status / 版本号 / 危险命令。
/// 校验通过返回 Ok(()),否则返回带中文说明的错误。
pub fn validate(&self) -> Result<(), AdapterError> {
if self.id.is_empty() {
return Err(AdapterError::Validation("id 不能为空".into()));
}
if !is_valid_id(&self.id) {
return Err(AdapterError::Validation(format!(
"id「{}」非法:只能由小写字母、数字、连字符组成,且以字母或数字开头",
self.id
)));
}
if self.status != "available" && self.status != "watch" {
return Err(AdapterError::Validation(format!(
"{}」的 status 非法: {}(应为 available | watch",
self.id, self.status
)));
}
if let Some(v) = &self.adapter_version {
if !is_valid_semver(v) {
return Err(AdapterError::Validation(format!(
"{}」的 adapter_version 非法: {}(应为 semver,如 1.2.0",
self.id, v
)));
}
}
// 危险命令检查:所有 command argv 必须不含 shell 元字符
if let Some(offender) = self.find_dangerous_command() {
return Err(AdapterError::DangerousCommand(format!(
"适配器「{}」声明了含 shell 元字符的命令参数 {:?},已拒绝加载(禁止管道/重定向/命令替换等)",
self.id, offender
)));
}
Ok(())
}
/// 遍历所有 command 数组,返回首个含 shell 元字符的参数。
pub fn find_dangerous_command(&self) -> Option<String> {
let mut commands: Vec<&Vec<String>> = Vec::new();
if let Some(install) = &self.install {
for ch in &install.channels {
commands.push(&ch.command);
}
}
if let Some(update) = &self.update {
commands.push(&update.command);
}
if let Some(uninstall) = &self.uninstall {
commands.push(&uninstall.command);
}
if let Some(auth) = &self.authorization {
for m in &auth.modes {
commands.push(&m.command);
commands.push(&m.status_command);
}
}
for argv in commands {
for arg in argv {
if let Some(c) = first_shell_metachar(arg) {
return Some(format!("{arg}(元字符 {c:?}"));
}
}
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(yaml: &str) -> Result<Adapter, AdapterError> {
let adapter: Adapter = serde_yaml::from_str(yaml).map_err(|e| AdapterError::Parse(e.to_string()))?;
adapter.validate()?;
Ok(adapter)
}
// ---- 合法 fixture(≥3 ----
#[test]
fn valid_minimal_five_fields() {
let yaml = "id: codex\nname: Codex CLI\nname_zh: Codex CLI\nvendor: OpenAI\nstatus: available\n";
assert!(parse(yaml).is_ok());
}
#[test]
fn valid_full_schema() {
let yaml = r#"
id: claude-code
name: Claude Code
name_zh: Claude Code
vendor: Anthropic
status: available
adapter_version: 1.2.0
license: 专有(仅官方渠道安装、不重打包)
platforms:
windows:
architectures: [x64]
linux:
distributions: [ubuntu]
architectures: [x64]
min_ubuntu: "22.04"
official:
homepage: https://claude.ai
docs: https://docs.anthropic.com
allowed_hosts: [claude.ai]
runtime_deps:
- id: node
semver_range: ">=20"
required_for: [install]
install:
preferred: npm
channels:
- id: npm
platforms: [windows, linux]
command: [npm, install, -g, "@anthropic-ai/claude-code"]
elevate: never
detect:
executable: claude
version_args: ["--version"]
version_regex: "^v?(\\d+\\.\\d+\\.\\d+)"
authorization:
modes:
- mode: browser_oauth
notes_zh: 浏览器登录
configuration:
files:
- path: "~/.claude/settings.json"
format: json
scope: user
"#;
let adapter = parse(yaml).expect("全字段 fixture 应合法");
assert_eq!(adapter.adapter_version.as_deref(), Some("1.2.0"));
assert_eq!(adapter.install.as_ref().unwrap().channels.len(), 1);
}
#[test]
fn valid_pre_release_semver() {
let yaml = "id: warp\nname: Warp Agent CLI\nname_zh: Warp Agent CLI\nvendor: Warp\nstatus: available\nadapter_version: 2.0.0-rc.1\n";
assert!(parse(yaml).is_ok());
}
// ---- 非法 fixture(≥3 ----
#[test]
fn invalid_unknown_field_rejected() {
let yaml = "id: x\nname: X\nname_zh: X\nvendor: V\nstatus: available\nbogus_field: 1\n";
let adapter: Result<Adapter, _> = serde_yaml::from_str(yaml);
assert!(adapter.is_err(), "未知字段应被 deny_unknown_fields 拒绝");
}
#[test]
fn invalid_id_pattern_rejected() {
let yaml = "id: Bad_ID!\nname: X\nname_zh: X\nvendor: V\nstatus: available\n";
match parse(yaml) {
Err(AdapterError::Validation(m)) => assert!(m.contains("id"), "错误应指向 id: {m}"),
other => panic!("应返回 Validation 错误,实际 {other:?}"),
}
}
#[test]
fn invalid_status_rejected() {
let yaml = "id: x\nname: X\nname_zh: X\nvendor: V\nstatus: unknown\n";
assert!(matches!(parse(yaml), Err(AdapterError::Validation(_))));
}
#[test]
fn invalid_adapter_version_rejected() {
let yaml = "id: x\nname: X\nname_zh: X\nvendor: V\nstatus: available\nadapter_version: not-a-version\n";
assert!(matches!(parse(yaml), Err(AdapterError::Validation(_))));
}
#[test]
fn dangerous_command_rejected_with_chinese_error() {
let yaml = r#"
id: x
name: X
name_zh: X
vendor: V
status: available
install:
channels:
- id: official_script
command: ["curl", "https://x.sh", "|", "sh"]
"#;
match parse(yaml) {
Err(AdapterError::DangerousCommand(m)) => {
assert!(m.contains('|'), "错误信息应包含元字符: {m}");
assert!(m.contains("已拒绝"), "错误应为中文且明确拒载: {m}");
}
other => panic!("应返回 DangerousCommand,实际 {other:?}"),
}
}
#[test]
fn semver_helper() {
assert!(is_valid_semver("1.2.0"));
assert!(is_valid_semver("0.0.1"));
assert!(is_valid_semver("10.20.30-alpha.1+build5"));
assert!(!is_valid_semver("1.2"));
assert!(!is_valid_semver("1.2.x"));
assert!(!is_valid_semver(""));
assert!(!is_valid_semver("v1.2.3"));
}
}
+4
View File
@@ -3,5 +3,9 @@ name = "agentdock-config"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
description = "配置读写:多格式编解码(TOML/JSON)+ 原子写入 + 自动备份(架构 §6)"
[dependencies] [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) //! agentdock-config —— 配置读写层(架构 §6)
//! //!
//! 职责:原子写入 + 自动备份、多格式统一编解码(ConfigCodec trait //! 职责:多格式编解码(`codec`)、原子写入 + 自动备份(`atomic`)、
//! toml / json / jsonc / yaml / env / crushrc)。 //! 配置文件路径解析(`~` 与平台变量展开)。
//! Wave 0:空骨架,随 Wave 1/2 落地。
/// 配置层能力标记(占位) pub mod atomic;
pub const LAYER: &str = "agentdock-config"; 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test] #[test]
fn layer_identity() { fn expand_home_prefix() {
assert_eq!(LAYER, "agentdock-config"); 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 version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
description = "编排层:发现/安装/配置/授权/诊断流水线(架构 §2 核心编排)"
[dependencies] [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())
}
}
+149
View File
@@ -0,0 +1,149 @@
//! 执行失败的错误人话化映射(Wave 2.2 Req 1
//!
//! 把「program not found / 网络超时 / 权限不足 / 磁盘不足」等原始错误
//! 映射为中文建议 + 结构化 code + 可直达安装的缺失运行时 id。
//! 原始报错保留在 `raw`(界面折叠展示),`friendly_zh` 默认展示。
use std::io::ErrorKind;
use crate::types::ErrorHint;
/// 由可执行文件名反推对应的运行时 id(用于「未找到 npm → 去装 Node.js」联动)。
pub fn runtime_for_prog(prog: &str) -> Option<&'static str> {
match prog.to_ascii_lowercase().as_str() {
"npm" | "node" | "node.exe" => Some("node"),
"python" | "python3" | "py" | "pip" => Some("python"),
"git" => Some("git"),
"winget" => Some("winget"),
"uv" => Some("uv"),
"choco" => Some("choco"),
"scoop" => Some("scoop"),
_ => None,
}
}
/// 主入口:结合 spawn 错误与 stderr 尾部文本,产出结构化错误提示。
/// `spawn_err` 为 `Command::spawn` 的 io::ErrorNone 表示进程已启动但退出非 0)。
pub fn map_exec_error(prog: &str, spawn_err: Option<&std::io::Error>, stderr_tail: &str) -> ErrorHint {
let raw = spawn_err.map(|e| e.to_string()).unwrap_or_default();
// 1. 命令不存在(最常见:未安装 / 不在 PATH)
if let Some(e) = spawn_err {
match e.kind() {
ErrorKind::NotFound => {
let missing = runtime_for_prog(prog);
let friendly = match missing {
Some(r) => format!("未找到 {prog} 命令:需要先安装 {r}(可在下方「本机环境」区一键安装)"),
None => format!("未找到 {prog} 命令:请确认 {prog} 已安装并加入 PATH"),
};
return ErrorHint {
code: "program_not_found".into(),
friendly_zh: friendly,
raw,
missing_runtime: missing.map(|s| s.to_string()),
};
}
ErrorKind::PermissionDenied => {
return ErrorHint {
code: "permission_denied".into(),
friendly_zh: "权限不足:需要管理员权限,或目标目录不可写。".into(),
raw,
missing_runtime: None,
};
}
_ => {}
}
}
// 2. 网络 / 磁盘等常见错误(从 stderr 尾部判断)
let lower = stderr_tail.to_lowercase();
let network_pats: &[(&str, &str, &str)] = &[
("timed out", "network_timeout", "网络连接超时:请检查网络或代理设置后重试。"),
("etimedout", "network_timeout", "网络连接超时:请检查网络或代理设置后重试。"),
("econnrefused", "network_timeout", "连接被拒绝:目标服务不可达,请稍后重试。"),
("getaddrinfo", "network_dns", "域名解析失败:请检查网络与 DNS 设置。"),
("enotfound", "network_dns", "域名解析失败:请检查网络与 DNS 设置。"),
("certificate", "network_tls", "证书校验失败:网络可能被代理/防火墙干扰。"),
("enospc", "disk_full", "磁盘空间不足:请清理磁盘后重试。"),
("no space left", "disk_full", "磁盘空间不足:请清理磁盘后重试。"),
("eacces", "permission_denied", "权限不足:需要管理员权限,或目标目录不可写。"),
("eperm", "permission_denied", "权限不足:需要管理员权限,或目标目录不可写。"),
];
for (pat, code, msg) in network_pats {
if lower.contains(pat) {
return ErrorHint {
code: (*code).into(),
friendly_zh: (*msg).into(),
raw: raw.clone(),
missing_runtime: None,
};
}
}
// 3. 兜底:通用失败
ErrorHint {
code: "exec_failed".into(),
friendly_zh: "命令执行失败,详见上方原始输出。".into(),
raw: if raw.is_empty() { stderr_tail.to_string() } else { raw },
missing_runtime: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn not_found() -> std::io::Error {
std::io::Error::new(ErrorKind::NotFound, "program not found")
}
#[test]
fn maps_program_not_found_to_node_for_npm() {
let h = map_exec_error("npm", Some(&not_found()), "");
assert_eq!(h.code, "program_not_found");
assert_eq!(h.missing_runtime.as_deref(), Some("node"));
assert!(h.friendly_zh.contains("Node") || h.friendly_zh.contains("node"));
}
#[test]
fn maps_program_not_found_for_plain_cli() {
let h = map_exec_error("gemini", Some(&not_found()), "");
assert_eq!(h.code, "program_not_found");
assert_eq!(h.missing_runtime, None);
assert!(h.friendly_zh.contains("gemini"));
}
#[test]
fn maps_permission_denied() {
let e = std::io::Error::new(ErrorKind::PermissionDenied, "access denied");
let h = map_exec_error("npm", Some(&e), "");
assert_eq!(h.code, "permission_denied");
}
#[test]
fn maps_network_timeout_from_stderr() {
let h = map_exec_error("npm", None, "npm ERR! code ETIMEDOUT");
assert_eq!(h.code, "network_timeout");
assert!(h.friendly_zh.contains("网络"));
}
#[test]
fn maps_disk_full_from_stderr() {
let h = map_exec_error("npm", None, "ENOSPC: no space left on device");
assert_eq!(h.code, "disk_full");
}
#[test]
fn fallback_is_generic() {
let h = map_exec_error("npm", None, "something weird happened");
assert_eq!(h.code, "exec_failed");
}
#[test]
fn runtime_lookup() {
assert_eq!(runtime_for_prog("npm"), Some("node"));
assert_eq!(runtime_for_prog("python"), Some("python"));
assert_eq!(runtime_for_prog("git"), Some("git"));
assert_eq!(runtime_for_prog("opencode"), None);
}
}
+23 -13
View File
@@ -1,17 +1,27 @@
//! agentdock-core —— 编排层(架构 §2 模块关系:UI --invoke--> commands --→ core orchestrator //! agentdock-core —— 编排层(架构 §2 模块关系:UI --invoke--> commands --→ core orchestrator
//! //!
//! 职责:发现 / 安装 / 配置 / 授权 / 诊断 流水线编排。 //! 把 adapter/config/secrets/exec/diag/platform 串成完整流水线:
//! Wave 0:空骨架,流水线自 Wave 1 起逐波落地。 //! detectCli / previewAction / runAction / readConfig / writeConfig /
//! authStatus / authorize / diagnose。
/// 编排层能力标记(占位) pub mod engine;
pub const LAYER: &str = "agentdock-core"; pub mod error;
pub mod errors_zh;
pub mod process;
pub mod runtime_install;
pub mod types;
#[cfg(test)] pub use engine::{
mod tests { ActionOpts, Engine, auth_mode_label_zh, auth_status, detect_one, diagnose, parse_device_code,
use super::*; parse_version, read_config, run_action, strip_ansi, verify_config, write_config,
};
#[test] pub use error::EngineError;
fn layer_identity() { pub use errors_zh::{map_exec_error, runtime_for_prog};
assert_eq!(LAYER, "agentdock-core"); pub use process::{open_with_shell, resolve_exe, run_capture, run_streaming, run_terminal, run_with_stdin, which_all, RunningProcess};
} pub use runtime_install::{
} download_with_curl, is_url_host_allowed, source_for, supported_runtimes, RuntimeSource,
};
pub use types::{
ActionEvent, AuthFlowEvent, AuthStatus, AuthModeInfo, ConfigFieldState, ConfigFileState, ConfigFormState,
DetectResult, EnvFieldState, ErrorHint, WriteResult, ConfigVerifyResult, now_secs,
};
+329
View File
@@ -0,0 +1,329 @@
//! 进程探测与流式执行辅助(内部)
//!
//! 所有命令经 `agentdock-exec::validate_argv` 校验后执行,禁止 shell 拼接。
//! 探测类命令(`--version`)隐藏控制台窗口(Windows CREATE_NO_WINDOW)。
//! 流式执行(`run_streaming` / `run_streaming_cancellable`)用两条读取线程
//! **并发**消费 stdout 与 stderr,再经 mpsc 通道按到达顺序回调——保证实时滚动,
//! 避免「先读 stdout 再读 stderr」造成的 stderr 延迟到进程结束时才出现的旧行为。
use std::io::BufRead;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, mpsc};
/// 按平台分隔符拆分 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
}
/// 在 PATH 中解析可执行文件为可运行路径(找不到时回退原 prog,让 spawn 报错)。
pub fn resolve_exe(program: &str) -> String {
which_all(program)
.first()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| program.to_string())
}
/// 运行只读探测命令并返回 (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))
}
/// 运行命令并把一段文本写入其 stdin(API Key 经 stdin 注入官方登录命令用),
/// 返回 (exit_ok, stdout+stderr 合并文本)。输入内容绝不明文落日志。
pub fn run_with_stdin(exe: &Path, args: &[String], stdin_text: &str) -> std::io::Result<(bool, String)> {
let mut cmd = Command::new(exe);
cmd.args(args);
cmd.stdin(Stdio::piped());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
}
let mut child = cmd.spawn()?;
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(stdin_text.as_bytes());
// 关闭 stdin,通知子进程输入结束
}
let out = child.wait_with_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))
}
/// 可取消的进程句柄:`cancel` 置位后流式读取循环会终止子进程;
/// 授权等交互式流程需要取消时调用 `request_cancel`。
#[derive(Default)]
pub struct RunningProcess {
cancel: Arc<AtomicBool>,
child: Arc<Mutex<Option<Child>>>,
}
impl RunningProcess {
pub fn new() -> Self {
RunningProcess::default()
}
/// 请求取消(安全幂等)。
pub fn request_cancel(&self) {
self.cancel.store(true, Ordering::SeqCst);
}
/// 取取消标志的共享句柄(供其它线程定时/条件取消)。
pub fn cancel_flag(&self) -> Arc<AtomicBool> {
self.cancel.clone()
}
pub fn is_cancelled(&self) -> bool {
self.cancel.load(Ordering::SeqCst)
}
fn kill(&self) {
if let Some(mut child) = self.child.lock().unwrap().take() {
kill_child_tree(&mut child);
}
}
}
/// 流式执行命令,逐行回调(stdout 与 stderr 并发、按到达顺序实时输出)。
/// 返回是否成功。
pub fn run_streaming<F>(exe: &str, args: &[String], on_line: F) -> std::io::Result<bool>
where
F: FnMut(bool, &str),
{
let rp = RunningProcess::new();
run_streaming_cancellable(&rp, exe, args, on_line)
}
/// 可取消的流式执行:stdout/stderr 并发读取,主线程按行回调;
/// 取消置位时终止子进程并结束。
pub fn run_streaming_cancellable<F>(
rp: &RunningProcess,
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()?;
let stdout = child.stdout.take();
let stderr = child.stderr.take();
*rp.child.lock().unwrap() = Some(child);
let (tx, rx) = mpsc::channel::<(bool, String)>();
// stdout 读取线程
if let Some(out) = stdout {
let tx = tx.clone();
std::thread::spawn(move || {
let reader = std::io::BufReader::new(out);
for line in reader.lines().map_while(Result::ok) {
if tx.send((false, line)).is_err() {
break;
}
}
});
}
// stderr 读取线程
if let Some(err) = stderr {
let tx = tx.clone();
std::thread::spawn(move || {
let reader = std::io::BufReader::new(err);
for line in reader.lines().map_while(Result::ok) {
if tx.send((true, line)).is_err() {
break;
}
}
});
}
drop(tx); // 主线程持有的发送端关闭,两线程结束后通道自然关闭
// 主线程按到达顺序实时回调(recv_timeout 兜底:静默期也能响应取消)
loop {
match rx.recv_timeout(std::time::Duration::from_millis(200)) {
Ok((is_err, line)) => {
on_line(is_err, &line);
if rp.is_cancelled() {
rp.kill();
break;
}
}
Err(mpsc::RecvTimeoutError::Timeout) => {
if rp.is_cancelled() {
rp.kill();
break;
}
}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
}
// 等待子进程退出并取状态
let status = rp
.child
.lock()
.unwrap()
.take()
.and_then(|mut c| c.wait().ok())
.map(|s| s.success())
.unwrap_or(false);
Ok(status)
}
/// 终止子进程(Windows 用 taskkill /T 杀掉整棵进程树,避免 .cmd 包装的 node 等子进程变孤儿)。
fn kill_child_tree(child: &mut Child) {
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
let pid = child.id();
let _ = Command::new("taskkill")
.args(["/PID", &pid.to_string(), "/T", "/F"])
.creation_flags(0x0800_0000)
.status();
let _ = child.kill();
let _ = child.wait();
}
#[cfg(not(windows))]
{
let _ = child.kill();
let _ = child.wait();
}
}
/// 在**独立控制台窗口**中运行命令(一次性本机终端,PRD FR-06 设计),
/// 结束后窗口自动关闭。仅回传退出成功与否,不回传输出。
/// 用于必须在终端里交互的登录流程(local_tui 等)。
#[cfg(windows)]
pub fn run_terminal(exe: &str, args: &[String]) -> std::io::Result<bool> {
use std::os::windows::process::CommandExt;
let mut cmd = Command::new(exe);
cmd.args(args);
cmd.creation_flags(0x0000_0010); // CREATE_NEW_CONSOLE
let status = cmd.status()?;
Ok(status.success())
}
/// 非 Windows 平台:终端窗口回退为普通前台执行。
#[cfg(not(windows))]
pub fn run_terminal(exe: &str, args: &[String]) -> std::io::Result<bool> {
let status = Command::new(exe).args(args).status()?;
Ok(status.success())
}
/// 打开本机默认程序/浏览器(文件用默认处理器打开、URL 用默认浏览器打开)。
/// 仅接受已通过来源白名单校验的目标,不做 shell 拼接。
#[cfg(windows)]
pub fn open_with_shell(target: &str) -> std::io::Result<()> {
// explorer.exe 同时能打开文件(默认处理器)与 URL(默认浏览器)
let status = Command::new("explorer.exe").arg(target).status()?;
if status.success() {
Ok(())
} else {
Err(std::io::Error::new(
std::io::ErrorKind::Other,
"explorer 打开目标失败",
))
}
}
#[cfg(not(windows))]
pub fn open_with_shell(target: &str) -> std::io::Result<()> {
let status = Command::new("xdg-open").arg(target).status()?;
if status.success() {
Ok(())
} else {
Err(std::io::Error::new(std::io::ErrorKind::Other, "xdg-open 打开目标失败"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn run_streaming_reports_stdout_in_order() {
// 用 echo 输出多行,验证 stdout 被逐行实时捕获
#[cfg(windows)]
let (exe, args) = (
"cmd.exe".to_string(),
vec!["/C".to_string(), "echo line1 & echo line2 & echo line3".to_string()],
);
#[cfg(not(windows))]
let (exe, args) = ("sh".to_string(), vec!["-c".to_string(), "echo line1; echo line2; echo line3".to_string()]);
let mut lines = Vec::new();
let ok = run_streaming(&exe, &args, |is_err, l| {
if !is_err {
lines.push(l.to_string());
}
})
.unwrap();
assert!(ok);
assert!(lines.iter().any(|l| l.contains("line1")));
assert!(lines.iter().any(|l| l.contains("line3")));
}
#[test]
fn running_process_cancel_is_idempotent() {
let rp = RunningProcess::new();
assert!(!rp.is_cancelled());
rp.request_cancel();
assert!(rp.is_cancelled());
rp.request_cancel();
assert!(rp.is_cancelled());
}
}
@@ -0,0 +1,205 @@
//! 本机环境运行时的一键安装来源表(Wave 2.2 Req 3
//!
//! 仅收录官方渠道,所有下载 URL 都经 `allowed_hosts` 白名单校验(架构 §3.1 安全红线)。
//! 只做「下载官方安装包 → 打开安装向导」,不静默安装;应用本身不提权。
//! 下载失败或来源不可直接安装时,兜底提供「打开官方下载页」。
use serde::{Deserialize, Serialize};
/// 单个运行时的官方安装来源。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuntimeSource {
/// 运行时 idnode / python / git / winget / uv
pub runtime: String,
/// 中文名(Node.js / Python / Git / winget / uv
pub label_zh: String,
/// 官方直接安装包/脚本地址(可被直接下载)
pub download_url: Option<String>,
/// 官方下载页(兜底打开)
pub download_page: String,
/// 大概体积(人话)
pub size_approx: String,
/// 是否需要管理员权限
pub elevate_needed: bool,
/// 允许的下载主机(白名单)
pub allowed_hosts: Vec<String>,
/// 是否可直接下载安装包(false 则只提供「打开官方下载页」)
pub direct_installable: bool,
/// 来源说明(展示给用户)
pub source_label: String,
}
/// 内置运行时来源表(仅官方渠道)。
fn table() -> Vec<RuntimeSource> {
vec![
RuntimeSource {
runtime: "node".into(),
label_zh: "Node.js".into(),
download_url: Some("https://nodejs.org/dist/v22.14.0/node-v22.14.0-x64.msi".into()),
download_page: "https://nodejs.org/en/download".into(),
size_approx: "约 31 MB".into(),
elevate_needed: true,
allowed_hosts: vec!["nodejs.org".into()],
direct_installable: true,
source_label: "Node.js 官方(nodejs.org".into(),
},
RuntimeSource {
runtime: "python".into(),
label_zh: "Python".into(),
download_url: Some("https://www.python.org/ftp/python/3.12.10/python-3.12.10-amd64.exe".into()),
download_page: "https://www.python.org/downloads/".into(),
size_approx: "约 27 MB".into(),
elevate_needed: false,
allowed_hosts: vec!["python.org".into(), "www.python.org".into()],
direct_installable: true,
source_label: "Python 官方(python.org".into(),
},
RuntimeSource {
runtime: "git".into(),
label_zh: "Git".into(),
download_url: Some(
"https://github.com/git-for-windows/git/releases/download/v2.47.1.windows.2/Git-2.47.1.2-64-bit.exe".into(),
),
download_page: "https://git-scm.com/download/win".into(),
size_approx: "约 64 MB".into(),
elevate_needed: false,
allowed_hosts: vec!["github.com".into(), "git-scm.com".into()],
direct_installable: true,
source_label: "Git for Windows 官方(git-scm.com".into(),
},
RuntimeSource {
runtime: "winget".into(),
label_zh: "winget".into(),
download_url: Some("https://aka.ms/getwinget".into()),
download_page: "https://learn.microsoft.com/windows/package-manager/winget/".into(),
size_approx: "约 60 MBApp 安装程序)".into(),
elevate_needed: false,
allowed_hosts: vec!["aka.ms".into(), "microsoft.com".into(), "learn.microsoft.com".into()],
direct_installable: true,
source_label: "微软官方(aka.ms/getwinget".into(),
},
RuntimeSource {
runtime: "uv".into(),
label_zh: "uv".into(),
download_url: None,
download_page: "https://docs.astral.sh/uv/getting-started/installation/".into(),
size_approx: "约 15 MB".into(),
elevate_needed: false,
allowed_hosts: vec!["astral.sh".into(), "docs.astral.sh".into()],
direct_installable: false,
source_label: "uv 官方(docs.astral.sh".into(),
},
]
}
/// 按 id 取运行时来源。
pub fn source_for(runtime: &str) -> Option<RuntimeSource> {
table().into_iter().find(|s| s.runtime == runtime)
}
/// 全部受支持的运行时 id(前端「可一键安装」判定用)。
pub fn supported_runtimes() -> Vec<String> {
table().into_iter().map(|s| s.runtime).collect()
}
/// 判断 URL 的主机是否在白名单内(安全红线:所有下载仅限白名单)。
pub fn is_url_host_allowed(url: &str, allowed_hosts: &[String]) -> bool {
let host = extract_host(url);
match host {
Some(h) => allowed_hosts.iter().any(|a| h == *a || h.ends_with(&format!(".{a}"))),
None => false,
}
}
/// 用系统 `curl.exe` 下载 URL 到目标文件(HTTPS、跟随重定向、失败即报错)。
/// 仅应配合 `is_url_host_allowed` 白名单校验后使用。
pub fn download_with_curl(url: &str, dest: &std::path::Path) -> std::io::Result<()> {
let mut cmd = std::process::Command::new("curl.exe");
cmd.args(["-L", "--fail", "--silent", "--show-error", "-o"]);
cmd.arg(dest);
cmd.arg(url);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
}
let status = cmd.status()?;
if status.success() {
Ok(())
} else {
Err(std::io::Error::new(
std::io::ErrorKind::Other,
"curl 下载失败(网络错误或来源不可用)",
))
}
}
/// 极简 URL 主机提取(仅用于白名单比对,不解析完整 URL)。
fn extract_host(url: &str) -> Option<String> {
let s = url.trim();
let s = s.strip_prefix("https://").or_else(|| s.strip_prefix("http://"))?;
let host = s.split(['/', '?', '#']).next()?;
let host = host.rsplit('@').next()?; // 剔除可能的 userinfo
let host = host.trim().trim_matches('.');
let host = if let Some((h, _)) = host.split_once(':') { h } else { host };
if host.is_empty() {
None
} else {
Some(host.to_ascii_lowercase())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sources_have_allowed_hosts_and_pages() {
for s in table() {
assert!(!s.download_page.is_empty());
assert!(!s.allowed_hosts.is_empty(), "{} 应有白名单", s.runtime);
}
}
#[test]
fn node_source_is_installable() {
let node = source_for("node").unwrap();
assert!(node.direct_installable);
assert_eq!(node.label_zh, "Node.js");
}
#[test]
fn uv_falls_back_to_page() {
let uv = source_for("uv").unwrap();
assert!(!uv.direct_installable);
assert!(uv.download_url.is_none());
}
#[test]
fn host_allowlist_matches_exact_and_subdomain() {
assert!(is_url_host_allowed(
"https://nodejs.org/dist/v22.14.0/node-v22.14.0-x64.msi",
&["nodejs.org".to_string()]
));
assert!(is_url_host_allowed(
"https://www.python.org/ftp/python/3.12.10/python.exe",
&["python.org".to_string()]
));
assert!(!is_url_host_allowed(
"https://evil.example.com/node.msi",
&["nodejs.org".to_string()]
));
}
#[test]
fn host_allowlist_rejects_non_https_host() {
assert!(!is_url_host_allowed("https://nodejs.org.evil.com/x.msi", &["nodejs.org".to_string()]));
}
#[test]
fn extract_host_handles_port_and_path() {
assert_eq!(extract_host("https://a.com:8443/x"), Some("a.com".into()));
assert_eq!(extract_host("https://a.com/x?y=1"), Some("a.com".into()));
assert_eq!(extract_host("not a url"), None);
}
}
+293
View File
@@ -0,0 +1,293 @@
//! 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,
/// 官方文档链接(配置项依据)
pub docs_url: Option<String>,
/// enum 类型的可选值(value + 中文名)
pub options: Vec<ConfigFieldOption>,
/// 该字段(keyring 类)对应的环境变量名(如 OPENAI_API_KEY),供界面说明
pub env_key: Option<String>,
/// 表单分组:auth | common | advanced(未声明按 common
pub group: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigFieldOption {
pub value: String,
pub label_zh: String,
}
/// 配置文件解析状态。
#[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>,
/// 官方授权方式(授权/登录层引导用)
pub auth_modes: Vec<AuthModeInfo>,
}
/// 官方授权方式(授权/登录层展示)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthModeInfo {
/// browser_oauth | device_code | api_key | local_tui
pub mode: String,
pub notes_zh: Option<String>,
/// 交互式登录命令(浏览器/设备码等;api_key 类常为空)
pub command: Vec<String>,
}
/// 写配置结果(writeConfig 返回)。
#[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>,
}
/// 配置写入后的「生效检查」结果(verifyConfig 返回)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigVerifyResult {
pub cli_id: String,
/// 整体是否通过(配置文件可解析即视为「已写入成功」)
pub ok: bool,
/// 验证层级:cli_accepted | config_parsed | config_parse_failed | not_installed
pub level: String,
/// 中文结论(如实标注验证到哪一层)
pub message_zh: String,
/// 脱敏后的补充信息(CLI 版本输出 / 解析错误)
pub detail: Option<String>,
}
/// 动作流事件(runAction 流式输出)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActionEvent {
/// step | stdout | stderr | done | error
pub kind: String,
/// 已脱敏的消息文本
pub message: String,
/// 当前阶段:prepare | exec | verify(供界面渲染步骤进度)
pub phase: Option<String>,
pub data: Option<serde_json::Value>,
}
impl ActionEvent {
pub fn step(msg: impl Into<String>) -> Self {
ActionEvent { kind: "step".into(), message: msg.into(), phase: None, data: None }
}
pub fn step_phase(phase: &str, msg: impl Into<String>) -> Self {
ActionEvent { kind: "step".into(), message: msg.into(), phase: Some(phase.into()), data: None }
}
pub fn stdout(line: impl Into<String>) -> Self {
ActionEvent { kind: "stdout".into(), message: line.into(), phase: Some("exec".into()), data: None }
}
pub fn stderr(line: impl Into<String>) -> Self {
ActionEvent { kind: "stderr".into(), message: line.into(), phase: Some("exec".into()), data: None }
}
pub fn done(data: Option<serde_json::Value>) -> Self {
ActionEvent { kind: "done".into(), message: String::new(), phase: Some("verify".into()), data }
}
pub fn error(msg: impl Into<String>) -> Self {
ActionEvent { kind: "error".into(), message: msg.into(), phase: None, data: None }
}
/// 携带结构化错误提示(人话化 + code + 缺失运行时)的错误事件。
pub fn error_hint(hint: &ErrorHint) -> Self {
ActionEvent {
kind: "error".into(),
message: hint.friendly_zh.clone(),
phase: None,
data: Some(serde_json::to_value(hint).unwrap_or(serde_json::Value::Null)),
}
}
}
/// 安装/执行失败的人话化错误提示(errors_zh 映射产出,随 error 事件的 data 回传)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ErrorHint {
/// program_not_found | permission_denied | network_timeout | network_dns | disk_full | exec_failed
pub code: String,
/// 人话版中文建议(默认展示)
pub friendly_zh: String,
/// 原始错误文本(折叠保留)
pub raw: String,
/// 缺失的运行时 id(如 node / npm / python / git / winget / uv),供界面一键直达安装
pub missing_runtime: Option<String>,
}
/// 授权流程事件(authorize 流式回传)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthFlowEvent {
pub cli_id: String,
pub mode: String,
/// started | line | device_code | waiting | done | error | cancelled
pub kind: String,
/// 已脱敏的说明文本 / 输出行
pub message: String,
/// 设备码(device_code 模式解析出的 user_code
pub user_code: Option<String>,
/// 验证链接(device_code 模式解析出的 verification_url
pub verification_url: Option<String>,
/// 终态时是否已授权(done 事件)
pub authorized: Option<bool>,
}
impl AuthFlowEvent {
pub fn started(cli_id: &str, mode: &str, message: impl Into<String>) -> Self {
AuthFlowEvent {
cli_id: cli_id.to_string(),
mode: mode.to_string(),
kind: "started".into(),
message: message.into(),
user_code: None,
verification_url: None,
authorized: None,
}
}
pub fn line(cli_id: &str, mode: &str, message: impl Into<String>) -> Self {
AuthFlowEvent {
cli_id: cli_id.to_string(),
mode: mode.to_string(),
kind: "line".into(),
message: message.into(),
user_code: None,
verification_url: None,
authorized: None,
}
}
pub fn device_code(cli_id: &str, mode: &str, user_code: String, verification_url: String) -> Self {
AuthFlowEvent {
cli_id: cli_id.to_string(),
mode: mode.to_string(),
kind: "device_code".into(),
message: "请在浏览器打开验证链接并输入设备码".into(),
user_code: Some(user_code),
verification_url: Some(verification_url),
authorized: None,
}
}
pub fn waiting(cli_id: &str, mode: &str, message: impl Into<String>) -> Self {
AuthFlowEvent {
cli_id: cli_id.to_string(),
mode: mode.to_string(),
kind: "waiting".into(),
message: message.into(),
user_code: None,
verification_url: None,
authorized: None,
}
}
pub fn done(cli_id: &str, mode: &str, authorized: bool) -> Self {
AuthFlowEvent {
cli_id: cli_id.to_string(),
mode: mode.to_string(),
kind: "done".into(),
message: if authorized { "授权完成".into() } else { "授权流程结束,未检测到登录".into() },
user_code: None,
verification_url: None,
authorized: Some(authorized),
}
}
pub fn error(cli_id: &str, mode: &str, message: impl Into<String>) -> Self {
AuthFlowEvent {
cli_id: cli_id.to_string(),
mode: mode.to_string(),
kind: "error".into(),
message: message.into(),
user_code: None,
verification_url: None,
authorized: None,
}
}
pub fn cancelled(cli_id: &str, mode: &str) -> Self {
AuthFlowEvent {
cli_id: cli_id.to_string(),
mode: mode.to_string(),
kind: "cancelled".into(),
message: "已取消授权流程".into(),
user_code: None,
verification_url: None,
authorized: 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 version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
description = "诊断规则引擎:PATH/依赖/版本冲突/配置损坏 四类检查(架构 §10)"
[dependencies] [dependencies]
serde = { version = "1", features = ["derive"] }
+213 -6
View File
@@ -1,17 +1,224 @@
//! agentdock-diag —— 诊断规则引擎(架构 §10) //! agentdock-diag —— 诊断规则引擎(架构 §10)
//! //!
//! 职责PATH / 依赖缺失 / 版本冲突 / 配置损坏 四类检查的规则引擎。 //! 四类必检PATH / 依赖版本 / 版本冲突 / 配置损坏。每个检查器都是纯函数:
//! Wave 0:空骨架,随 Wave 2 落地。 //! 输入真实探测结果与声明阈值,输出 `Option<Finding>`。规则本身不依赖适配器
//! 或进程执行(由 core 编排层传入数据),便于单元测试与「构造缺失场景」验证。
/// 诊断层能力标记(占位) use serde::{Deserialize, Serialize};
pub const LAYER: &str = "agentdock-diag";
/// 诊断级别(对齐视觉规范 §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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test] #[test]
fn layer_identity() { fn path_not_installed_rule() {
assert_eq!(LAYER, "agentdock-diag"); 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"));
} }
} }
+31
View File
@@ -0,0 +1,31 @@
//! 执行层错误类型(中文,不含密钥明文)
use std::fmt;
#[derive(Debug)]
pub enum ExecError {
/// 参数含 shell 元字符等危险输入
DangerousInput(String),
/// 参数槽位白名单校验失败
InvalidSlot(String),
/// 工作目录越界
CwdNotAllowed(String),
/// 进程启动失败
Spawn(String),
/// 非零退出码
NonZeroExit(String),
}
impl fmt::Display for ExecError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ExecError::DangerousInput(m) => write!(f, "危险输入: {m}"),
ExecError::InvalidSlot(m) => write!(f, "参数校验失败: {m}"),
ExecError::CwdNotAllowed(m) => write!(f, "工作目录越界: {m}"),
ExecError::Spawn(m) => write!(f, "进程启动失败: {m}"),
ExecError::NonZeroExit(m) => write!(f, "命令非零退出: {m}"),
}
}
}
impl std::error::Error for ExecError {}
+141
View File
@@ -0,0 +1,141 @@
//! 仅 argv 数组的安全进程执行(架构 §4.2)
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use crate::error::ExecError;
/// shell 注入元字符(argv 场景,架构 §4.2)。`\` `/` 属路径分隔符,允许出现在
/// 可执行文件路径或路径参数里(argv 不经 shell,无转义语义)。
const SHELL_INJECT_CHARS: &[char] = &['|', '&', ';', '$', '>', '<', '(', ')', '`'];
/// 返回首个 shell 注入元字符。
pub fn first_metachar(s: &str) -> Option<char> {
s.chars().find(|c| SHELL_INJECT_CHARS.contains(c))
}
/// 校验可执行文件名:非空、不含 shell 注入元字符、不以 `-` 开头。
fn validate_executable(prog: &str) -> Result<(), ExecError> {
if prog.is_empty() {
return Err(ExecError::InvalidSlot("可执行文件名不能为空".into()));
}
if let Some(c) = first_metachar(prog) {
return Err(ExecError::DangerousInput(format!("可执行文件名含 shell 元字符 {c:?}: {prog}")));
}
if prog.starts_with('-') {
return Err(ExecError::InvalidSlot(format!("可执行文件名不能以 - 开头: {prog}")));
}
Ok(())
}
/// 校验 argv 数组:可执行文件 + 每个参数均不含 shell 元字符。
/// 用户输入只能作为已校验参数槽位(枚举/路径/版本号)传入,禁止拼接。
pub fn validate_argv(prog: &str, args: &[String]) -> Result<(), ExecError> {
validate_executable(prog)?;
for arg in args {
if let Some(c) = first_metachar(arg) {
return Err(ExecError::DangerousInput(format!("参数含 shell 元字符 {c:?}: {arg}")));
}
}
Ok(())
}
/// 路径规范化:优先 canonicalize(消 .. 与符号链接),失败则转绝对路径。
fn normalize(p: &Path) -> PathBuf {
std::fs::canonicalize(p).unwrap_or_else(|_| {
if p.is_absolute() {
p.to_path_buf()
} else {
std::env::current_dir()
.unwrap_or_default()
.join(p)
}
})
}
/// 工作目录限制(架构 §4.2 第 3 条):cwd 必须落在某个允许的根目录之内。
pub fn ensure_cwd_within(cwd: &Path, allowed_roots: &[PathBuf]) -> Result<(), ExecError> {
if allowed_roots.is_empty() {
return Err(ExecError::CwdNotAllowed("未配置允许的工作目录根".into()));
}
let cwd_norm = normalize(cwd);
for root in allowed_roots {
let root_norm = normalize(root);
if cwd_norm.starts_with(&root_norm) {
return Ok(());
}
}
Err(ExecError::CwdNotAllowed(format!(
"工作目录「{}」超出允许范围",
cwd.display()
)))
}
/// 仅 argv 执行:`Command::new(prog).args(args)`,禁止 shell 拼接 / 管道 / 重定向。
/// cwd 为 None 时继承当前进程目录。
pub fn spawn(prog: &str, args: &[String], cwd: Option<&Path>) -> Result<Output, ExecError> {
validate_argv(prog, args)?;
let mut cmd = Command::new(prog);
cmd.args(args);
if let Some(dir) = cwd {
cmd.current_dir(dir);
}
cmd.output()
.map_err(|e| ExecError::Spawn(format!("无法执行 {prog}: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_shell_metachar_in_args() {
for (prog, args) in [
("npm", vec!["a|b".to_string()]),
("npm", vec!["a&&b".to_string()]),
("sh", vec!["$(id)".to_string()]),
("sh", vec!["`whoami`".to_string()]),
("sh", vec!["a;b".to_string()]),
] {
assert!(matches!(validate_argv(prog, &args), Err(ExecError::DangerousInput(_))),
"{prog} {args:?} 应被拒绝");
}
}
#[test]
fn rejects_metachar_in_executable() {
assert!(matches!(validate_argv("a|b", &[]), Err(ExecError::DangerousInput(_))));
assert!(matches!(validate_argv("", &[]), Err(ExecError::InvalidSlot(_))));
assert!(matches!(validate_argv("-rf", &[]), Err(ExecError::InvalidSlot(_))));
}
#[test]
fn accepts_plain_argv() {
assert!(validate_argv("npm", &["install".into(), "-g".into(), "@openai/codex".into()]).is_ok());
assert!(validate_argv("C:\\Program Files\\nodejs\\node.exe", &["--version".into()]).is_ok());
}
#[test]
fn cwd_within_allowed_root() {
let root = std::env::temp_dir();
let ok = root.join("agentdock-exec-ok");
let out = std::env::temp_dir().join("agentdock-exec-out");
let _ = std::fs::create_dir_all(&ok);
assert!(ensure_cwd_within(&ok, &[root.clone()]).is_ok());
assert!(ensure_cwd_within(&out, &[ok.clone()]).is_err());
}
#[cfg(windows)]
#[test]
fn spawn_runs_argv_without_shell() {
let out = spawn("where.exe", &["where".to_string()], None).expect("where.exe 应可执行");
assert!(out.status.success());
}
#[cfg(not(windows))]
#[test]
fn spawn_runs_argv_without_shell() {
let out = spawn("/bin/true", &[], None).expect("/bin/true 应可执行");
assert!(out.status.success());
}
}
+8 -13
View File
@@ -1,17 +1,12 @@
//! agentdock-exec —— 安全进程执行层(架构 §3.2 / §4.2) //! agentdock-exec —— 安全进程执行层(架构 §3.2 / §4.2)
//! //!
//! 职责:仅执行适配器声明的 argv,禁止 shell 拼接、管道、重定向; //! 职责:仅执行适配器声明的 argv 数组,禁止 shell 拼接、管道、重定向;
//! 参数白名单校验。Wave 0:空骨架,随 Wave 1 落地 //! 参数槽位白名单校验;工作目录限制
/// 执行层能力标记(占位) pub mod error;
pub const LAYER: &str = "agentdock-exec"; pub mod exec;
pub mod slots;
#[cfg(test)] pub use error::ExecError;
mod tests { pub use exec::{ensure_cwd_within, spawn, validate_argv};
use super::*; pub use slots::{Slot, validate_slot};
#[test]
fn layer_identity() {
assert_eq!(LAYER, "agentdock-exec");
}
}
+89
View File
@@ -0,0 +1,89 @@
//! 参数槽位白名单校验(架构 §4.2:用户输入只作为已校验参数槽位)
use crate::error::ExecError;
use crate::exec::first_metachar;
/// 参数槽位类型。
#[derive(Debug, Clone, PartialEq)]
pub enum Slot {
/// 枚举:值必须在白名单内
Enum(Vec<&'static str>),
/// 版本号:纯数字 + 点 + 可选连字符(如 1.2.3 / 24.18.0
Version,
/// 路径:禁止 shell 元字符
Path,
/// 普通参数:禁止 shell 元字符、禁止为空
Plain,
}
/// 校验单个参数槽位。
pub fn validate_slot(slot: &Slot, value: &str) -> Result<(), ExecError> {
if value.is_empty() {
return Err(ExecError::InvalidSlot("参数不能为空".into()));
}
if let Some(c) = first_metachar(value) {
return Err(ExecError::DangerousInput(format!("参数含 shell 元字符 {c:?}: {value}")));
}
match slot {
Slot::Enum(allowed) => {
if !allowed.iter().any(|a| *a == value) {
return Err(ExecError::InvalidSlot(format!(
"值「{value}」不在白名单 {allowed:?}"
)));
}
}
Slot::Version => {
if !value.chars().all(|c| c.is_ascii_digit() || c == '.' || c == '-') {
return Err(ExecError::InvalidSlot(format!("{value}」不是合法版本号")));
}
}
Slot::Path | Slot::Plain => {}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn enum_whitelist_accepts_listed_value() {
let slot = Slot::Enum(vec!["npm", "winget", "apt"]);
assert!(validate_slot(&slot, "npm").is_ok());
assert!(validate_slot(&slot, "winget").is_ok());
}
#[test]
fn enum_whitelist_rejects_unknown_value() {
let slot = Slot::Enum(vec!["npm", "winget", "apt"]);
match validate_slot(&slot, "choco") {
Err(ExecError::InvalidSlot(m)) => assert!(m.contains("choco"), "错误应包含被拒值: {m}"),
other => panic!("应返回 InvalidSlot,实际 {other:?}"),
}
}
#[test]
fn version_slot_accepts_and_rejects() {
let slot = Slot::Version;
assert!(validate_slot(&slot, "24.18.0").is_ok());
assert!(validate_slot(&slot, "3.14.6").is_ok());
assert!(matches!(validate_slot(&slot, "abc"), Err(ExecError::InvalidSlot(_))));
}
#[test]
fn rejects_metachar_in_any_slot() {
for slot in [
Slot::Plain,
Slot::Path,
Slot::Version,
Slot::Enum(vec!["npm"]),
] {
assert!(matches!(validate_slot(&slot, "a|b"), Err(ExecError::DangerousInput(_))));
}
}
#[test]
fn rejects_empty() {
assert!(matches!(validate_slot(&Slot::Plain, ""), Err(ExecError::InvalidSlot(_))));
}
}
+61 -3
View File
@@ -15,10 +15,14 @@ pub fn detect_env() -> PlatformEnv {
#[cfg(not(windows))] #[cfg(not(windows))]
let (os, os_version) = (String::from("linux"), linux_os_version()); let (os, os_version) = (String::from("linux"), linux_os_version());
let (distro, distro_version) = distro_info();
PlatformEnv { PlatformEnv {
os, os,
os_version, os_version,
arch: std::env::consts::ARCH.to_string(), arch: std::env::consts::ARCH.to_string(),
distro,
distro_version,
shells: detect_shells(), shells: detect_shells(),
runtimes: detect_runtimes(), runtimes: detect_runtimes(),
path_entries: path_entries(), path_entries: path_entries(),
@@ -26,6 +30,31 @@ pub fn detect_env() -> PlatformEnv {
} }
} }
#[cfg(windows)]
fn distro_info() -> (Option<String>, Option<String>) {
(None, None)
}
#[cfg(not(windows))]
fn distro_info() -> (Option<String>, Option<String>) {
parse_os_release(&std::fs::read_to_string("/etc/os-release").unwrap_or_default())
}
/// 解析 os-release 内容,返回 (发行版名, 版本号)。
/// 独立成纯函数以便跨平台单元测试。
pub fn parse_os_release(content: &str) -> (Option<String>, Option<String>) {
let mut id = None;
let mut ver = None;
for line in content.lines() {
if let Some(v) = line.strip_prefix("ID=") {
id = Some(v.trim().trim_matches('"').to_string());
} else if let Some(v) = line.strip_prefix("VERSION_ID=") {
ver = Some(v.trim().trim_matches('"').to_string());
}
}
(id, ver)
}
/// 从命令输出中提取首个版本号(如 "v24.18.0" -> "24.18.0")。 /// 从命令输出中提取首个版本号(如 "v24.18.0" -> "24.18.0")。
/// 规则:取第一段以数字开头、由数字与点组成的子串,尾部点剔除。 /// 规则:取第一段以数字开头、由数字与点组成的子串,尾部点剔除。
pub fn extract_version(output: &str) -> Option<String> { pub fn extract_version(output: &str) -> Option<String> {
@@ -95,7 +124,7 @@ fn resolve_program(program: &str) -> Option<PathBuf> {
} }
/// 探测一个可执行文件:返回状态 / 版本 / 路径。 /// 探测一个可执行文件:返回状态 / 版本 / 路径。
/// 结果分类:installed / not_in_path / exec_failed / version_unparseable。 /// 结果分类:installed / not_in_path / permission_denied / exec_failed / version_unparseable。
fn probe(program: &str, args: &[&str]) -> Option<RuntimeInfo> { fn probe(program: &str, args: &[&str]) -> Option<RuntimeInfo> {
let path = resolve_program(program); let path = resolve_program(program);
let path_str = path.as_ref().map(|p| p.to_string_lossy().to_string()); let path_str = path.as_ref().map(|p| p.to_string_lossy().to_string());
@@ -119,9 +148,15 @@ fn probe(program: &str, args: &[&str]) -> Option<RuntimeInfo> {
let output = match cmd.output() { let output = match cmd.output() {
Ok(o) => o, Ok(o) => o,
Err(_) => { Err(e) => {
// 架构 §5:把「权限不足」与一般执行失败拆分开(总工 Wave 0 🟡)
let status = if e.kind() == std::io::ErrorKind::PermissionDenied {
"permission_denied"
} else {
"exec_failed"
};
return Some(RuntimeInfo { return Some(RuntimeInfo {
status: "exec_failed".into(), status: status.into(),
version: None, version: None,
path: path_str, path: path_str,
}); });
@@ -296,6 +331,29 @@ mod tests {
assert_eq!(extract_version("v1.2.3."), Some("1.2.3".into())); assert_eq!(extract_version("v1.2.3."), Some("1.2.3".into()));
} }
#[test]
fn parses_os_release_distro_and_version() {
let content = "NAME=\"Ubuntu\"\nVERSION=\"24.04.1 LTS (Noble Numbat)\"\nID=ubuntu\nID_LIKE=debian\nVERSION_ID=\"24.04\"\n";
let (distro, ver) = parse_os_release(content);
assert_eq!(distro.as_deref(), Some("ubuntu"));
assert_eq!(ver.as_deref(), Some("24.04"));
}
#[test]
fn parses_os_release_missing_fields() {
let (distro, ver) = parse_os_release("NAME=\"Other\"\n");
assert_eq!(distro, None);
assert_eq!(ver, None);
}
#[cfg(windows)]
#[test]
fn windows_distro_is_none() {
let env = detect_env();
assert_eq!(env.distro, None);
assert_eq!(env.distro_version, None);
}
#[test] #[test]
fn env_has_path_on_windows() { fn env_has_path_on_windows() {
// PATH 变量在任何真实 Windows/Linux 上都存在 // PATH 变量在任何真实 Windows/Linux 上都存在
+7 -1
View File
@@ -11,6 +11,12 @@ pub struct PlatformEnv {
pub os_version: String, pub os_version: String,
/// 架构(std::env::consts::ARCH,如 x86_64 /// 架构(std::env::consts::ARCH,如 x86_64
pub arch: String, pub arch: String,
/// Linux 发行版名称(如 "ubuntu");Windows 为 None。
/// 架构 §5 原 `distro?: ubuntu + version` 合并字段,本波拆分为
/// `distro` + `distro_version` 两个字段(总工 Wave 0 🟡)。
pub distro: Option<String>,
/// Linux 发行版版本(如 "22.04");Windows 为 None。
pub distro_version: Option<String>,
pub shells: Shells, pub shells: Shells,
pub runtimes: Runtimes, pub runtimes: Runtimes,
/// PATH 条目(Windows 用 ';' 分割,Linux 用 ':' /// PATH 条目(Windows 用 ';' 分割,Linux 用 ':'
@@ -41,7 +47,7 @@ pub struct Runtimes {
/// 单个运行时探测结果 /// 单个运行时探测结果
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct RuntimeInfo { pub struct RuntimeInfo {
/// installed | not_installed | not_in_path | exec_failed | version_unparseable /// installed | not_in_path | permission_denied | exec_failed | version_unparseable
pub status: String, pub status: String,
pub version: Option<String>, pub version: Option<String>,
/// 解析到的可执行文件绝对路径(PATH 搜索) /// 解析到的可执行文件绝对路径(PATH 搜索)
+10
View File
@@ -3,5 +3,15 @@ name = "agentdock-secrets"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
description = "系统密钥库封装(keyring)+ 日志脱敏(架构 §4.1 / §4.3"
[dependencies] [dependencies]
regex = "1"
# keyring 为架构 §1/§4.1 指定的密钥库后端:Windows Credential Manager /
# Linux Secret Service。按平台仅编译对应后端,避免把 dbus 栈拖进 Windows 构建。
[target.'cfg(windows)'.dependencies]
keyring = { version = "3", features = ["windows-native"] }
[target.'cfg(target_os = "linux")'.dependencies]
keyring = { version = "3", features = ["sync-secret-service"] }
+25
View File
@@ -0,0 +1,25 @@
//! 密钥库错误类型。
//!
//! 铁律:错误信息(Display / Debug)绝不携带密钥明文,也不携带后端原始错误
//! 文本(原始错误可能夹带敏感信息),只保留中文说明。
use std::fmt;
#[derive(Debug)]
pub enum SecretStoreError {
/// 无此条目
NotFound,
/// 后端不可用(中文说明,不含明文)
Backend(String),
}
impl fmt::Display for SecretStoreError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SecretStoreError::NotFound => write!(f, "未找到对应密钥条目"),
SecretStoreError::Backend(m) => write!(f, "密钥库错误: {m}"),
}
}
}
impl std::error::Error for SecretStoreError {}
+116
View File
@@ -0,0 +1,116 @@
//! 生产密钥库后端:keyring → Windows Credential Manager(优先)/ Linux Secret Service
//! (架构 §4.1
//!
//! 错误映射时只保留中文说明,绝不把后端原始错误或密钥明文带入错误信息。
use crate::error::SecretStoreError;
use crate::store::SecretStore;
/// keyring 后端封装。
#[derive(Default)]
pub struct KeyringSecretStore;
impl KeyringSecretStore {
pub fn new() -> Self {
Self
}
}
#[cfg(windows)]
mod platform {
use keyring::Entry;
use crate::error::SecretStoreError;
pub fn set(service: &str, account: &str, secret: &str) -> Result<(), SecretStoreError> {
let entry = Entry::new(service, account).map_err(map_err)?;
entry.set_password(secret).map_err(map_err)
}
pub fn get(service: &str, account: &str) -> Result<String, SecretStoreError> {
let entry = Entry::new(service, account).map_err(map_err)?;
entry.get_password().map_err(map_err)
}
pub fn has(service: &str, account: &str) -> bool {
Entry::new(service, account)
.and_then(|e| e.get_password())
.is_ok()
}
pub fn delete(service: &str, account: &str) -> Result<(), SecretStoreError> {
let entry = Entry::new(service, account).map_err(map_err)?;
entry.delete_credential().map_err(map_err)
}
fn map_err(e: keyring::Error) -> SecretStoreError {
// NoEntry 表示无此条目;其余错误统一映射为后端失败,绝不回显原始错误文本。
if matches!(e, keyring::Error::NoEntry) {
SecretStoreError::NotFound
} else {
SecretStoreError::Backend("系统密钥库操作失败(Windows Credential Manager".into())
}
}
}
#[cfg(not(windows))]
mod platform {
use crate::error::SecretStoreError;
pub fn set(_service: &str, _account: &str, _secret: &str) -> Result<(), SecretStoreError> {
Err(SecretStoreError::Backend("Linux 密钥库后端自 Wave 2 起接入(Secret Service".into()))
}
pub fn get(_service: &str, _account: &str) -> Result<String, SecretStoreError> {
Err(SecretStoreError::Backend("Linux 密钥库后端自 Wave 2 起接入(Secret Service".into()))
}
pub fn has(_service: &str, _account: &str) -> bool {
false
}
pub fn delete(_service: &str, _account: &str) -> Result<(), SecretStoreError> {
Err(SecretStoreError::Backend("Linux 密钥库后端自 Wave 2 起接入(Secret Service".into()))
}
}
impl SecretStore for KeyringSecretStore {
fn set(&self, service: &str, account: &str, secret: &str) -> Result<(), SecretStoreError> {
platform::set(service, account, secret)
}
fn get(&self, service: &str, account: &str) -> Result<String, SecretStoreError> {
platform::get(service, account)
}
fn has(&self, service: &str, account: &str) -> bool {
platform::has(service, account)
}
fn delete(&self, service: &str, account: &str) -> Result<(), SecretStoreError> {
platform::delete(service, account)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// 真机密钥库往返(Windows Credential Manager):写假 key → 读回一致 → 删除清理。
/// 运行:cargo test -p agentdock-secrets real_machine_keyring_roundtrip -- --ignored --nocapture
#[test]
#[ignore]
fn real_machine_keyring_roundtrip() {
let store = KeyringSecretStore::new();
let service = "agentdock.selftest";
let account = "api_key";
let fake = "sk-test-selftest-only";
store.set(service, account, fake).expect("写 Credential Manager 应成功");
assert!(store.has(service, account), "写后应可读到");
assert_eq!(store.get(service, account).unwrap(), fake, "读回应一致");
store.delete(service, account).expect("删除应成功");
assert!(!store.has(service, account), "删除后应不存在");
println!("真机密钥库往返通过(Windows Credential Manager,假 key 已清理)");
}
}
+14 -13
View File
@@ -1,17 +1,18 @@
//! agentdock-secrets —— 系统密钥库封装与日志脱敏(架构 §4.1 / §4.3) //! agentdock-secrets —— 系统密钥库封装与日志脱敏(架构 §4.1 / §4.3)
//! //!
//! 职责:keyring 封装(Windows Credential Manager / Linux Secret Service //! - `store` / `mock` / `keyring`:密钥库封装(set/get/has/delete
//! 敏感值脱敏。Wave 0:空骨架,随 Wave 1 落地。 //! 生产后端走 keyringWindows Credential Manager / Linux Secret Service),
//! 测试用 mock 内存后端。
//! - `redact`:日志脱敏器(sk-/xai-/ghp_/gho_/JWT 与敏感键值)。
/// 密钥层能力标记(占位) pub mod error;
pub const LAYER: &str = "agentdock-secrets"; pub mod keyring;
pub mod mock;
pub mod redact;
pub mod store;
#[cfg(test)] pub use error::SecretStoreError;
mod tests { pub use keyring::KeyringSecretStore;
use super::*; pub use mock::MockSecretStore;
pub use redact::{REDACTED, redact, redact_value};
#[test] pub use store::{SecretStore, service_name};
fn layer_identity() {
assert_eq!(LAYER, "agentdock-secrets");
}
}
+96
View File
@@ -0,0 +1,96 @@
//! 内存 mock 密钥库后端(测试与无系统钥匙串环境用)
use std::collections::HashMap;
use std::sync::Mutex;
use crate::error::SecretStoreError;
use crate::store::SecretStore;
/// 线程安全的内存后端。仅用于测试与开发联调,不落盘。
#[derive(Default)]
pub struct MockSecretStore {
inner: Mutex<HashMap<(String, String), String>>,
}
impl MockSecretStore {
pub fn new() -> Self {
Self::default()
}
/// 返回当前条目数量(测试断言用)。
pub fn len(&self) -> usize {
self.inner.lock().map(|m| m.len()).unwrap_or(0)
}
}
impl SecretStore for MockSecretStore {
fn set(&self, service: &str, account: &str, secret: &str) -> Result<(), SecretStoreError> {
let mut map = self.inner.lock().map_err(|_| SecretStoreError::Backend("内存锁中毒".into()))?;
map.insert((service.to_string(), account.to_string()), secret.to_string());
Ok(())
}
fn get(&self, service: &str, account: &str) -> Result<String, SecretStoreError> {
let map = self.inner.lock().map_err(|_| SecretStoreError::Backend("内存锁中毒".into()))?;
map.get(&(service.to_string(), account.to_string()))
.cloned()
.ok_or(SecretStoreError::NotFound)
}
fn has(&self, service: &str, account: &str) -> bool {
self.get(service, account).is_ok()
}
fn delete(&self, service: &str, account: &str) -> Result<(), SecretStoreError> {
let mut map = self.inner.lock().map_err(|_| SecretStoreError::Backend("内存锁中毒".into()))?;
map.remove(&(service.to_string(), account.to_string()))
.map(|_| ())
.ok_or(SecretStoreError::NotFound)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip_set_get() {
let store = MockSecretStore::new();
store.set("agentdock.codex", "api_key", "sk-verysecret").unwrap();
assert_eq!(store.get("agentdock.codex", "api_key").unwrap(), "sk-verysecret");
assert!(store.has("agentdock.codex", "api_key"));
}
#[test]
fn get_missing_returns_not_found_without_secret() {
let store = MockSecretStore::new();
let err = store.get("agentdock.codex", "nope").unwrap_err();
assert!(matches!(err, SecretStoreError::NotFound));
// 错误信息不得含任何密钥明文
let secret = "sk-do-not-leak";
assert!(!format!("{err}").contains(secret));
assert!(!format!("{err:?}").contains(secret));
}
#[test]
fn delete_removes_entry() {
let store = MockSecretStore::new();
store.set("agentdock.claude", "api_key", "xai-123").unwrap();
assert!(store.has("agentdock.claude", "api_key"));
store.delete("agentdock.claude", "api_key").unwrap();
assert!(!store.has("agentdock.claude", "api_key"));
assert!(matches!(store.delete("agentdock.claude", "api_key"), Err(SecretStoreError::NotFound)));
}
#[test]
fn error_never_contains_plaintext() {
let store = MockSecretStore::new();
let secret = "sk-super-secret-value";
store.set("agentdock.x", "k", secret).unwrap();
// 触发一个错误路径:删除后读取
store.delete("agentdock.x", "k").unwrap();
let err = store.get("agentdock.x", "k").unwrap_err();
let rendered = format!("{err} / {err:?}");
assert!(!rendered.contains(secret), "错误渲染不应含密钥明文: {rendered}");
}
}
+121
View File
@@ -0,0 +1,121 @@
//! 日志脱敏器(架构 §4.3
//!
//! 模式表:
//! - `sk-` / `xai-` / `ghp_`(及 GitHub 其它前缀 `gho_/ghs_/ghu_/ghr_`/ JWT 形态 → `***REDACTED***`
//! - `key=value` / `key: value` 形式的敏感键(api_key/token/secret/password/authorization/bearer)→ 值脱敏
//! - 任何标记 `sensitive: true` 的字段值,调用方用 `redact_value` 强制脱敏
use std::sync::OnceLock;
use regex::Regex;
/// 统一的脱敏占位符。
pub const REDACTED: &str = "***REDACTED***";
fn jwt_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"eyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+").unwrap())
}
fn sk_xai_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"(?i)\b(?:sk|xai)-[A-Za-z0-9_-]+").unwrap())
}
fn github_token_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"\bgh[pousr]_[A-Za-z0-9]+").unwrap())
}
fn sensitive_kv_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(
r#"(?i)\b(api[_-]?key|apikey|token|secret|password|authorization|bearer)\s*[:=]\s*("[^"]*"|'[^']*'|[^\s,;]+)"#,
)
.unwrap()
})
}
/// 对任意文本做脱敏:替换前缀密钥、JWT、敏感键值。
pub fn redact(input: &str) -> String {
let mut out = input.to_string();
out = jwt_re().replace_all(&out, REDACTED).to_string();
out = sk_xai_re().replace_all(&out, REDACTED).to_string();
out = github_token_re().replace_all(&out, REDACTED).to_string();
out = sensitive_kv_re()
.replace_all(&out, "$1=***REDACTED***")
.to_string();
out
}
/// 对单个已知敏感值强制脱敏(适配器 `sensitive: true` 字段落日志前调用)。
pub fn redact_value(_value: &str) -> String {
REDACTED.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redacts_sk_prefix() {
let out = redact("密钥是 sk-abc123def");
assert!(out.contains(REDACTED));
assert!(!out.contains("sk-abc123def"));
}
#[test]
fn redacts_xai_prefix() {
let out = redact("xai-verysecret");
assert!(out.contains(REDACTED));
assert!(!out.contains("xai-verysecret"));
}
#[test]
fn redacts_github_tokens() {
for token in ["ghp_abcdefghijklmnop", "gho_1234", "ghs_xyz", "ghu_9", "ghr_ab"] {
let out = redact(token);
assert!(out.contains(REDACTED), "{token}");
assert!(!out.contains(token), "{token}");
}
}
#[test]
fn redacts_jwt() {
let jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
let out = redact(&format!("Authorization: Bearer {jwt}"));
assert!(!out.contains(jwt));
assert!(out.contains(REDACTED));
}
#[test]
fn redacts_sensitive_key_value() {
let out = redact("API_KEY=sk-abc123");
assert!(out.contains("API_KEY=***REDACTED***"), "{out}");
assert!(!out.contains("sk-abc123"));
let out2 = redact("token: abcdef123456");
assert!(out2.contains("***REDACTED***"), "{out2}");
assert!(!out2.contains("abcdef123456"));
}
#[test]
fn redacts_quoted_value() {
let out = redact("password=\"hunter2secret\"");
assert!(out.contains("***REDACTED***"), "{out}");
assert!(!out.contains("hunter2secret"));
}
#[test]
fn leaves_innocent_text_untouched() {
let s = "node --version 返回 24.18.0";
assert_eq!(redact(s), s);
}
#[test]
fn redact_value_always_masks() {
assert_eq!(redact_value("anything"), REDACTED);
assert_eq!(redact_value("sk-abc"), REDACTED);
}
}
+21
View File
@@ -0,0 +1,21 @@
//! 密钥库抽象接口(架构 §4.1)
use crate::error::SecretStoreError;
/// 系统密钥库封装接口。`service` 固定前缀 `agentdock.<cli_id>`
/// `account` 为字段 id(如 `api_key`)。
pub trait SecretStore: Send + Sync {
/// 写入密钥。
fn set(&self, service: &str, account: &str, secret: &str) -> Result<(), SecretStoreError>;
/// 读取密钥。
fn get(&self, service: &str, account: &str) -> Result<String, SecretStoreError>;
/// 是否存在该密钥条目。
fn has(&self, service: &str, account: &str) -> bool;
/// 删除密钥条目。
fn delete(&self, service: &str, account: &str) -> Result<(), SecretStoreError>;
}
/// 构造 service 名:`agentdock.<cli_id>`。
pub fn service_name(cli_id: &str) -> String {
format!("agentdock.{cli_id}")
}
+3 -1
View File
@@ -313,7 +313,8 @@ AdapterExecutor:
os: windows | linux os: windows | linux
os_version: ... os_version: ...
arch: x64 | other(reject) arch: x64 | other(reject)
distro?: ubuntu + version distro?: string # 发行版名,如 ubuntuWindows 为 None
distro_version?: string # 发行版版本,如 22.04Wave 1 拆分:原 distro?: ubuntu + version
shells: powershell_version?, bash_available? shells: powershell_version?, bash_available?
runtimes: { node?, npm?, python?, uv?, git?, winget?, apt? } runtimes: { node?, npm?, python?, uv?, git?, winget?, apt? }
path_entries: [...] path_entries: [...]
@@ -331,6 +332,7 @@ capabilities: { keyring: ok|missing, can_elevate: bool }
| 密钥库 | Credential Manager 可用性 | `secret-tool` / DBus Secret Service | | 密钥库 | Credential Manager 可用性 | `secret-tool` / DBus Secret Service |
检测失败分类(对齐 FR-02):`not_installed` | `not_in_path` | `permission_denied` | `exec_failed` | `version_unparseable` 检测失败分类(对齐 FR-02):`not_installed` | `not_in_path` | `permission_denied` | `exec_failed` | `version_unparseable`
Wave 1 已把 `permission_denied` 从一般执行失败中拆分为独立状态(`std::io::ErrorKind::PermissionDenied` 单独归类),`RuntimeInfo.status` 相应支持该值。
--- ---