4 Commits
Author SHA1 Message Date
leeferandmultica-agent 0cec468ae6 Wave 3 返工:修复总工 4 条必改(cursor/goose 脚本 URL、goose 配置路径按平台选型、yaml+crushrc 编解码)
Co-authored-by: multica-agent <github@multica.ai>
2026-08-25 19:34:10 +08:00
leeferandmultica-agent b0d7124170 Wave 3: 补齐九个适配器(Qwen/CodeBuddy/Cline/Copilot/Crush/Goose/Aider/Cursor/Warp)
Co-authored-by: multica-agent <github@multica.ai>
2026-08-25 19:13:37 +08:00
AgentDock 施工员 4a2be5899d 入库初版:AgentDock Wave 2.2 全量代码入库
- 当前进度 Wave 2.2:五件套全链路打通 + 两轮返工完成
- 来源:此前各波施工的工位仓库(wave-0 / wave-1 / wave-0.5 / wave-2 / wave-2.1 / wave-2.2)
- README 补「入库说明」一节,并更正仓库当前状态为 Wave 2.2
2026-08-25 18:01:20 +08:00
leeferandCursor dd5a9378d2 Wave 2.2: 流式安装输出、软件内授权四模式、本机环境一键装与总览缓存
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 17:11:14 +08:00
40 changed files with 4274 additions and 478 deletions
Generated
+1
View File
@@ -22,6 +22,7 @@ version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"serde_yaml",
"toml 0.8.2",
]
+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)。
+14 -2
View File
@@ -2,8 +2,20 @@
跨平台、中文化、可扩展的 Agent CLI 软件管理器。帮助用户发现、安装、更新、配置、授权和排查 Agent CLI。
**本仓库当前为 Wave 0 状态**:桌面应用空壳可运行(Tauri 2 + React 19 + TypeScript + Vite),含设计 Token、
八层 Rust crate 骨架、适配器目录骨架、Windows 本机环境检测与 SQLite 空库。
## 入库说明
本仓库为 AgentDock 的**入库初版**。当前进度做到 **Wave 2.2**(五件套全链路打通 + 两轮返工完成),
来源为此前各波施工的工位仓库。分支 `main` 的提交历史保留各波次 tag
- `wave-0`Tauri2 + React 桌面应用空壳可运行,Windows 本机环境检测
- `wave-1`:适配器框架与安全基座(schema 校验 / dry-run / exec 沙箱 / 密钥库 / 日志脱敏)
- `wave-0.5`:4K 布局与空态修复(老板验收 15 条,视觉规范 v1.3)
- `wave-2`:五件套打通全链路(安装/检测/配置/授权/诊断)
- `wave-2.1`:返工黑屏/安装进度/官方配置三层/文档深度/真机列表
- `wave-2.2`:流式安装输出、软件内授权四模式、本机环境一键装与总览缓存
**本仓库当前为 Wave 2.2 状态**:五件套(发现/安装/配置/授权/诊断)全链路打通,含流式安装输出、软件内
四种授权方式、本机环境一键安装与总览缓存。已过总工真机审核并部署到桌面。
## 仓库结构
+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 读图受限,以真机复核为准)。
+38 -2
View File
@@ -182,7 +182,12 @@
"properties": {
"path": { "type": "string", "description": "支持 ~ 与平台变量" },
"format": { "type": "string", "enum": ["toml", "json", "jsonc", "yaml", "env", "crushrc"] },
"scope": { "type": "string", "enum": ["user", "project", "system"] }
"scope": { "type": "string", "enum": ["user", "project", "system"] },
"platforms": {
"type": "array",
"items": { "type": "string", "enum": ["windows", "linux"] },
"description": "适用平台;空数组 = 全平台适用(同一 CLI 不同平台路径不同时用于按平台选型)"
}
}
}
},
@@ -214,7 +219,24 @@
"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" }
"docs_url": { "type": "string", "format": "uri" },
"options": {
"type": "array",
"description": "enum 类型的可选值(value + 中文名)",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["value", "label_zh"],
"properties": {
"value": { "type": "string" },
"label_zh": { "type": "string" }
}
}
},
"group": {
"type": "string",
"description": "表单分组:auth(授权/登录)| common(常用配置)| advanced(高级配置,默认折叠)"
}
}
}
}
@@ -236,6 +258,8 @@
"additionalProperties": false,
"properties": {
"quickstart_zh": { "type": "string" },
"install_zh": { "type": "string", "description": "安装说明(中文)" },
"auth_zh": { "type": "string", "description": "授权说明(中文)" },
"commands": {
"type": "array",
"items": {
@@ -247,6 +271,18 @@
}
}
},
"params": {
"type": "array",
"description": "常用参数(中文)",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"param": { "type": "string" },
"desc_zh": { "type": "string" }
}
}
},
"updated_at": { "type": "string", "format": "date" },
"risks_zh": { "type": "array", "items": { "type": "string" } }
}
+139 -21
View File
@@ -1,28 +1,146 @@
# ============================================================
# AgentDock 适配器占位(Wave 1,对齐架构 §3.1 完整 schema
# 本波仅填五字段(id/name/name_zh/vendor/status);其余字段留空,
# Wave 2 起按调研底稿 agent-cli-survey-2026-08-24.md 逐项填充,禁止编造
#
# 完整字段(全部留空占位,Wave 2 填写):
# adapter_version # semver,如 1.2.0
# license # 展示用;专有许可注明「仅官方渠道安装、不重打包」
# platforms # windows/linuxarchitectures、notes、min_ubuntu
# official # homepage / docs / allowed_hosts(网络白名单)
# runtime_deps # [ { id, semver_range, required_for: [install|run] } ]
# install # preferred + channels[ {id, platforms, command[], script, package, elevate, elevate_reason_zh, post_checks} ]
# detect # executable / version_args / version_regex / version_unconfirmed / path_hints
# update # method + command[]
# uninstall # method + command[] + keep_config_default
# authorization # modes[ {mode, command[], env_keys[], status_command[], notes_zh} ]
# configuration # files[] / environment[] / fields[]
# diagnostics # [ { rule_id } ]
# documentation # quickstart_zh / commands[] / updated_at / risks_zh[]
#
# 铁律:所有 command 一律 argv 数组,禁止 shell 元字符(| & ; $ \ > < (`);
# 本波不实现任何真实安装/检测/配置/授权命令(那是 Wave 2 的事)。
# AgentDock 适配器 · Aideraider.chat)(Wave 3
# 数据依据:调研底稿 agent-cli-survey-2026-08-24.md + 架构 §3.3
# 注意:官方独立安装器 aider-install / PyPI aider-chat;不走 brew;独立 Python 3.12 环境
# ============================================================
id: aider
name: Aider
name_zh: Aider
vendor: aider.chat
status: available
adapter_version: 1.1.0
license: Apache-2.0
platforms:
windows:
architectures: [x64]
notes: 官方 PS 脚本 / PyPI;独立 Python 3.12 环境
linux:
distributions: [ubuntu]
architectures: [x64]
official:
homepage: https://aider.chat
docs: https://aider.chat/docs/
allowed_hosts: [aider.chat, 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: official_script
channels:
- id: official_script
platforms: [windows]
script:
url: https://aider.chat/install.ps1
kind: powershell_irm
elevate: never
post_checks: [detect]
- id: official_script
platforms: [linux]
script:
url: https://aider.chat/install.sh
kind: bash_pipe
elevate: never
post_checks: [detect]
- id: pypi_uv
platforms: [windows, linux]
command: [uv, tool, install, aider-chat]
package: aider-chat
elevate: never
post_checks: [detect]
detect:
executable: aider
version_args: ["--version"]
update:
method: pypi_upgrade
command: [uv, tool, upgrade, aider-chat]
uninstall:
method: package_manager
command: [uv, tool, uninstall, aider-chat]
keep_config_default: true
authorization:
modes:
- mode: api_key
env_keys: [ANTHROPIC_API_KEY, OPENAI_API_KEY]
notes_zh: 仅 API Key:各家模型 KeyAnthropic / OpenAI 等)
# 配置机制(调研底稿 Aider 章节):YAML。用户级 ~/.aider.conf.yml(项目级 .aider.conf.yml)。
configuration:
files:
- path: "~/.aider.conf.yml"
format: yaml
scope: user
environment:
- key: ANTHROPIC_API_KEY
sensitive: true
maps_to_field: api_key
fields:
- id: model
label_zh: 主模型
group: common
help_zh: 官方键 modelAider 主模型(如 anthropic/claude-sonnet-4
required: false
sensitive: false
type: string
storage: file
docs_url: https://aider.chat/docs/configuration/aider_conf.html
- id: api_key
label_zh: API Key
group: auth
help_zh: 存入系统密钥库(对应 ANTHROPIC_API_KEY / OPENAI_API_KEY 等环境变量)
required: false
sensitive: true
type: string
storage: keyring
docs_url: https://aider.chat/docs/configuration/api-keys.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: 安装后配置 API Key,在项目目录运行 `aider`,或用 `aider --message "提示词"` 无头执行。
install_zh: 推荐官方独立安装器 aider-installWindows 用 install.ps1 / Linux 用 install.sh,自动装独立 Python 3.12 环境);也可用 PyPI `uv tool install aider-chat`。不走 brew。
auth_zh: 仅 API Key:各家模型 KeyANTHROPIC_API_KEY / OPENAI_API_KEY 等),保存后经环境变量注入。
commands:
- cmd: aider
desc_zh: 启动交互式会话
- cmd: aider --message "提示词"
desc_zh: 无头单次执行
- cmd: aider --model <模型>
desc_zh: 指定模型
- cmd: aider --version
desc_zh: 显示版本号
- cmd: aider --yes
desc_zh: 自动接受所有建议
params:
- param: --message / --msg
desc_zh: 无头单次执行
- param: --model
desc_zh: 指定模型
- param: --yes
desc_zh: 自动接受所有建议
- param: --edit-format
desc_zh: 编辑格式
- param: --chat-mode
desc_zh: 会话模式
updated_at: "2026-08-25"
risks_zh:
- 不走 brew(官方不推荐,安装路径与依赖易出问题)
- 官方独立安装器自动装独立 Python 3.12 环境,本适配器声明 uv/python 依赖仅针对 PyPI 渠道
- 项目级 .aider.conf.yml 与多 provider 高级配置本表单不覆盖,以官方文档为准
+116 -21
View File
@@ -1,28 +1,123 @@
# ============================================================
# AgentDock 适配器占位(Wave 1,对齐架构 §3.1 完整 schema
# 本波仅填五字段(id/name/name_zh/vendor/status);其余字段留空,
# Wave 2 起按调研底稿 agent-cli-survey-2026-08-24.md 逐项填充,禁止编造。
#
# 完整字段(全部留空占位,Wave 2 填写):
# adapter_version # semver,如 1.2.0
# license # 展示用;专有许可注明「仅官方渠道安装、不重打包」
# platforms # windows/linuxarchitectures、notes、min_ubuntu
# official # homepage / docs / allowed_hosts(网络白名单)
# runtime_deps # [ { id, semver_range, required_for: [install|run] } ]
# install # preferred + channels[ {id, platforms, command[], script, package, elevate, elevate_reason_zh, post_checks} ]
# detect # executable / version_args / version_regex / version_unconfirmed / path_hints
# update # method + command[]
# uninstall # method + command[] + keep_config_default
# authorization # modes[ {mode, command[], env_keys[], status_command[], notes_zh} ]
# configuration # files[] / environment[] / fields[]
# diagnostics # [ { rule_id } ]
# documentation # quickstart_zh / commands[] / updated_at / risks_zh[]
#
# 铁律:所有 command 一律 argv 数组,禁止 shell 元字符(| & ; $ \ > < (`);
# 本波不实现任何真实安装/检测/配置/授权命令(那是 Wave 2 的事)。
# AgentDock 适配器 · ClineWave 3
# 数据依据:调研底稿 agent-cli-survey-2026-08-24.md + 架构 §3.3
# ============================================================
id: cline
name: Cline
name_zh: Cline
vendor: Cline
status: available
adapter_version: 1.1.0
license: Apache-2.0
platforms:
windows:
architectures: [x64]
notes: 原生支持(npm,非 WSL
linux:
distributions: [ubuntu]
architectures: [x64]
official:
homepage: https://github.com/cline/cline
docs: https://docs.cline.bot/
allowed_hosts: [registry.npmjs.org, github.com, docs.cline.bot]
runtime_deps:
- id: node
semver_range: ">=22"
required_for: [install, run]
install:
preferred: npm
channels:
- id: npm
platforms: [windows, linux]
command: [npm, install, -g, "cline"]
package: cline
elevate: never
post_checks: [detect]
- id: npm
platforms: [windows, linux]
command: [npm, install, -g, "cline@nightly"]
package: cline
elevate: never
post_checks: [detect]
detect:
executable: cline
version_args: ["--version"]
update:
method: npm_update
command: [npm, update, -g, "cline"]
uninstall:
method: npm_uninstall
command: [npm, uninstall, -g, "cline"]
keep_config_default: true
authorization:
modes:
- mode: browser_oauth
command: [cline]
notes_zh: 运行 cline 后按提示浏览器登录(各家模型账号)
- mode: api_key
env_keys: [ANTHROPIC_API_KEY, OPENAI_API_KEY]
notes_zh: 各家 API Key(存入系统钥匙串;Cline 密钥本就进系统 keychain
# 配置机制(调研底稿 Cline 章节):用户级 ~/.cline/data/settings/providers.jsonJSON)。
# 密钥已用系统 keychain(与 AgentDock 密钥库策略一致)。
configuration:
files:
- path: "~/.cline/data/settings/providers.json"
format: json
scope: user
environment:
- key: ANTHROPIC_API_KEY
sensitive: true
maps_to_field: api_key
fields:
- 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://docs.cline.bot/
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: 安装后运行 `cline` 登录,或用 `cline -p "提示词"` 无头执行。
install_zh: 推荐 `npm install -g cline`(本适配器默认渠道,需 Node 22+);另有 nightly 通道 `npm install -g cline@nightly`。
auth_zh: ① 浏览器登录(运行 `cline` 后按提示);② API Key(各家模型,存入系统钥匙串);③ 本地模型(Ollama/LM Studio 等)。密钥本就进系统 keychain。
commands:
- cmd: cline
desc_zh: 启动交互式会话
- cmd: cline -p "提示词"
desc_zh: 无头单次执行
- cmd: cline --model <模型>
desc_zh: 指定模型
- cmd: cline --version
desc_zh: 显示版本号
params:
- param: -p / --print
desc_zh: 无头模式执行后退出
- param: --model
desc_zh: 指定模型
- param: --allowedTools
desc_zh: 预授权工具
- param: --continue
desc_zh: 续接最近会话
updated_at: "2026-08-25"
risks_zh:
- 密钥存入系统 keychain(与 AgentDock 密钥库策略一致,不额外落明文)
- providers.json 多 provider 高级配置本表单不覆盖,以官方文档为准
+151 -21
View File
@@ -1,28 +1,158 @@
# ============================================================
# AgentDock 适配器占位(Wave 1,对齐架构 §3.1 完整 schema
# 本波仅填五字段(id/name/name_zh/vendor/status);其余字段留空,
# Wave 2 起按调研底稿 agent-cli-survey-2026-08-24.md 逐项填充,禁止编造。
#
# 完整字段(全部留空占位,Wave 2 填写):
# adapter_version # semver,如 1.2.0
# license # 展示用;专有许可注明「仅官方渠道安装、不重打包」
# platforms # windows/linuxarchitectures、notes、min_ubuntu
# official # homepage / docs / allowed_hosts(网络白名单)
# runtime_deps # [ { id, semver_range, required_for: [install|run] } ]
# install # preferred + channels[ {id, platforms, command[], script, package, elevate, elevate_reason_zh, post_checks} ]
# detect # executable / version_args / version_regex / version_unconfirmed / path_hints
# update # method + command[]
# uninstall # method + command[] + keep_config_default
# authorization # modes[ {mode, command[], env_keys[], status_command[], notes_zh} ]
# configuration # files[] / environment[] / fields[]
# diagnostics # [ { rule_id } ]
# documentation # quickstart_zh / commands[] / updated_at / risks_zh[]
#
# 铁律:所有 command 一律 argv 数组,禁止 shell 元字符(| & ; $ \ > < (`);
# 本波不实现任何真实安装/检测/配置/授权命令(那是 Wave 2 的事)。
# AgentDock 适配器 · CodeBuddy Code(腾讯)(Wave 3
# 数据依据:调研底稿 agent-cli-survey-2026-08-24.md + 架构 §3.3 + 真机实测
# 真机:codebuddy --version → 2.132.0;配置 ~/.codebuddy/settings.jsonJSON,实测补齐)
# 双升级路径:npm@tencent-ai/codebuddy-code/ 原生(codebuddy install stable
# ============================================================
id: codebuddy
name: CodeBuddy Code
name_zh: CodeBuddy Code
vendor: 腾讯
status: available
adapter_version: 1.1.0
license: 专有(仅官方渠道安装、不重打包)
platforms:
windows:
architectures: [x64]
notes: 原生支持(npm / 原生线,非 WSL)
linux:
distributions: [ubuntu]
architectures: [x64]
official:
homepage: https://codebuddy.ai
docs: https://codebuddy.ai/docs
allowed_hosts: [registry.npmjs.org, codebuddy.ai, github.com]
runtime_deps:
- id: node
semver_range: ">=22"
required_for: [install, run]
install:
preferred: npm
channels:
- id: npm
platforms: [windows, linux]
command: [npm, install, -g, "@tencent-ai/codebuddy-code"]
package: "@tencent-ai/codebuddy-code"
elevate: never
post_checks: [detect]
- id: official_script
platforms: [windows, linux]
command: [codebuddy, install, stable]
package: codebuddy-native
elevate: never
post_checks: [detect]
detect:
executable: codebuddy
version_args: ["--version"]
version_regex: "^v?(\\d+\\.\\d+\\.\\d+)"
update:
method: npm_update
command: [npm, update, -g, "@tencent-ai/codebuddy-code"]
uninstall:
method: npm_uninstall
command: [npm, uninstall, -g, "@tencent-ai/codebuddy-code"]
keep_config_default: true
authorization:
modes:
- mode: browser_oauth
command: [codebuddy]
notes_zh: 运行 codebuddy 后按提示用微信扫码登录(也可在会话中输入 /login)
- mode: local_tui
command: [codebuddy]
notes_zh: 在一次性终端窗口内完成微信扫码登录
# 配置机制(真机实测补齐):~/.codebuddy/settings.jsonJSON)。
# 官方未披露配置文件位置,本波真机定位到 ~/.codebuddy/settings.json
# 主要配置经 `codebuddy config set [-g] <key> <value>` 管理,API Key 未确认(以微信登录为主)。
configuration:
files:
- path: "~/.codebuddy/settings.json"
format: json
scope: user
environment: []
fields:
- id: model
label_zh: 默认模型
group: common
help_zh: 默认模型 ID(对应 --model 参数,如 deepseek-v4-pro / glm-5.3 等);可用 `codebuddy config set -g model <id>` 设置
required: false
sensitive: false
type: string
storage: file
docs_url: https://codebuddy.ai/docs
- id: theme
label_zh: 主题
group: common
help_zh: 界面主题(对应 `codebuddy config set -g theme <dark|light>`
required: false
sensitive: false
type: enum
storage: file
docs_url: https://codebuddy.ai/docs
options:
- value: dark
label_zh: 深色
- value: light
label_zh: 浅色
- id: sandbox.enabled
label_zh: 沙箱模式
group: advanced
help_zh: 官方键 sandbox.enabled,是否启用沙箱(默认关闭)
required: false
sensitive: false
type: bool
storage: file
docs_url: https://codebuddy.ai/docs
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: 安装后运行 `codebuddy` 微信扫码登录,或用 `codebuddy -p "提示词"` 无头执行。
install_zh: 双升级路径:① npm `@tencent-ai/codebuddy-code`(本适配器默认渠道,需 Node 22+);② 原生线 `codebuddy install stable`。两线均走官方渠道,不重打包。
auth_zh: 微信扫码登录(运行 `codebuddy` 后按提示,或在会话中输入 /login)。API Key 官方未确认,本适配器不提供。
commands:
- cmd: codebuddy
desc_zh: 启动交互式会话
- cmd: codebuddy -p "提示词"
desc_zh: 无头单次执行(--print
- cmd: codebuddy --output-format json -p "提示词"
desc_zh: 以 JSON 输出
- cmd: codebuddy --model <模型>
desc_zh: 指定模型
- cmd: codebuddy config set -g theme dark
desc_zh: 修改全局配置项
- cmd: codebuddy install stable
desc_zh: 安装原生线稳定版
- cmd: codebuddy update
desc_zh: 检查并安装更新
- cmd: codebuddy doctor
desc_zh: 自更新健康检查
params:
- param: -p / --print
desc_zh: 无头模式,执行后退出
- param: --output-format
desc_zh: 输出格式(text / json / stream-json
- param: --model
desc_zh: 指定模型 ID
- param: -c / --continue
desc_zh: 续接最近会话
- param: -y / --dangerously-skip-permissions
desc_zh: 跳过权限检查(仅限无外网沙箱)
updated_at: "2026-08-25"
risks_zh:
- 配置路径官方未披露,本波真机实测定位到 ~/.codebuddy/settings.json
- API Key 官方未确认,本适配器仅提供微信登录,不提供 API Key 配置
- 专有许可:只引导官方渠道安装,不重打包、不修改二进制
+110 -21
View File
@@ -1,28 +1,117 @@
# ============================================================
# AgentDock 适配器占位(Wave 1,对齐架构 §3.1 完整 schema
# 本波仅填五字段(id/name/name_zh/vendor/status);其余字段留空,
# Wave 2 起按调研底稿 agent-cli-survey-2026-08-24.md 逐项填充,禁止编造
#
# 完整字段(全部留空占位,Wave 2 填写):
# adapter_version # semver,如 1.2.0
# license # 展示用;专有许可注明「仅官方渠道安装、不重打包」
# platforms # windows/linuxarchitectures、notes、min_ubuntu
# official # homepage / docs / allowed_hosts(网络白名单)
# runtime_deps # [ { id, semver_range, required_for: [install|run] } ]
# install # preferred + channels[ {id, platforms, command[], script, package, elevate, elevate_reason_zh, post_checks} ]
# detect # executable / version_args / version_regex / version_unconfirmed / path_hints
# update # method + command[]
# uninstall # method + command[] + keep_config_default
# authorization # modes[ {mode, command[], env_keys[], status_command[], notes_zh} ]
# configuration # files[] / environment[] / fields[]
# diagnostics # [ { rule_id } ]
# documentation # quickstart_zh / commands[] / updated_at / risks_zh[]
#
# 铁律:所有 command 一律 argv 数组,禁止 shell 元字符(| & ; $ \ > < (`);
# 本波不实现任何真实安装/检测/配置/授权命令(那是 Wave 2 的事)。
# AgentDock 适配器 · GitHub Copilot CLIWave 3
# 数据依据:调研底稿 agent-cli-survey-2026-08-24.md + 架构 §3.3
# 备注:登录方式以本机终端 /login 为主;无头用 PAT 环境变量授权;需 Copilot 订阅
# ============================================================
id: copilot
name: Copilot CLI
name_zh: Copilot CLI
vendor: GitHub
status: available
adapter_version: 1.1.0
license: 专有(仅官方渠道安装、不重打包)
platforms:
windows:
architectures: [x64]
notes: winget 渠道需 PowerShell 6+winget 安装 GitHub.Copilot
linux:
distributions: [ubuntu]
architectures: [x64]
official:
homepage: https://github.com/features/copilot
docs: https://docs.github.com/copilot
allowed_hosts: [github.com, docs.github.com, registry.npmjs.org]
runtime_deps: []
install:
preferred: winget
channels:
- id: winget
platforms: [windows]
command: [winget, install, GitHub.Copilot]
package: GitHub.Copilot
elevate: never
post_checks: [detect]
- id: npm
platforms: [windows, linux]
command: [npm, install, -g, "@github/copilot"]
package: "@github/copilot"
elevate: never
post_checks: [detect]
detect:
executable: copilot
version_args: ["--version"]
version_regex: "(\\d+\\.\\d+\\.\\d+)"
update:
method: winget_upgrade
command: [winget, upgrade, GitHub.Copilot]
uninstall:
method: package_manager
command: [winget, uninstall, GitHub.Copilot]
keep_config_default: true
authorization:
modes:
- mode: local_tui
command: [copilot]
notes_zh: 运行 copilot 后输入 /login 在终端内登录 GitHub 账号
- mode: api_key
env_keys: [GH_TOKEN]
notes_zh: 无头授权:设置 GitHub PAT(GH_TOKEN)环境变量后无需交互登录
# 配置机制(调研底稿 Copilot 章节):JSON + 环境变量(COPILOT_*)。
# 无头授权用 PAT 环境变量;交互登录经 /login 写回本机会话。
configuration:
files: []
environment:
- key: GH_TOKEN
sensitive: true
maps_to_field: api_key
- key: COPILOT_API_KEY
sensitive: true
maps_to_field: api_key
fields:
- id: api_key
label_zh: 访问令牌(PAT
group: auth
help_zh: 存入系统密钥库(对应 GH_TOKEN / COPILOT_API_KEY),用于无头授权;需 Copilot 订阅
required: false
sensitive: true
type: string
storage: keyring
docs_url: https://docs.github.com/copilot
diagnostics:
- rule_id: path.not_installed
- rule_id: path.not_in_path
- rule_id: version_conflict.multiple_copies
documentation:
quickstart_zh: 安装后运行 `copilot`,在终端输入 `/login` 登录 GitHub 账号;或用 `copilot -p "提示词"` 无头执行。
install_zh: Windows 推荐 winget `GitHub.Copilot`(需 PowerShell 6+);双端可用 npm `@github/copilot`。Ubuntu 另有官方脚本 / brew 渠道。
auth_zh: ① 本机终端登录:运行 `copilot` 输入 /login(交互);② 无头 PAT:设置 GH_TOKEN(或 COPILOT_API_KEY)环境变量。需要有效 Copilot 订阅(个人/商业/企业)。
commands:
- cmd: copilot
desc_zh: 启动交互式会话
- cmd: copilot -p "提示词"
desc_zh: 无头单次执行
- cmd: copilot --help
desc_zh: 查看帮助
params:
- param: -p / --print
desc_zh: 无头模式执行后退出
- param: --output-format
desc_zh: 输出格式
- param: --model
desc_zh: 指定模型
updated_at: "2026-08-25"
risks_zh:
- 需要有效 Copilot 订阅(免费 GitHub 账号不提供 Copilot CLI 完整能力)
- 真机 `copilot --version` 返回「GitHub Copilot CLI 1.0.80.」,版本检测已据此确认
- winget 渠道需 PowerShell 6+(见 platforms.notes
+134 -21
View File
@@ -1,28 +1,141 @@
# ============================================================
# AgentDock 适配器占位(Wave 1,对齐架构 §3.1 完整 schema
# 本波仅填五字段(id/name/name_zh/vendor/status);其余字段留空,
# Wave 2 起按调研底稿 agent-cli-survey-2026-08-24.md 逐项填充,禁止编造
#
# 完整字段(全部留空占位,Wave 2 填写):
# adapter_version # semver,如 1.2.0
# license # 展示用;专有许可注明「仅官方渠道安装、不重打包」
# platforms # windows/linuxarchitectures、notes、min_ubuntu
# official # homepage / docs / allowed_hosts(网络白名单)
# runtime_deps # [ { id, semver_range, required_for: [install|run] } ]
# install # preferred + channels[ {id, platforms, command[], script, package, elevate, elevate_reason_zh, post_checks} ]
# detect # executable / version_args / version_regex / version_unconfirmed / path_hints
# update # method + command[]
# uninstall # method + command[] + keep_config_default
# authorization # modes[ {mode, command[], env_keys[], status_command[], notes_zh} ]
# configuration # files[] / environment[] / fields[]
# diagnostics # [ { rule_id } ]
# documentation # quickstart_zh / commands[] / updated_at / risks_zh[]
#
# 铁律:所有 command 一律 argv 数组,禁止 shell 元字符(| & ; $ \ > < (`);
# 本波不实现任何真实安装/检测/配置/授权命令(那是 Wave 2 的事)。
# AgentDock 适配器 · CrushCharm)(Wave 3
# 数据依据:调研底稿 agent-cli-survey-2026-08-24.md + 架构 §3.3
# 注意:crushrc 是 Bash 语法配置,走 Wave 1 专用解析器(只识别内建赋值,不执行任意脚本)
# ============================================================
id: crush
name: Crush
name_zh: Crush
vendor: Charm
status: available
adapter_version: 1.1.0
license: FSLFunctional Source License,仅引导官方安装、不重分发源码)
platforms:
windows:
architectures: [x64]
notes: winget / scoop / Releases 渠道
linux:
distributions: [ubuntu]
architectures: [x64]
official:
homepage: https://github.com/charmbracelet/crush
docs: https://github.com/charmbracelet/crush
allowed_hosts: [github.com, charm.sh]
runtime_deps: []
install:
preferred: winget
channels:
- id: winget
platforms: [windows]
command: [winget, install, charmbracelet.crush]
package: charmbracelet.crush
elevate: never
post_checks: [detect]
- id: npm
platforms: [windows, linux]
command: [npm, install, -g, "@charmland/crush"]
package: "@charmland/crush"
elevate: never
post_checks: [detect]
- id: apt
platforms: [linux]
command: [sudo, apt-get, install, -y, crush]
package: crush
elevate: required
elevate_reason_zh: 官方 apt 仓库(GPG)安装需要管理员权限
post_checks: [detect]
- id: github_release
platforms: [windows, linux]
package: crush
elevate: never
post_checks: [detect]
detect:
executable: crush
version_args: ["--version"]
version_unconfirmed: true
update:
method: winget_upgrade
command: [winget, upgrade, charmbracelet.crush]
uninstall:
method: package_manager
command: [winget, uninstall, charmbracelet.crush]
keep_config_default: true
authorization:
modes:
- mode: api_key
env_keys: [OPENAI_API_KEY, ANTHROPIC_API_KEY]
notes_zh: 各家模型 API Key(环境变量注入);crushrc 内亦可用 export 语句
# 配置机制:crushrc 是 Bash 语法配置(~/.config/crush/crushrc)。
# 走 Wave 1 专用解析器:只识别内建赋值(export KEY=value 等),不执行任意脚本,未知行原样保留。
configuration:
files:
- path: "~/.config/crush/crushrc"
format: crushrc
scope: user
environment:
- key: OPENAI_API_KEY
sensitive: true
maps_to_field: api_key
fields:
- id: api_key
label_zh: API Key
group: auth
help_zh: 存入系统密钥库(对应 OPENAI_API_KEY 等环境变量);crushrc 内也可用 export 注入
required: false
sensitive: true
type: string
storage: keyring
docs_url: https://github.com/charmbracelet/crush
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: 安装后配置 API Key,运行 `crush` 进入会话,或用 `crush -p "提示词"` 无头执行。
install_zh: Windows 推荐 winget `charmbracelet.crush`Ubuntu 用官方 apt 仓库(GPG)。备选 npm `@charmland/crush` / scoop / GitHub Releases。
auth_zh: 仅 API Key(环境变量注入):OPENAI_API_KEY / ANTHROPIC_API_KEY 等各家模型 Key。crushrc 内也可用 export 语句。
commands:
- cmd: crush
desc_zh: 启动交互式会话
- cmd: crush "提示词"
desc_zh: 直接以提示词启动
- cmd: crush -p "提示词"
desc_zh: 无头单次执行
- cmd: crush --session <名称>
desc_zh: 指定会话名
- cmd: crush --continue
desc_zh: 续接最近会话
- cmd: crush --list-sessions
desc_zh: 列出会话
- cmd: crush --model <模型>
desc_zh: 指定模型
- cmd: crush --version
desc_zh: 显示版本号
params:
- param: -p / --print
desc_zh: 无头模式单次执行
- param: --session
desc_zh: 指定会话名
- param: --continue
desc_zh: 续接最近会话
- param: --model
desc_zh: 指定模型
- param: --list-sessions
desc_zh: 列出会话
updated_at: "2026-08-25"
risks_zh:
- crushrc 是 Bash 语法配置,AgentDock 走专用解析器,只识别内建赋值、不执行任意脚本
- --version 官方未确认,适配器已标 version_unconfirmed(命令细节以 `crush --help` 实测为准)
- FSL 许可:仅引导官方安装,不重分发源码、不修改二进制
+149 -21
View File
@@ -1,28 +1,156 @@
# ============================================================
# AgentDock 适配器占位(Wave 1,对齐架构 §3.1 完整 schema
# 本波仅填五字段(id/name/name_zh/vendor/status);其余字段留空,
# Wave 2 起按调研底稿 agent-cli-survey-2026-08-24.md 逐项填充,禁止编造
#
# 完整字段(全部留空占位,Wave 2 填写):
# adapter_version # semver,如 1.2.0
# license # 展示用;专有许可注明「仅官方渠道安装、不重打包」
# platforms # windows/linuxarchitectures、notes、min_ubuntu
# official # homepage / docs / allowed_hosts(网络白名单)
# runtime_deps # [ { id, semver_range, required_for: [install|run] } ]
# install # preferred + channels[ {id, platforms, command[], script, package, elevate, elevate_reason_zh, post_checks} ]
# detect # executable / version_args / version_regex / version_unconfirmed / path_hints
# update # method + command[]
# uninstall # method + command[] + keep_config_default
# authorization # modes[ {mode, command[], env_keys[], status_command[], notes_zh} ]
# configuration # files[] / environment[] / fields[]
# diagnostics # [ { rule_id } ]
# documentation # quickstart_zh / commands[] / updated_at / risks_zh[]
#
# 铁律:所有 command 一律 argv 数组,禁止 shell 元字符(| & ; $ \ > < (`);
# 本波不实现任何真实安装/检测/配置/授权命令(那是 Wave 2 的事)。
# AgentDock 适配器 · Cursor CLIWave 3
# 数据依据:调研底稿 agent-cli-survey-2026-08-24.md + 架构 §3.3 + 真机实测
# 真机:agent --version → 2026.08.11-e8db854;配置 ~/.cursor/cli-config.jsonJSON
# 铁律:命令名是 `agent`(不是 cursor-agent);只有官方脚本渠道,无 npm。
# ============================================================
id: cursor
name: Cursor CLI
name_zh: Cursor CLI
vendor: Cursor
status: available
adapter_version: 1.1.0
license: 专有(仅官方渠道安装、不重打包)
platforms:
windows:
architectures: [x64]
notes: 仅官方脚本渠道;命令名为 agent
linux:
distributions: [ubuntu]
architectures: [x64]
official:
homepage: https://cursor.com
docs: https://cursor.com/docs/cli
allowed_hosts: [cursor.com, api2.cursor.sh]
runtime_deps: []
install:
preferred: official_script
channels:
- id: official_script
platforms: [windows]
script:
url: https://cursor.com/install?win32=true
kind: powershell_irm
elevate: never
post_checks: [detect]
- id: official_script
platforms: [linux]
script:
url: https://cursor.com/install
kind: bash_pipe
elevate: never
post_checks: [detect]
detect:
executable: agent
version_args: ["--version"]
version_regex: "^(\\d{4}\\.\\d{2}\\.\\d{2})"
path_hints:
- "%LOCALAPPDATA%\\cursor-agent\\agent.cmd"
update:
method: self_update_cmd
command: [agent, update]
uninstall:
method: manual_delete
command: []
keep_config_default: true
authorization:
modes:
- mode: browser_oauth
command: [agent, login]
status_command: [agent, status]
notes_zh: 浏览器登录 Cursor 账号(NO_OPEN_BROWSER 可禁用自动打开浏览器)
- mode: api_key
env_keys: [CURSOR_API_KEY]
notes_zh: API KeyCURSOR_API_KEY 环境变量或 --api-key 参数)
# 配置机制(真机实测):~/.cursor/cli-config.jsonJSON)。
# 账户体系为主(authInfo 存 OAuth 登录态);model.modelId / approvalMode 可经文件或 agent 命令调整。
configuration:
files:
- path: "~/.cursor/cli-config.json"
format: json
scope: user
environment:
- key: CURSOR_API_KEY
sensitive: true
maps_to_field: api_key
fields:
- id: model.modelId
label_zh: 默认模型
group: common
help_zh: 官方键 model.modelId,默认模型(真机为 default,即 auto
required: false
sensitive: false
type: string
storage: file
docs_url: https://cursor.com/docs/cli
- id: approvalMode
label_zh: 审批模式
group: advanced
help_zh: 官方键 approvalModeallowlist / yolo / default 等)
required: false
sensitive: false
type: string
storage: file
docs_url: https://cursor.com/docs/cli
- id: api_key
label_zh: API Key
group: auth
help_zh: 存入系统密钥库(对应 CURSOR_API_KEY 环境变量;一般用账号登录即可,无需 API Key)
required: false
sensitive: true
type: string
storage: keyring
docs_url: https://cursor.com/docs/cli
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: 安装后运行 `agent login` 登录 Cursor 账号,或用 `agent -p "提示词"` 无头执行。
install_zh: 仅官方脚本渠道(Windows `https://cursor.com/install?win32=true` / Linux `https://cursor.com/install`),无 npm。`agent` 命令随 Cursor 安装(也可在 Cursor 内「Install agent command」)。
auth_zh: ① 浏览器登录:`agent login``agent status` 查看状态);② API KeyCURSOR_API_KEY 环境变量或 --api-key。
commands:
- cmd: agent
desc_zh: 启动交互式会话
- cmd: agent -p "提示词"
desc_zh: 无头单次执行
- cmd: agent login
desc_zh: 浏览器登录 Cursor 账号
- cmd: agent status
desc_zh: 查看认证状态
- cmd: agent --model <模型>
desc_zh: 指定模型
- cmd: agent update
desc_zh: 更新到最新版
- cmd: agent --list-models
desc_zh: 列出可用模型
- cmd: agent --version
desc_zh: 显示版本号
params:
- param: -p / --print
desc_zh: 无头模式执行后退出
- param: --model
desc_zh: 指定模型
- param: --output-format
desc_zh: 输出格式(text / json / stream-json
- param: --sandbox
desc_zh: 沙箱模式(enabled / disabled
- param: --list-models
desc_zh: 列出可用模型
updated_at: "2026-08-25"
risks_zh:
- 命令名是 `agent`(不是 cursor-agent),detect 以 agent 为准
- 真机 `agent --version` 返回日期式版本 2026.08.11-e8db854version_regex 已据此填写)
- 账户体系为主,cli-config.json 高级键本表单只覆盖常用项,以官方文档为准
+144 -21
View File
@@ -1,28 +1,151 @@
# ============================================================
# AgentDock 适配器占位(Wave 1,对齐架构 §3.1 完整 schema
# 本波仅填五字段(id/name/name_zh/vendor/status);其余字段留空,
# Wave 2 起按调研底稿 agent-cli-survey-2026-08-24.md 逐项填充,禁止编造
#
# 完整字段(全部留空占位,Wave 2 填写):
# adapter_version # semver,如 1.2.0
# license # 展示用;专有许可注明「仅官方渠道安装、不重打包」
# platforms # windows/linuxarchitectures、notes、min_ubuntu
# official # homepage / docs / allowed_hosts(网络白名单)
# runtime_deps # [ { id, semver_range, required_for: [install|run] } ]
# install # preferred + channels[ {id, platforms, command[], script, package, elevate, elevate_reason_zh, post_checks} ]
# detect # executable / version_args / version_regex / version_unconfirmed / path_hints
# update # method + command[]
# uninstall # method + command[] + keep_config_default
# authorization # modes[ {mode, command[], env_keys[], status_command[], notes_zh} ]
# configuration # files[] / environment[] / fields[]
# diagnostics # [ { rule_id } ]
# documentation # quickstart_zh / commands[] / updated_at / risks_zh[]
#
# 铁律:所有 command 一律 argv 数组,禁止 shell 元字符(| & ; $ \ > < (`);
# 本波不实现任何真实安装/检测/配置/授权命令(那是 Wave 2 的事)。
# AgentDock 适配器 · GooseLinux 基金会 AAIF / Block)(Wave 3
# 数据依据:调研底稿 agent-cli-survey-2026-08-24.md + 架构 §3.3
# 注意:配置 YAMLWindows 路径差异 %APPDATA%\Block\goose\config\;密钥默认进系统钥匙串
# ============================================================
id: goose
name: Goose
name_zh: Goose
vendor: Linux 基金会(AAIF
status: available
adapter_version: 1.1.0
license: Apache-2.0
platforms:
windows:
architectures: [x64]
notes: 官方 download_cli.ps1;配置位于 %APPDATA%\Block\goose\config\
linux:
distributions: [ubuntu]
architectures: [x64]
official:
homepage: https://block.github.io/goose/
docs: https://block.github.io/goose/docs/
allowed_hosts: [github.com, raw.githubusercontent.com, block.github.io]
runtime_deps: []
install:
preferred: official_script
channels:
- id: official_script
platforms: [windows]
script:
url: https://raw.githubusercontent.com/aaif-goose/goose/main/download_cli.ps1
kind: powershell_irm
elevate: never
post_checks: [detect]
- id: official_script
platforms: [linux]
script:
url: https://github.com/aaif-goose/goose/releases/download/stable/download_cli.sh
kind: bash_pipe
elevate: never
post_checks: [detect]
- id: github_release
platforms: [windows, linux]
package: goose
elevate: never
post_checks: [detect]
detect:
executable: goose
version_args: ["--version"]
update:
method: self_update_cmd
command: [goose, update]
uninstall:
method: manual_delete
command: []
keep_config_default: true
authorization:
modes:
- mode: api_key
env_keys: [ANTHROPIC_API_KEY, OPENAI_API_KEY]
notes_zh: 各家模型 API Key(密钥默认进系统钥匙串);另有订阅 ACP 接入
# 配置机制(调研底稿 Goose 章节):YAML。密钥默认进系统钥匙串,不写入 config.yaml。
# 按平台选型(引擎按 files[].platforms 过滤当前平台):
# Windows -> %APPDATA%\Block\goose\config\config.yaml(比旧版多一层 config\
# Linux -> ~/.config/goose/config.yaml
configuration:
files:
- path: "%APPDATA%\\Block\\goose\\config\\config.yaml"
format: yaml
scope: user
platforms: [windows]
- path: "~/.config/goose/config.yaml"
format: yaml
scope: user
platforms: [linux]
environment:
- key: ANTHROPIC_API_KEY
sensitive: true
maps_to_field: api_key
fields:
- id: GOOSE_PROVIDER
label_zh: 默认 Provider
group: common
help_zh: 官方键 GOOSE_PROVIDER(如 anthropic / openai / databricks / ollama 等)
required: false
sensitive: false
type: string
storage: file
docs_url: https://block.github.io/goose/docs/
- id: GOOSE_MODEL
label_zh: 默认模型
group: common
help_zh: 官方键 GOOSE_MODEL,默认模型
required: false
sensitive: false
type: string
storage: file
docs_url: https://block.github.io/goose/docs/
- id: api_key
label_zh: API Key
group: auth
help_zh: 存入系统钥匙串(Goose 密钥本就默认进系统钥匙串,不写入 config.yaml
required: false
sensitive: true
type: string
storage: keyring
docs_url: https://block.github.io/goose/docs/
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: 安装后配置 provider 与 API Key,运行 `goose` 进入会话,或用 `goose -p "提示词"` 无头执行。
install_zh: Windows 用官方 download_cli.ps1Ubuntu 用官方 download_cli.sh。备选 GitHub Releases 二进制 / brew / deb。
auth_zh: ① API Key:各家模型 KeyAnthropic/OpenAI 等),密钥默认进系统钥匙串;② 订阅 ACPAgent Client Protocol)接入。
commands:
- cmd: goose
desc_zh: 启动交互式会话
- cmd: goose -p "提示词"
desc_zh: 无头单次执行
- cmd: goose configure
desc_zh: 交互式配置 provider/模型
- cmd: goose --version
desc_zh: 显示版本号
params:
- param: -p / --print
desc_zh: 无头模式执行后退出
- param: --provider
desc_zh: 指定 provider
- param: --model
desc_zh: 指定模型
- param: --version
desc_zh: 显示版本号
updated_at: "2026-08-25"
risks_zh:
- 安装 URL 与配置路径已按官方与调研底稿核对(aaif-goose 仓库;Windows 为 %APPDATA%\Block\goose\config\config.yamlLinux 为 ~/.config/goose/config.yaml
- Windows 配置路径比旧版多一层 config\%APPDATA%\Block\goose\config\),引擎按 files[].platforms 选型
- 密钥默认进系统钥匙串,AgentDock 与之对齐,不把 Key 写入 config.yaml
- extensions / profiles 等高级配置本表单不覆盖,以官方文档为准
+140 -21
View File
@@ -1,28 +1,147 @@
# ============================================================
# AgentDock 适配器占位(Wave 1,对齐架构 §3.1 完整 schema
# 本波仅填五字段(id/name/name_zh/vendor/status);其余字段留空,
# Wave 2 起按调研底稿 agent-cli-survey-2026-08-24.md 逐项填充,禁止编造。
#
# 完整字段(全部留空占位,Wave 2 填写):
# adapter_version # semver,如 1.2.0
# license # 展示用;专有许可注明「仅官方渠道安装、不重打包」
# platforms # windows/linuxarchitectures、notes、min_ubuntu
# official # homepage / docs / allowed_hosts(网络白名单)
# runtime_deps # [ { id, semver_range, required_for: [install|run] } ]
# install # preferred + channels[ {id, platforms, command[], script, package, elevate, elevate_reason_zh, post_checks} ]
# detect # executable / version_args / version_regex / version_unconfirmed / path_hints
# update # method + command[]
# uninstall # method + command[] + keep_config_default
# authorization # modes[ {mode, command[], env_keys[], status_command[], notes_zh} ]
# configuration # files[] / environment[] / fields[]
# diagnostics # [ { rule_id } ]
# documentation # quickstart_zh / commands[] / updated_at / risks_zh[]
#
# 铁律:所有 command 一律 argv 数组,禁止 shell 元字符(| & ; $ \ > < (`);
# 本波不实现任何真实安装/检测/配置/授权命令(那是 Wave 2 的事)。
# AgentDock 适配器 · Qwen Code(阿里巴巴)(Wave 3
# 数据依据:调研底稿 agent-cli-survey-2026-08-24.md + 架构 §3.3 + 真机实测
# 真机:qwen --version → 0.21.15;配置 ~/.qwen/settings.jsonJSONenv/modelProviders/model
# ============================================================
id: qwen
name: Qwen Code
name_zh: Qwen Code
vendor: 阿里巴巴
status: available
adapter_version: 1.1.0
license: Apache-2.0
platforms:
windows:
architectures: [x64]
notes: 原生支持(npm / 阿里 OSS 独立脚本,非 WSL)
linux:
distributions: [ubuntu]
architectures: [x64]
official:
homepage: https://github.com/QwenLM/qwen-code
docs: https://qwen.ai/
allowed_hosts: [registry.npmjs.org, github.com, qwen.ai, dashscope.aliyuncs.com]
runtime_deps:
- id: node
semver_range: ">=22"
required_for: [install, run]
install:
preferred: npm
channels:
- id: npm
platforms: [windows, linux]
command: [npm, install, -g, "@qwen-code/qwen-code"]
package: "@qwen-code/qwen-code"
elevate: never
post_checks: [detect]
detect:
executable: qwen
version_args: ["--version"]
version_regex: "^v?(\\d+\\.\\d+\\.\\d+)"
update:
method: npm_update
command: [npm, update, -g, "@qwen-code/qwen-code"]
uninstall:
method: npm_uninstall
command: [npm, uninstall, -g, "@qwen-code/qwen-code"]
keep_config_default: true
authorization:
modes:
- mode: api_key
env_keys: [DASHSCOPE_API_KEY]
notes_zh: 免费 OAuth 已停用(qwen auth 命令已移除),仅支持 API Key(阿里云百炼 DashScope;也可在 settings.json 接 OpenAI 兼容端点)
# 配置机制(真机 ~/.qwen/settings.jsonJSON):model.name / model.baseUrl 设默认模型;
# env 块注入环境变量(API Key);modelProviders 接多 provider(含 OpenAI 兼容端点)。
configuration:
files:
- path: "~/.qwen/settings.json"
format: json
scope: user
environment:
- key: DASHSCOPE_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://qwen.ai/
- id: model.baseUrl
label_zh: Base URL
group: advanced
help_zh: 官方键 model.baseUrl,默认模型服务端点(OpenAI 兼容网关/代理场景)
required: false
sensitive: false
type: url
storage: file
docs_url: https://qwen.ai/
- id: api_key
label_zh: API Key
group: auth
help_zh: 存入系统密钥库(对应环境变量 DASHSCOPE_API_KEY,阿里云百炼申请)
required: false
sensitive: true
type: string
storage: keyring
docs_url: https://qwen.ai/
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: 安装后配置 API Key,用 `qwen -p "提示词"` 无头执行,或 `qwen` 进入交互会话。
install_zh: 推荐 `npm install -g @qwen-code/qwen-code`(需 Node 22+);也可用 `npx @qwen-code/qwen-code` 临时试用。阿里 OSS 独立脚本见官方文档。
auth_zh: 仅支持 API Key:免费 OAuth 已停用(`qwen auth` 命令已移除)。请在阿里云百炼(DashScope)申请 API Key 后粘贴保存(经 DASHSCOPE_API_KEY 注入);也可在 settings.json 的 modelProviders 接 OpenAI 兼容端点(自定义 envKey)。
commands:
- cmd: qwen
desc_zh: 启动交互式会话
- cmd: qwen -p "提示词"
desc_zh: 无头单次执行
- cmd: qwen -m <模型>
desc_zh: 指定模型
- cmd: qwen -o json -p "提示词"
desc_zh: 以 JSON 输出
- cmd: qwen --version
desc_zh: 显示版本号
- cmd: qwen update
desc_zh: 检查并安装更新
- cmd: qwen sessions
desc_zh: 管理会话
- cmd: npx @qwen-code/qwen-code
desc_zh: 免安装临时运行
params:
- param: -p / --prompt
desc_zh: 无头模式单次执行
- param: -m / --model
desc_zh: 指定模型
- param: -o / --output-format
desc_zh: 输出格式(text / json / stream-json
- param: -s / --sandbox
desc_zh: 沙箱执行
- param: -c / --continue
desc_zh: 续接最近会话
- param: -v / --version
desc_zh: 显示版本号
updated_at: "2026-08-25"
risks_zh:
- 免费 OAuth 已停用,仅支持 API Key(真机 `qwen auth` 命令显示 removed
- 真机 `qwen --version` 返回 0.21.15,版本检测已据此确认
- modelProviders 多 provider / env 块自定义 envKey 等高级配置本表单不覆盖,以官方文档为准
+115 -21
View File
@@ -1,28 +1,122 @@
# ============================================================
# AgentDock 适配器占位(Wave 1,对齐架构 §3.1 完整 schema
# 本波仅填五字段(id/name/name_zh/vendor/status);其余字段留空,
# Wave 2 起按调研底稿 agent-cli-survey-2026-08-24.md 逐项填充,禁止编造
#
# 完整字段(全部留空占位,Wave 2 填写):
# adapter_version # semver,如 1.2.0
# license # 展示用;专有许可注明「仅官方渠道安装、不重打包」
# platforms # windows/linuxarchitectures、notes、min_ubuntu
# official # homepage / docs / allowed_hosts(网络白名单)
# runtime_deps # [ { id, semver_range, required_for: [install|run] } ]
# install # preferred + channels[ {id, platforms, command[], script, package, elevate, elevate_reason_zh, post_checks} ]
# detect # executable / version_args / version_regex / version_unconfirmed / path_hints
# update # method + command[]
# uninstall # method + command[] + keep_config_default
# authorization # modes[ {mode, command[], env_keys[], status_command[], notes_zh} ]
# configuration # files[] / environment[] / fields[]
# diagnostics # [ { rule_id } ]
# documentation # quickstart_zh / commands[] / updated_at / risks_zh[]
#
# 铁律:所有 command 一律 argv 数组,禁止 shell 元字符(| & ; $ \ > < (`);
# 本波不实现任何真实安装/检测/配置/授权命令(那是 Wave 2 的事)。
# AgentDock 适配器 · Warp Agent CLIWave 3
# 数据依据:调研底稿 agent-cli-survey-2026-08-24.md + 架构 §3.3
# 注意:只管 Agent CLI(命令 warp),不管 Warp 终端本体;设备码+浏览器+API Key 三授权
# ============================================================
id: warp
name: Warp Agent CLI
name_zh: Warp Agent CLI
vendor: Warp
status: available
adapter_version: 1.1.0
license: 专有(仅官方渠道安装、不重打包)
platforms:
windows:
architectures: [x64]
notes: 官方 agent-cli.ps1 脚本渠道
linux:
distributions: [ubuntu]
architectures: [x64]
official:
homepage: https://www.warp.dev
docs: https://docs.warp.dev/agent-mode
allowed_hosts: [warp.dev, app.warp.dev, www.warp.dev]
runtime_deps: []
install:
preferred: official_script
channels:
- id: official_script
platforms: [windows]
script:
url: https://app.warp.dev/download/agent-cli.ps1
kind: powershell_irm
elevate: never
post_checks: [detect]
- id: official_script
platforms: [linux]
script:
url: https://app.warp.dev/download/agent-cli
kind: bash_pipe
elevate: never
post_checks: [detect]
detect:
executable: warp
version_args: ["--version"]
update:
method: self_update_cmd
command: [warp, update]
uninstall:
method: manual_delete
command: []
keep_config_default: true
authorization:
modes:
- mode: browser_oauth
command: [warp, login]
notes_zh: 浏览器登录 Warp 账号
- mode: device_code
command: [warp, login]
notes_zh: 设备码登录(不弹浏览器)
- mode: api_key
env_keys: [WARP_API_KEY]
notes_zh: WARP_API_KEY 环境变量(API Key 授权)
# 配置机制(调研底稿 Warp 章节):Warp 账户体系为主,API Key 经 WARP_API_KEY 环境变量。
configuration:
files: []
environment:
- key: WARP_API_KEY
sensitive: true
maps_to_field: api_key
fields:
- id: api_key
label_zh: API Key
group: auth
help_zh: 存入系统密钥库(对应 WARP_API_KEY 环境变量);一般用账号登录即可
required: false
sensitive: true
type: string
storage: keyring
docs_url: https://docs.warp.dev/agent-mode
diagnostics:
- rule_id: path.not_installed
- rule_id: path.not_in_path
- rule_id: version_conflict.multiple_copies
documentation:
quickstart_zh: 安装后运行 `warp login` 登录 Warp 账号,或用 `warp -p "提示词"` 无头执行。
install_zh: 仅官方脚本渠道:Windows agent-cli.ps1 / Linux curl 脚本。只管 Agent CLI,不管 Warp 终端本体。
auth_zh: 三授权:① 浏览器登录(`warp login`);② 设备码(`warp login`,不弹浏览器);③ API KeyWARP_API_KEY 环境变量)。
commands:
- cmd: warp
desc_zh: 启动交互式会话
- cmd: warp -p "提示词"
desc_zh: 无头单次执行
- cmd: warp login
desc_zh: 登录 Warp 账号
- cmd: warp logout
desc_zh: 退出登录
- cmd: warp --version
desc_zh: 显示版本号
params:
- param: -p / --print
desc_zh: 无头模式执行后退出
- param: --model
desc_zh: 指定模型
- param: --output-format
desc_zh: 输出格式
- param: --version
desc_zh: 显示版本号
updated_at: "2026-08-25"
risks_zh:
- 只管 Agent CLI(命令 warp),不管 Warp 终端本体
- 官方脚本 URL 以 docs.warp.dev/agent-mode 为准,安装前请核对
+36 -1
View File
@@ -7,7 +7,7 @@
use std::collections::BTreeMap;
use agentdock_adapter::{AdapterAction, DryRunPlan};
use agentdock_core::{ActionEvent, ActionOpts, AuthStatus, ConfigFormState, ConfigVerifyResult, DetectResult, Engine, WriteResult};
use agentdock_core::{ActionEvent, ActionOpts, AuthFlowEvent, AuthStatus, ConfigFormState, ConfigVerifyResult, DetectResult, Engine, WriteResult};
use agentdock_diag::DiagnosticReport;
use serde_json::json;
use tauri::Emitter;
@@ -125,9 +125,44 @@ pub fn auth_status(id: String, state: tauri::State<'_, Engine>) -> Result<AuthSt
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,3 +1,4 @@
pub mod catalog;
pub mod cli;
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(())
}
+6
View File
@@ -56,7 +56,13 @@ pub fn run() {
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!())
.expect("error while running tauri application");
+18 -3
View File
@@ -29,6 +29,7 @@ function envWarningCount(env: PlatformEnv | null): number {
export default function App() {
const [page, setPage] = useState<PageKey>("overview");
const [detailCliId, setDetailCliId] = useState<string | null>(null);
const [pendingRuntime, setPendingRuntime] = useState<string | null>(null);
const { env } = useEnv();
const warningCount = envWarningCount(env);
@@ -37,13 +38,27 @@ export default function App() {
setPage(next);
}
// 从详情页/其它页直达本机环境区某个运行时的一键安装(联动 Wave 2.2 Req 1/3
function openRuntimeInstall(runtime: string) {
setDetailCliId(null);
setPage("overview");
setPendingRuntime(runtime);
}
const content = useMemo(() => {
if (detailCliId) {
return <CliDetailPage id={detailCliId} onBack={() => setDetailCliId(null)} />;
return <CliDetailPage id={detailCliId} onBack={() => setDetailCliId(null)} onInstallRuntime={openRuntimeInstall} />;
}
switch (page) {
case "overview":
return <OverviewPage onNavigate={navigate} onOpenDetail={setDetailCliId} />;
return (
<OverviewPage
onNavigate={navigate}
onOpenDetail={setDetailCliId}
pendingRuntime={pendingRuntime}
onRuntimeHandled={() => setPendingRuntime(null)}
/>
);
case "catalog":
return <CatalogPage onOpenDetail={setDetailCliId} />;
case "my-cli":
@@ -55,7 +70,7 @@ export default function App() {
case "settings":
return <SettingsPage />;
}
}, [page, detailCliId, env]);
}, [page, detailCliId, env, pendingRuntime]);
const title = detailCliId ? "CLI 详情" : PAGE_META[page].title;
+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>
);
}
+6 -32
View File
@@ -7,27 +7,13 @@ import type {
ConfigVerifyResult,
WriteResult,
} from "../ipc/types";
import { AuthPanel } from "./AuthPanel";
import { MonoChip } from "./MonoChip";
/** 官方授权方式中文名 */
function authModeLabel(mode: string): string {
switch (mode) {
case "browser_oauth":
return "账号授权(浏览器)";
case "device_code":
return "设备码";
case "api_key":
return "API Key";
case "local_tui":
return "本机终端授权";
default:
return mode;
}
}
/** 配置表单(视觉规范 §3.4:单列 ≤640px、敏感字段密码框 + 密钥库说明、吸底保存条)
* Wave 2.1:字段按官方配置方法渲染 + 保存后「生效检查」 */
export function ConfigForm({ id }: { id: string }) {
* 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>>({});
@@ -138,24 +124,12 @@ export function ConfigForm({ id }: { id: string }) {
<div className="config-form">
{!hasFields && <p className="panel-empty"> CLI </p>}
{/* 第一层:授权 / 登录(官方授权方式引导 + API Key 类字段) */}
{/* 第一层:授权 / 登录(可操作授权流程 + API Key 类字段) */}
{(state.auth_modes.length > 0 || authFields.length > 0) && (
<section className="config-section">
<h4 className="config-section-title"> / </h4>
{state.auth_modes.length > 0 && (
<ul className="auth-modes">
{state.auth_modes.map((m) => (
<li key={m.mode} className="auth-mode">
<span className="auth-mode-name">{authModeLabel(m.mode)}</span>
{m.notes_zh && <span className="auth-mode-note">{m.notes_zh}</span>}
{m.command.length > 0 && (
<span className="auth-mode-cmd">
<MonoChip>{m.command.join(" ")}</MonoChip>
</span>
)}
</li>
))}
</ul>
<AuthPanel id={id} authModes={state.auth_modes} onAuthChanged={onAuthChanged ?? (() => {})} />
)}
{authFields.map(renderField)}
</section>
@@ -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>
)}
</>
);
}
+7 -2
View File
@@ -9,10 +9,14 @@ export interface UseDetectAllResult {
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>>({});
const [loading, setLoading] = useState(true);
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 () => {
@@ -20,6 +24,7 @@ export function useDetectAll(): UseDetectAllResult {
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) {
+7 -3
View File
@@ -8,10 +8,13 @@ export interface UseEnvResult {
error: string | null;
}
/** 加载本机环境检测结果(一次性的只读快照) */
// 本机环境本地缓存(Wave 2.2 Req 4):切换页面先用缓存立即渲染,后台静默刷新。
let cachedEnv: PlatformEnv | null = null;
/** 加载本机环境检测结果(缓存优先 + 后台刷新) */
export function useEnv(): UseEnvResult {
const [env, setEnv] = useState<PlatformEnv | null>(null);
const [loading, setLoading] = useState(true);
const [env, setEnv] = useState<PlatformEnv | null>(cachedEnv);
const [loading, setLoading] = useState(cachedEnv == null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
@@ -19,6 +22,7 @@ export function useEnv(): UseEnvResult {
detectEnv()
.then((e) => {
if (!cancelled) {
cachedEnv = e;
setEnv(e);
setLoading(false);
}
+62
View File
@@ -3,6 +3,7 @@ import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import type {
Adapter,
AuthFlowEvent,
AuthStatus,
CatalogEntry,
CliAction,
@@ -13,6 +14,8 @@ import type {
DiagnosticReport,
DryRunPlan,
PlatformEnv,
RuntimeInstallEvent,
RuntimeSource,
WriteResult,
} from "./types";
@@ -98,6 +101,53 @@ export async function diagnose(id: string): Promise<DiagnosticReport> {
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,
@@ -256,3 +306,15 @@ const mockDryRun = (_id: string, action: CliAction): DryRunPlan => ({
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: "官方来源",
});
+48
View File
@@ -186,6 +186,54 @@ export interface DiagnosticReport {
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 {
+76 -9
View File
@@ -21,6 +21,7 @@ import type {
DetectResult,
DiagnosticReport,
DryRunPlan,
ErrorHint,
} from "../ipc/types";
import { CliMonogram } from "../components/CliMonogram";
import { MonoChip } from "../components/MonoChip";
@@ -40,7 +41,7 @@ type RunPhase = "prepare" | "exec" | "verify";
type RunOutcome =
| { status: "success"; action: CliAction; detect: DetectResult | null }
| { status: "failed"; action: CliAction; message: string };
| { status: "failed"; action: CliAction; message: string; hint: ErrorHint | null };
/** 授权状态灯(§3.3:已授权=绿、未授权=紫、可能过期=黄呼吸、未知=灰) */
function AuthLight({ auth }: { auth: AuthStatus | null }) {
@@ -90,7 +91,15 @@ function detectLabel(d: DetectResult | null): string {
}
}
export function CliDetailPage({ id, onBack }: { id: string; onBack: () => void }) {
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);
@@ -119,7 +128,8 @@ export function CliDetailPage({ id, onBack }: { id: string; onBack: () => void }
}
if (ev.kind === "error") {
setRunning(false);
setOutcome({ status: "failed", action: currentActionRef.current, message: ev.message });
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);
@@ -239,7 +249,7 @@ export function CliDetailPage({ id, onBack }: { id: string; onBack: () => void }
<section className="cli-detail-body">
{tab === "overview" && <OverviewTab adapter={adapter} detect={detect} auth={auth} />}
{tab === "config" && <ConfigForm id={id} />}
{tab === "config" && <ConfigForm id={id} onAuthChanged={refresh} />}
{tab === "docs" && <DocsTab adapter={adapter} />}
{tab === "diag" && <DiagTab id={id} />}
</section>
@@ -277,7 +287,7 @@ export function CliDetailPage({ id, onBack }: { id: string; onBack: () => void }
)
}
>
<RunBody phase={phase} log={log} outcome={outcome} />
<RunBody phase={phase} log={log} outcome={outcome} onInstallRuntime={onInstallRuntime} />
</Modal>
)}
</div>
@@ -309,10 +319,12 @@ function RunBody({
phase,
log,
outcome,
onInstallRuntime,
}: {
phase: RunPhase;
log: ActionEvent[];
outcome: RunOutcome | null;
onInstallRuntime?: (runtime: string) => void;
}) {
return (
<div className="run-body">
@@ -349,15 +361,70 @@ function RunBody({
</div>
)}
{outcome?.status === "failed" && (
<div className="run-result failed">
<ShieldAlert size={14} strokeWidth={1.5} aria-hidden="true" />
<span>{outcome.message}</span>
</div>
<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);
+271 -124
View File
@@ -1,13 +1,23 @@
import { useState } from "react";
import { Activity, Archive, Lock, Plus, ScanSearch } from "lucide-react";
import { useEffect, useState } from "react";
import { Activity, Archive, Download, Loader2, Lock, Plus, ScanSearch } from "lucide-react";
import { useEnv } from "../hooks/useEnv";
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 { MonoChip } from "../components/MonoChip";
import { StatusBadge } from "../components/StatusBadge";
import { CliMonogram } from "../components/CliMonogram";
import type { CatalogEntry, DetectResult, PlatformEnv, RuntimeInfo, RuntimeStatus } from "../ipc/types";
import { Modal } from "../components/Modal";
import { RuntimeInstallModal } from "../components/RuntimeInstallModal";
import type { PageKey } from "../components/Sidebar";
/** 平台相关运行时集合(Windows 不看 aptLinux 不看 winget */
@@ -30,34 +40,34 @@ function runtimeProblems(env: PlatformEnv): { name: string; info: RuntimeInfo }[
function runtimeMeta(status: RuntimeStatus): {
dot: string;
missingText: string | null;
needsGuide: boolean;
needsInstall: boolean;
} {
switch (status) {
case "installed":
return { dot: "status-dot ok", missingText: null, needsGuide: false };
return { dot: "status-dot ok", missingText: null, needsInstall: false };
case "not_installed":
return { dot: "status-dot unknown", missingText: "未检测到", needsGuide: true };
return { dot: "status-dot unknown", missingText: "未检测到", needsInstall: true };
case "not_in_path":
return { dot: "status-dot warn breathe", missingText: "不在 PATH", needsGuide: false };
return { dot: "status-dot warn breathe", missingText: "不在 PATH", needsInstall: false };
case "permission_denied":
return { dot: "status-dot warn breathe", missingText: "无访问权限", needsGuide: false };
return { dot: "status-dot warn breathe", missingText: "无访问权限", needsInstall: false };
case "exec_failed":
return { dot: "status-dot warn breathe", missingText: "执行失败", needsGuide: false };
return { dot: "status-dot warn breathe", missingText: "执行失败", needsInstall: false };
case "version_unparseable":
return { dot: "status-dot warn breathe", missingText: "版本未知", needsGuide: false };
return { dot: "status-dot warn breathe", missingText: "版本未知", needsInstall: false };
default:
return { dot: "status-dot unknown", missingText: "未知", needsGuide: false };
return { dot: "status-dot unknown", missingText: "未知", needsInstall: false };
}
}
function RuntimeItem({
name,
info,
onNavigate,
onInstall,
}: {
name: string;
info: RuntimeInfo;
onNavigate: (p: PageKey) => void;
onInstall: (runtime: string) => void;
}) {
const meta = runtimeMeta(info.status);
return (
@@ -71,107 +81,121 @@ function RuntimeItem({
) : (
<span className="runtime-missing">{meta.missingText}</span>
)}
{meta.needsGuide && (
<button type="button" className="link-btn runtime-action" onClick={() => onNavigate("catalog")}>
{meta.needsInstall && (
<button type="button" className="link-btn runtime-action" onClick={() => onInstall(name)}>
<Download size={12} strokeWidth={1.5} aria-hidden="true" />
</button>
)}
</div>
);
}
function EnvPanel({ onNavigate }: { onNavigate: (p: PageKey) => void }) {
const { env, loading, error } = useEnv();
function EnvPanel({
onInstallRuntime,
}: {
onInstallRuntime: (runtime: string) => void;
}) {
const { env, loading } = useEnv();
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 (
<section className="panel env-panel">
<div className="panel-head">
<h2 className="panel-title"></h2>
</div>
<p className="panel-empty">{error}</p>
<p className="panel-empty"></p>
</section>
);
}
const pathPreview = env ? env.path_entries.slice(0, 8) : [];
const pathPreview = env.path_entries.slice(0, 8);
return (
<section className="panel env-panel">
<div className="panel-head">
<h2 className="panel-title"></h2>
{loading && <span className="panel-hint"></span>}
{loading && <span className="panel-hint"></span>}
</div>
{env && (
<>
<div className="env-grid">
<div className="env-basic">
<div className="env-row">
<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} onNavigate={onNavigate} />;
},
)}
</div>
<div className="env-grid">
<div className="env-basic">
<div className="env-row">
<span className="env-key"></span>
<span className="env-val">{env.os_version}</span>
</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 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} 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>
);
}
@@ -221,9 +245,29 @@ function Onboarding({ onNavigate }: { onNavigate: (p: PageKey) => void }) {
);
}
/** 最近诊断卡(v1.3 §3.1.2:无记录时展示空态,不渲染占位行与「查看全部」 */
function DiagPanel() {
// Wave 0.5 尚无诊断引擎数据,恒为空态(语义按 §3.7,不虚构记录)
/** 检测进行中的骨架屏(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">
@@ -233,7 +277,7 @@ function DiagPanel() {
{records.length === 0 ? (
<div className="diag-empty">
<span className="diag-empty-text"></span>
<button type="button" className="btn btn-secondary">
<button type="button" className="btn btn-secondary" onClick={onDiagnose}>
<ScanSearch size={16} strokeWidth={1.5} aria-hidden="true" />
</button>
</div>
@@ -257,9 +301,11 @@ function DiagPanel() {
function QuickActions({
firstRun,
onNavigate,
onDiagnose,
}: {
firstRun: boolean;
onNavigate: (p: PageKey) => void;
onDiagnose: () => void;
}) {
return (
<div className="panel quick-card">
@@ -267,7 +313,7 @@ function QuickActions({
<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">
<button type="button" className="btn btn-secondary" onClick={onDiagnose}>
<Activity size={16} strokeWidth={1.5} aria-hidden="true" />
</button>
<button
@@ -348,16 +394,47 @@ function CliBlock({
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 }: OverviewProps) {
export function OverviewPage({ onNavigate, onOpenDetail, pendingRuntime, onRuntimeHandled }: OverviewProps) {
const { env } = useEnv();
const { entries } = useCatalog();
const { detectMap } = useDetectAll();
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 firstRun = installedCount === 0; // 无安装且无诊断记录(当前无诊断引擎)
// 检测未完成(首次、无缓存)时不得判空态
const ready = !detecting && !catalogLoading;
const firstRun = ready && installedCount === 0;
const warningCount = env ? runtimeProblems(env).length : 0;
const warningNames = env ? runtimeProblems(env).map((p) => p.name) : [];
@@ -365,7 +442,7 @@ export function OverviewPage({ onNavigate, onOpenDetail }: OverviewProps) {
return (
<div className="overview">
{/* ① KPI 一排 4 张(首跑空态隐藏,v1.3 §3.1.5) */}
{!firstRun && (
{ready && !firstRun && (
<section className="kpi-grid" aria-label="概览指标">
<KpiCard label="已安装" value={installedCount} tone="primary" subtitle="暂无安装" action={{ label: "去目录看看", onClick: () => onNavigate("catalog") }} />
<KpiCard label="待授权" value={0} tone="attention" subtitle="暂无待授权" />
@@ -380,35 +457,105 @@ export function OverviewPage({ onNavigate, onOpenDetail }: OverviewProps) {
)}
{/* ② 中段:诊断/引导 + 快速操作(超宽三栏见 global.css */}
<section className="overview-mid">
{firstRun ? <Onboarding onNavigate={onNavigate} /> : <DiagPanel />}
<QuickActions firstRun={firstRun} onNavigate={onNavigate} />
</section>
{ready ? (
<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 状态网格通栏(首跑精简:可安装列表 + 安装入口) */}
<section className="panel cli-status-panel">
<div className="panel-head">
<h2 className="panel-title">
{firstRun ? `可安装的 CLI${entries.length}` : "CLI 状态"}
</h2>
{/* 「管理全部 N 个」入口:仅当未全量展示时才渲染(v1.3 §3.1.4),本页恒全量展示,故不渲染 */}
</div>
<div className="cli-status-grid">
{entries.map((entry) => (
<CliBlock
key={entry.id}
entry={entry}
detect={detectMap[entry.id]}
compact={firstRun}
onNavigate={onNavigate}
onOpenDetail={onOpenDetail}
/>
))}
</div>
</section>
{!ready ? (
<OverviewSkeleton />
) : (
<section className="panel cli-status-panel">
<div className="panel-head">
<h2 className="panel-title">
{firstRun ? `可安装的 CLI${entries.length}` : "CLI 状态"}
</h2>
</div>
<div className="cli-status-grid">
{entries.map((entry) => (
<CliBlock
key={entry.id}
entry={entry}
detect={detectMap[entry.id]}
compact={firstRun}
onNavigate={onNavigate}
onOpenDetail={onOpenDetail}
/>
))}
</div>
</section>
)}
{/* ④ 本机环境(真实检测结果,架构 §5;超宽断点上提为第三栏) */}
<EnvPanel onNavigate={onNavigate} />
<EnvPanel onInstallRuntime={setRuntimeInstall} />
{/* 运行时一键安装弹窗 */}
{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>
);
}
+316
View File
@@ -2205,3 +2205,319 @@ button {
flex-direction: column;
gap: var(--ad-space-5);
}
/* ============================================================
* Wave 2.2£ºÊµÊ±Êä³ö / Èí¼þÄÚÊÚȨ / ÒÀÀµÒ»¼ü×° / ¼ÓÔØ¹Ç¼Ü
* È«²¿ÑÕÉ«À´×Ô tokens/
* ============================================================ */
/* ---------- ʧ°Ü̬ÈË»°»¯£¨°²×°/Ð¶ÔØµ¯´°£© ---------- */
.run-failure {
flex-direction: column;
align-items: flex-start;
}
.run-failure-head {
display: flex;
align-items: flex-start;
gap: var(--ad-space-2);
}
.run-failure-action {
margin-top: var(--ad-space-2);
}
.run-failure-toggle {
align-self: flex-start;
}
.run-failure-raw {
width: 100%;
padding: var(--ad-space-3);
background: var(--ad-bg-0);
border: 1px solid var(--ad-border);
border-radius: var(--ad-radius-s);
font-family: var(--ad-font-mono);
font-size: var(--ad-text-xs-size);
color: var(--ad-text-2);
white-space: pre-wrap;
word-break: break-all;
}
/* ---------- Èí¼þÄÚÊÚȨ£¨AuthPanel£© ---------- */
.auth-mode-action {
height: 28px;
min-width: 0;
padding: 0 var(--ad-space-3);
margin-left: auto;
flex-shrink: 0;
}
.auth-flow {
display: flex;
flex-direction: column;
gap: var(--ad-space-4);
}
.auth-device {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--ad-space-3);
padding: var(--ad-space-5);
border: 1px solid var(--ad-border);
border-radius: var(--ad-radius-m);
background: var(--ad-bg-1);
}
.auth-device-label {
color: var(--ad-text-2);
font-size: var(--ad-text-s-size);
}
.auth-device-code {
display: flex;
align-items: center;
gap: var(--ad-space-3);
}
.auth-device-code-text {
font-family: var(--ad-font-mono);
font-size: var(--ad-text-num-size);
letter-spacing: 0.12em;
color: var(--ad-primary);
}
.auth-copy {
height: 32px;
min-width: 0;
}
.auth-open {
text-decoration: none;
}
.auth-device-hint {
color: var(--ad-text-3);
font-size: var(--ad-text-xs-size);
}
.auth-status {
display: flex;
align-items: center;
gap: var(--ad-space-2);
font-size: var(--ad-text-s-size);
}
.auth-waiting {
display: inline-flex;
align-items: center;
gap: var(--ad-space-2);
color: var(--ad-attention);
}
.auth-done {
display: inline-flex;
align-items: center;
gap: var(--ad-space-2);
color: var(--ad-success);
}
.auth-failed {
display: inline-flex;
align-items: center;
gap: var(--ad-space-2);
color: var(--ad-danger);
}
.auth-log {
display: flex;
flex-direction: column;
gap: var(--ad-space-1);
max-height: 200px;
overflow-y: auto;
padding: var(--ad-space-3);
border: 1px solid var(--ad-border);
border-radius: var(--ad-radius-s);
background: var(--ad-bg-0);
font-family: var(--ad-font-mono);
font-size: var(--ad-text-xs-size);
}
.auth-log-line {
color: var(--ad-text-2);
word-break: break-all;
white-space: pre-wrap;
}
.auth-log-line.waiting {
color: var(--ad-attention);
}
/* ---------- ±¾»ú»·¾³Ò»¼ü°²×°£¨RuntimeInstallModal£© ---------- */
.runtime-install {
display: flex;
flex-direction: column;
gap: var(--ad-space-4);
}
.runtime-source,
.runtime-meta {
display: flex;
flex-direction: column;
gap: var(--ad-space-2);
}
.runtime-url {
display: flex;
flex-direction: column;
gap: var(--ad-space-1);
}
.runtime-url-text {
font-family: var(--ad-font-mono);
font-size: var(--ad-text-xs-size);
color: var(--ad-text-2);
word-break: break-all;
}
.runtime-progress {
display: flex;
align-items: center;
}
.runtime-error {
display: flex;
align-items: flex-start;
gap: var(--ad-space-2);
padding: var(--ad-space-3);
border: 1px solid var(--ad-border);
border-radius: var(--ad-radius-m);
color: var(--ad-danger);
font-size: var(--ad-text-s-size);
}
.runtime-note {
color: var(--ad-text-3);
font-size: var(--ad-text-xs-size);
}
/* ---------- ×ÜÀÀ¼ÓÔØ¹Ç¼ÜÆÁ£¨Wave 2.2 Req 4£© ---------- */
.overview-skeleton {
padding: var(--ad-space-5);
background: var(--ad-bg-1);
border: 1px solid var(--ad-border);
border-radius: var(--ad-radius-l);
}
.skeleton-notice {
display: flex;
align-items: center;
gap: var(--ad-space-2);
margin-bottom: var(--ad-space-4);
color: var(--ad-text-2);
font-size: var(--ad-text-m-size);
}
.skeleton-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--ad-space-4);
}
.skeleton-card {
display: flex;
flex-direction: column;
gap: var(--ad-space-2);
padding: var(--ad-space-4);
border: 1px solid var(--ad-border);
border-radius: var(--ad-radius-m);
}
.skeleton-rows {
display: flex;
flex-direction: column;
gap: var(--ad-space-3);
}
.skeleton-line {
height: 14px;
border-radius: 4px;
background: var(--ad-bg-3);
animation: ad-skeleton 1.4s var(--ad-ease-inout) infinite;
}
.skeleton-line.short {
width: 60%;
}
.skeleton-line.avatar {
width: 32px;
height: 32px;
border-radius: var(--ad-radius-m);
}
@keyframes ad-skeleton {
0%,
100% {
opacity: 0.4;
}
50% {
opacity: 0.9;
}
}
/* ---------- È«Á¿Õï¶Ï»ã×Ü ---------- */
.diag-summary-all {
display: flex;
flex-direction: column;
gap: var(--ad-space-3);
}
.diag-report-group {
display: flex;
flex-direction: column;
gap: var(--ad-space-2);
padding: var(--ad-space-3);
border: 1px solid var(--ad-border);
border-radius: var(--ad-radius-m);
}
.diag-report-cli {
color: var(--ad-primary);
font-family: var(--ad-font-mono);
font-size: var(--ad-text-s-size);
}
.diag-finding-row {
display: flex;
align-items: flex-start;
gap: var(--ad-space-2);
}
.diag-finding-row-msg {
flex: 1;
min-width: 0;
color: var(--ad-text-1);
font-size: var(--ad-text-s-size);
line-height: 1.5;
}
/* È«Á¿Õï¶Ï»ã×ÜÀïµÄ¼¶±ðÎÄ×Ö±êÇ©£¨½öÎÄ×ÖÉ«£¬²»Óà diag-sev-tag µÄÌî³äÉ«£¬±ÜÃâÎÄ×Ö±»±³¾°Í̵ô£© */
.diag-sev-label {
flex-shrink: 0;
font-size: var(--ad-text-xs-size);
}
.diag-sev-label.error {
color: var(--ad-danger);
}
.diag-sev-label.warn {
color: var(--ad-warning);
}
.diag-sev-label.info {
color: var(--ad-attention);
}
+3
View File
@@ -219,6 +219,9 @@ pub struct ConfigFile {
pub format: String,
#[serde(default)]
pub scope: Option<String>,
/// 适用平台:windows | linux;空数组 = 全平台适用(用于同一 CLI 不同平台路径不同的场景,如 Goose)
#[serde(default)]
pub platforms: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
+1
View File
@@ -8,4 +8,5 @@ description = "配置读写:多格式编解码(TOML/JSON)+ 原子写入 +
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
toml = "0.8"
+170 -15
View File
@@ -1,8 +1,9 @@
//! 配置格式与统一编解码(架构 §6.2 `ConfigCodec`
//!
//! 统一以 `serde_json::Value` 作为内部表示(运行时单一真相)。TOML 通过
//! `toml::Value` 桥接(双向转换),JSON/JSONC 直接用 serde_json。yaml / crushrc
//! 属 Wave 3 工具,本波返回明确的「未实现」错误,不静默吞数据。
//! `toml::Value` 桥接(双向转换),JSON/JSONC 直接用 serde_jsonYAML 经
//! serde_yaml 解析/序列化。crushrc 走专用解析器:只识别内建赋值 / export,
//! 未知行原样保留、绝不执行任意脚本。
use serde_json::{Map, Value};
@@ -46,10 +47,9 @@ pub fn parse(format: ConfigFormat, text: &str) -> Result<Value, ConfigError> {
Ok(toml_to_json(&tv))
}
ConfigFormat::Env => parse_env(text),
ConfigFormat::Yaml | ConfigFormat::Crushrc => Err(ConfigError::Unsupported(format!(
"{:?} 格式本波(Wave 2)未实现,将在后续波次接入",
format
))),
ConfigFormat::Yaml => serde_yaml::from_str::<Value>(text)
.map_err(|e| ConfigError::Parse(e.to_string())),
ConfigFormat::Crushrc => parse_crushrc(text),
}
}
@@ -63,10 +63,8 @@ pub fn serialize(format: ConfigFormat, value: &Value) -> Result<String, ConfigEr
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
))),
ConfigFormat::Yaml => serde_yaml::to_string(value).map_err(|e| ConfigError::Parse(e.to_string())),
ConfigFormat::Crushrc => serialize_crushrc(value),
}
}
@@ -191,6 +189,98 @@ fn serialize_env(value: &Value) -> Result<String, ConfigError> {
Ok(out)
}
// ---- crushrc 专用解析器(Bash 语法子集)----
//
// 只识别内建赋值:`export KEY=value` 或裸 `KEY=value`KEY 为合法变量名)。
// 其余 Bash 结构(函数定义、if/fi、$(...) 命令替换、反引号、进程替换等)一律
// 跳过、绝不执行——本解析器是纯字符串解析,不 shell out,也不展开变量/命令。
// 未识别的行在解析期被忽略(不报错、不执行),序列化时只输出已识别的键。
fn parse_crushrc(text: &str) -> Result<Value, ConfigError> {
let mut m = Map::new();
for line in text.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let stmt = strip_export(trimmed);
// 只识别「合法变量名=值」;其余语句跳过
let Some(eq) = stmt.find('=') else { continue };
let key = stmt[..eq].trim();
if key.is_empty() || !is_bash_var_name(key) {
continue;
}
let raw_val = stmt[eq + 1..].trim();
// 含命令替换 / 反引号 / 进程替换的赋值视为不安全,跳过不解析(绝不执行)
if raw_val.contains("$(") || raw_val.contains('`') || raw_val.contains("<(") {
continue;
}
m.insert(key.to_string(), Value::String(parse_bash_scalar(raw_val)));
}
Ok(Value::Object(m))
}
fn serialize_crushrc(value: &Value) -> Result<String, ConfigError> {
let obj = value.as_object().ok_or_else(|| {
ConfigError::Parse("crushrc 格式要求顶层为对象".into())
})?;
let mut out = String::new();
for (k, v) in obj {
let s = match v {
Value::String(s) => s.clone(),
Value::Bool(b) => b.to_string(),
Value::Number(n) => n.to_string(),
_ => continue,
};
out.push_str(&format!("export {k}={}\n", quote_bash(&s)));
}
Ok(out)
}
/// 去掉前导 `export` 关键字(仅当后随空白时,避免误伤 `exported=1` 这类变量名)。
fn strip_export(line: &str) -> &str {
let Some(rest) = line.strip_prefix("export") else {
return line;
};
if rest.is_empty() || rest.starts_with(' ') || rest.starts_with('\t') {
rest.trim_start()
} else {
line
}
}
/// 合法 Bash 变量名:`[A-Za-z_][A-Za-z0-9_]*`。
fn is_bash_var_name(s: &str) -> bool {
let mut chars = s.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
/// 解析赋值右侧标量:剥离成对单/双引号,其余按原样返回(不展开变量、不执行)。
fn parse_bash_scalar(raw: &str) -> String {
let s = raw.trim();
if s.len() >= 2 {
let first = s.chars().next().unwrap();
let last = s.chars().last().unwrap();
if (first == '"' && last == '"') || (first == '\'' && last == '\'') {
return s[1..s.len() - 1].to_string();
}
}
s.to_string()
}
/// 序列化时对含空白/引号的值加双引号。
fn quote_bash(s: &str) -> String {
if s.chars().any(|c| c.is_whitespace()) || s.contains('\'') {
format!("\"{s}\"")
} else {
s.to_string()
}
}
/// 剥离 JSONC 的 // 与 /* */ 注释(保守处理,不处理字符串内的注释序列)。
fn strip_jsonc_comments(text: &str) -> String {
let mut out = String::with_capacity(text.len());
@@ -286,11 +376,76 @@ mod tests {
}
#[test]
fn unsupported_format_is_explicit() {
assert!(matches!(
parse(ConfigFormat::Crushrc, "foo=bar"),
Err(ConfigError::Unsupported(_))
));
fn yaml_roundtrip() {
let text = "GOOSE_PROVIDER: anthropic\nGOOSE_MODEL: claude-sonnet-4\n";
let v = parse(ConfigFormat::Yaml, text).unwrap();
assert_eq!(
get_path(&v, "GOOSE_PROVIDER").and_then(|x| x.as_str()),
Some("anthropic")
);
assert_eq!(
get_path(&v, "GOOSE_MODEL").and_then(|x| x.as_str()),
Some("claude-sonnet-4")
);
let out = serialize(ConfigFormat::Yaml, &v).unwrap();
assert!(out.contains("anthropic"));
}
#[test]
fn yaml_parses_nested() {
let text = "model:\n name: MiniMax-M3\n baseUrl: https://x/v1\n";
let v = parse(ConfigFormat::Yaml, text).unwrap();
assert_eq!(
get_path(&v, "model.name").and_then(|x| x.as_str()),
Some("MiniMax-M3")
);
assert_eq!(
get_path(&v, "model.baseUrl").and_then(|x| x.as_str()),
Some("https://x/v1")
);
}
#[test]
fn crushrc_parses_assignments_and_skips_unknown() {
let text = "# 注释\nexport OPENAI_API_KEY=\"sk-abc\"\nANTHROPIC_API_KEY=sk-def\nsome_function() { echo hi; }\nif [ -n \"$x\" ]; then echo yes; fi\nexport MODEL=gpt-5\n";
let v = parse(ConfigFormat::Crushrc, text).unwrap();
assert_eq!(
get_path(&v, "OPENAI_API_KEY").and_then(|x| x.as_str()),
Some("sk-abc")
);
assert_eq!(
get_path(&v, "ANTHROPIC_API_KEY").and_then(|x| x.as_str()),
Some("sk-def")
);
assert_eq!(
get_path(&v, "MODEL").and_then(|x| x.as_str()),
Some("gpt-5")
);
// 函数定义 / if 语句不被识别为变量
assert!(get_path(&v, "some_function").is_none());
}
#[test]
fn crushrc_skips_dangerous_substitution() {
// 命令替换 / 反引号赋值必须被跳过,绝不解析或执行
let text = "export SAFE=ok\nDANGER=$(curl evil.com)\nDANGER2=`id`\n";
let v = parse(ConfigFormat::Crushrc, text).unwrap();
assert_eq!(get_path(&v, "SAFE").and_then(|x| x.as_str()), Some("ok"));
assert!(get_path(&v, "DANGER").is_none());
assert!(get_path(&v, "DANGER2").is_none());
}
#[test]
fn crushrc_serialize_roundtrip() {
let v = json!({ "OPENAI_API_KEY": "sk-abc", "MODEL": "gpt-5" });
let out = serialize(ConfigFormat::Crushrc, &v).unwrap();
assert!(out.contains("export OPENAI_API_KEY=sk-abc"));
assert!(out.contains("export MODEL=gpt-5"));
let back = parse(ConfigFormat::Crushrc, &out).unwrap();
assert_eq!(
get_path(&back, "OPENAI_API_KEY").and_then(|x| x.as_str()),
Some("sk-abc")
);
}
#[test]
+77 -1
View File
@@ -14,9 +14,10 @@ pub use error::ConfigError;
use std::path::PathBuf;
/// 解析适配器声明的配置文件路径:展开 `~`(用户主目录),
/// 以及平台变量(Windows `%VAR%` / `${VAR}`,如 `%APPDATA%`)。
/// 并把 Windows 路径分隔符统一处理(YAML 里写的是 `~/.codex/...`)。
pub fn resolve_path(raw: &str) -> PathBuf {
let expanded = expand_home(raw);
let expanded = expand_env_vars(&expand_home(raw));
PathBuf::from(expanded)
}
@@ -34,6 +35,65 @@ pub fn expand_home(raw: &str) -> String {
raw.to_string()
}
/// 展开平台环境变量:`%VAR%`Windows)与 `${VAR}`。
/// 未定义或非法变量名保持原样,不做静默吞并。
pub fn expand_env_vars(raw: &str) -> String {
// 先用 ${VAR} 处理(避免与 %VAR% 互相干扰)
let mut out = String::with_capacity(raw.len());
let bytes: Vec<char> = raw.chars().collect();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == '%' {
if let Some((name, end)) = take_until(&bytes, i + 1, '%') {
if is_valid_var_name(&name) {
if let Ok(v) = std::env::var(&name) {
out.push_str(&v);
i = end + 1;
continue;
}
}
}
}
if bytes[i] == '$' && i + 1 < bytes.len() && bytes[i + 1] == '{' {
if let Some((name, end)) = take_until(&bytes, i + 2, '}') {
if is_valid_var_name(&name) {
if let Ok(v) = std::env::var(&name) {
out.push_str(&v);
i = end + 1;
continue;
}
}
}
}
out.push(bytes[i]);
i += 1;
}
out
}
/// 从 `from` 开始找 `close`,返回 (变量名, close 下标)。找不到返回 None。
fn take_until(chars: &[char], from: usize, close: char) -> Option<(String, usize)> {
let mut end = from;
while end < chars.len() {
if chars[end] == close {
let name: String = chars[from..end].iter().collect();
return Some((name, end));
}
end += 1;
}
None
}
/// 环境变量名合法性:`[A-Za-z_][A-Za-z0-9_]*`(与常见 shell/OS 约定一致)。
fn is_valid_var_name(name: &str) -> bool {
let mut chars = name.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
/// 用户主目录(含路径分隔符兜底)。
pub fn home_dir() -> String {
#[cfg(windows)]
@@ -63,4 +123,20 @@ mod tests {
fn plain_path_unchanged() {
assert_eq!(expand_home("/etc/codex/config.toml"), "/etc/codex/config.toml");
}
#[test]
fn expand_env_vars_windows_and_braces() {
std::env::set_var("AGENTDOCK_TEST_VAR", "C:\\Users\\Test");
assert_eq!(
expand_env_vars("%AGENTDOCK_TEST_VAR%\\block\\goose"),
"C:\\Users\\Test\\block\\goose"
);
assert_eq!(
expand_env_vars("${AGENTDOCK_TEST_VAR}/x"),
"C:\\Users\\Test/x"
);
// 未定义变量保持原样
assert_eq!(expand_env_vars("%AGENTDOCK_NOT_SET_VAR%\\x"), "%AGENTDOCK_NOT_SET_VAR%\\x");
std::env::remove_var("AGENTDOCK_TEST_VAR");
}
}
+574 -78
View File
@@ -4,8 +4,9 @@
//! detectCli / previewAction / runAction / readConfig / writeConfig /
//! authStatus / authorize / diagnose。所有命令执行走 argv 数组 + 脱敏。
use std::path::PathBuf;
use std::sync::Arc;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use agentdock_adapter::{Adapter, AdapterAction, DryRunPlan, load_adapters, preview_action};
use agentdock_config as cfg;
@@ -14,9 +15,18 @@ use agentdock_secrets::{SecretStore, redact, service_name};
use serde_json::json;
use crate::error::EngineError;
use crate::process::{run_capture, run_streaming, which_all};
use crate::errors_zh::map_exec_error;
use crate::process::{
resolve_exe, run_capture, run_streaming, run_streaming_cancellable, run_terminal, run_with_stdin,
which_all, RunningProcess,
};
use crate::types::*;
/// 授权会话 key`<cli_id>:<mode>`。
fn session_key(id: &str, mode: &str) -> String {
format!("{id}:{mode}")
}
/// 动作选项(runAction 的 opts)。
#[derive(Debug, Clone, Default)]
pub struct ActionOpts {
@@ -29,11 +39,17 @@ pub struct ActionOpts {
pub struct Engine {
adapters_dir: PathBuf,
secrets: Arc<dyn SecretStore>,
/// 进行中的授权会话(可取消的进程句柄)
auth_sessions: Arc<Mutex<HashMap<String, Arc<RunningProcess>>>>,
}
impl Engine {
pub fn new(adapters_dir: PathBuf, secrets: Arc<dyn SecretStore>) -> Self {
Engine { adapters_dir, secrets }
Engine {
adapters_dir,
secrets,
auth_sessions: Arc::new(Mutex::new(HashMap::new())),
}
}
/// 加载全部适配器(已过 schema 校验)。
@@ -94,9 +110,222 @@ impl Engine {
Ok(auth_status(&adapter, self.secrets.as_ref()))
}
pub fn authorize(&self, id: &str, mode: &str) -> Result<(), EngineError> {
/// 取消正在进行的授权流程(按 cli_id + mode)。
pub fn cancel_authorize(&self, id: &str, mode: &str) {
if let Some(rp) = self.auth_sessions.lock().unwrap().get(&session_key(id, mode)) {
rp.request_cancel();
}
}
/// 软件内授权(Wave 2.2 Req 2):按官方授权方式运行登录命令,
/// 流式回传事件(设备码/浏览器/API Key/本机终端)。
pub fn authorize_stream<F>(&self, id: &str, mode: &str, mut emit: F) -> Result<(), EngineError>
where
F: FnMut(AuthFlowEvent),
{
let adapter = self.adapter(id)?;
authorize(&adapter, mode)
let auth = adapter
.authorization
.as_ref()
.ok_or_else(|| EngineError::NotSupported(format!("{} 未声明授权方式", adapter.id)))?;
let target = auth
.modes
.iter()
.find(|m| m.mode == mode)
.ok_or_else(|| EngineError::NotSupported(format!("{} 未声明 {mode} 授权方式", adapter.id)))?;
emit(AuthFlowEvent::started(
&adapter.id,
mode,
format!("开始{}授权…", auth_mode_label_zh(mode)),
));
// API Key 且无官方命令:仅核对密钥库是否已保存
if mode == "api_key" && target.command.is_empty() {
let st = auth_status(&adapter, self.secrets.as_ref());
emit(AuthFlowEvent::done(&adapter.id, mode, st.status == "authorized"));
return Ok(());
}
let prog = &target.command[0];
let args: Vec<String> = target.command[1..].to_vec();
agentdock_exec::validate_argv(prog, &args)?;
let exe = resolve_exe(prog);
match mode {
"device_code" => self.run_device_code(&adapter, mode, &exe, &args, &mut emit),
"browser_oauth" => self.run_browser_oauth(&adapter, mode, &exe, &args, &mut emit),
"local_tui" => self.run_terminal_flow(&adapter, mode, &exe, &args, &mut emit),
"api_key" => self.run_api_key_flow(&adapter, mode, &exe, &args, &mut emit),
other => Err(EngineError::NotSupported(format!("未支持的授权模式 {other}"))),
}
}
fn register_session(&self, id: &str, mode: &str, rp: Arc<RunningProcess>) {
self.auth_sessions.lock().unwrap().insert(session_key(id, mode), rp);
}
fn unregister_session(&self, id: &str, mode: &str) {
self.auth_sessions.lock().unwrap().remove(&session_key(id, mode));
}
/// 设备码授权:运行官方命令,解析 user_code + verification_url,轮询等待结果。
fn run_device_code<F>(
&self,
adapter: &Adapter,
mode: &str,
exe: &str,
args: &[String],
emit: &mut F,
) -> Result<(), EngineError>
where
F: FnMut(AuthFlowEvent),
{
let rp = Arc::new(RunningProcess::new());
self.register_session(&adapter.id, mode, rp.clone());
let mut captured = String::new();
let mut code_emitted = false;
let result = run_streaming_cancellable(rp.as_ref(), exe, args, |is_err, line| {
let clean = strip_ansi(line);
if !is_err {
captured.push_str(&clean);
captured.push('\n');
}
emit(AuthFlowEvent::line(&adapter.id, mode, redact(&clean)));
if !code_emitted {
if let Some((code, url)) = parse_device_code(&captured) {
code_emitted = true;
emit(AuthFlowEvent::device_code(&adapter.id, mode, code, url));
}
}
});
self.unregister_session(&adapter.id, mode);
if rp.is_cancelled() {
emit(AuthFlowEvent::cancelled(&adapter.id, mode));
return Ok(());
}
match result {
Ok(ok) => {
let st = auth_status(adapter, self.secrets.as_ref());
emit(AuthFlowEvent::done(&adapter.id, mode, ok && st.status == "authorized"));
Ok(())
}
Err(e) => {
emit(AuthFlowEvent::error(&adapter.id, mode, format!("授权命令执行失败:{e}")));
Err(EngineError::Exec(e.to_string()))
}
}
}
/// 浏览器授权:后台运行官方登录命令,等待浏览器确认(可取消)。
fn run_browser_oauth<F>(
&self,
adapter: &Adapter,
mode: &str,
exe: &str,
args: &[String],
emit: &mut F,
) -> Result<(), EngineError>
where
F: FnMut(AuthFlowEvent),
{
let rp = Arc::new(RunningProcess::new());
self.register_session(&adapter.id, mode, rp.clone());
emit(AuthFlowEvent::waiting(
&adapter.id,
mode,
"正在等待浏览器确认…(完成后会自动刷新授权状态)",
));
let result = run_streaming_cancellable(rp.as_ref(), exe, args, |_is_err, line| {
let clean = strip_ansi(line);
emit(AuthFlowEvent::line(&adapter.id, mode, redact(&clean)));
});
self.unregister_session(&adapter.id, mode);
if rp.is_cancelled() {
emit(AuthFlowEvent::cancelled(&adapter.id, mode));
return Ok(());
}
match result {
Ok(ok) => {
let st = auth_status(adapter, self.secrets.as_ref());
emit(AuthFlowEvent::done(&adapter.id, mode, ok && st.status == "authorized"));
Ok(())
}
Err(e) => {
emit(AuthFlowEvent::error(&adapter.id, mode, format!("授权命令执行失败:{e}")));
Err(EngineError::Exec(e.to_string()))
}
}
}
/// API Key 授权(有官方命令时):从密钥库读 key,经 stdin 注入官方登录命令。
fn run_api_key_flow<F>(
&self,
adapter: &Adapter,
mode: &str,
exe: &str,
args: &[String],
emit: &mut F,
) -> Result<(), EngineError>
where
F: FnMut(AuthFlowEvent),
{
let key = self
.secrets
.get(&service_name(&adapter.id), "api_key")
.map_err(|_| {
EngineError::Secrets("未找到已保存的 API Key:请先在「授权 / 登录」里保存 API Key".into())
})?;
emit(AuthFlowEvent::waiting(
&adapter.id,
mode,
"正在用系统密钥库中保存的 API Key 调用官方登录命令…",
));
match run_with_stdin(Path::new(exe), args, &key) {
Ok((ok, _text)) => {
let st = auth_status(adapter, self.secrets.as_ref());
emit(AuthFlowEvent::done(&adapter.id, mode, ok && st.status == "authorized"));
Ok(())
}
Err(e) => {
emit(AuthFlowEvent::error(&adapter.id, mode, format!("登录命令执行失败:{e}")));
Err(EngineError::Exec(e.to_string()))
}
}
}
/// 本机终端授权(local_tui):一次性终端窗口跑官方命令,结束只回传成败状态。
fn run_terminal_flow<F>(
&self,
adapter: &Adapter,
mode: &str,
exe: &str,
args: &[String],
emit: &mut F,
) -> Result<(), EngineError>
where
F: FnMut(AuthFlowEvent),
{
emit(AuthFlowEvent::waiting(
&adapter.id,
mode,
"已打开本机终端窗口运行官方登录命令,完成后窗口会自动关闭…",
));
match run_terminal(exe, args) {
Ok(ok) => {
let st = auth_status(adapter, self.secrets.as_ref());
emit(AuthFlowEvent::done(&adapter.id, mode, ok && st.status == "authorized"));
Ok(())
}
Err(e) => {
emit(AuthFlowEvent::error(&adapter.id, mode, format!("终端命令执行失败:{e}")));
Err(EngineError::Exec(e.to_string()))
}
}
}
// ---- 诊断 ----
@@ -106,6 +335,16 @@ impl Engine {
Ok(diagnose(&adapter, env, self.secrets.as_ref()))
}
/// 批量诊断全部已装工具(总览「立即诊断」入口用)。
pub fn diagnose_all(&self, env: &agentdock_platform::model::PlatformEnv) -> Result<Vec<diag::DiagnosticReport>, EngineError> {
let adapters = self.adapters()?;
Ok(adapters
.iter()
.filter(|a| detect_one(a).status == "installed")
.map(|a| diagnose(a, env, self.secrets.as_ref()))
.collect())
}
// ---- 执行 ----
pub fn run<F>(&self, id: &str, action: AdapterAction, opts: &ActionOpts, mut emit: F) -> Result<(), EngineError>
@@ -237,6 +476,19 @@ fn generic_extract_version(output: &str) -> Option<String> {
// 配置读写
// =====================================================================
/// 判断配置文件是否适用于当前平台(未声明 platforms 视为全平台适用)。
/// 用于同一 CLI 在不同平台路径不同的场景(如 GooseWindows %APPDATA%… / Linux ~/.config/…)。
fn file_applies_to_current_platform(f: &agentdock_adapter::schema::ConfigFile) -> bool {
if f.platforms.is_empty() {
return true;
}
#[cfg(windows)]
let cur = "windows";
#[cfg(not(windows))]
let cur = "linux";
f.platforms.iter().any(|p| p == cur)
}
/// 读取适配器配置,产出表单状态。
pub fn read_config(adapter: &Adapter, secrets: &dyn SecretStore) -> Result<ConfigFormState, EngineError> {
let cli_id = adapter.id.clone();
@@ -247,8 +499,8 @@ pub fn read_config(adapter: &Adapter, secrets: &dyn SecretStore) -> Result<Confi
let mut environment = Vec::new();
if let Some(cfg) = cfg_opt {
// 文件解析状态
for f in &cfg.files {
// 文件解析状态(仅当前平台适用的配置文件)
for f in cfg.files.iter().filter(|f| file_applies_to_current_platform(f)) {
let path = cfg::resolve_path(&f.path);
let exists = path.exists();
let (parse_ok, error) = if exists {
@@ -339,7 +591,7 @@ fn read_file_field(adapter: &Adapter, field_id: &str) -> Result<Option<String>,
let Some(cfg) = adapter.configuration.as_ref() else {
return Ok(None);
};
for f in &cfg.files {
for f in cfg.files.iter().filter(|f| file_applies_to_current_platform(f)) {
let path = cfg::resolve_path(&f.path);
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
@@ -403,7 +655,10 @@ pub fn write_config(
}
}
"file" | "env" => {
if let Some(idx) = cfg.files.iter().position(|f| matches!(f.scope.as_deref(), Some("user") | None)) {
if let Some(idx) = cfg.files.iter().position(|f| {
file_applies_to_current_platform(f)
&& matches!(f.scope.as_deref(), Some("user") | None)
}) {
file_patches.entry(idx).or_default().push((field.id.clone(), value.clone()));
} else {
errors.push(format!("{} 未声明可写的配置文件", field.label_zh));
@@ -455,7 +710,7 @@ pub fn verify_config(adapter: &Adapter) -> ConfigVerifyResult {
// 1. 重读配置文件并解析(有配置文件声明时)
if let Some(cfg) = adapter.configuration.as_ref() {
for f in &cfg.files {
for f in cfg.files.iter().filter(|f| file_applies_to_current_platform(f)) {
let path = cfg::resolve_path(&f.path);
if !path.exists() {
continue;
@@ -610,42 +865,98 @@ pub fn auth_status(adapter: &Adapter, secrets: &dyn SecretStore) -> AuthStatus {
}
}
/// 执行授权命令(交互式,流式输出)。
pub fn authorize(adapter: &Adapter, mode: &str) -> Result<(), EngineError> {
let auth = adapter
.authorization
.as_ref()
.ok_or_else(|| EngineError::NotSupported(format!("{} 未声明授权方式", adapter.id)))?;
let target = auth
.modes
.iter()
.find(|m| m.mode == mode)
.or_else(|| auth.modes.first())
.ok_or_else(|| EngineError::NotSupported(format!("{} 无可用授权模式", adapter.id)))?;
if target.command.is_empty() {
return Err(EngineError::NotSupported(format!(
"{}{mode} 授权本波由配置表单完成(API Key)",
adapter.id
)));
// =====================================================================
// 授权辅助:模式中文名 / 设备码解析 / ANSI 去色
// =====================================================================
/// 授权模式中文名。
pub fn auth_mode_label_zh(mode: &str) -> &'static str {
match mode {
"browser_oauth" => "浏览器账号授权",
"device_code" => "设备码",
"api_key" => "API Key",
"local_tui" => "本机终端授权",
_ => "授权",
}
let prog = &target.command[0];
let args: Vec<String> = target.command[1..].to_vec();
agentdock_exec::validate_argv(prog, &args)?;
let exe = which_all(prog)
.first()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| prog.clone());
let ok = run_streaming(&exe, &args, |is_err, line| {
let r = redact(line);
// authorize 不吐日志到文件,仅进程内消费;这里仅占位
let _ = (is_err, r);
})?;
if !ok {
return Err(EngineError::Exec(format!("{mode} 授权命令退出码非 0")));
}
Ok(())
}
/// 去除终端 ANSI 转义序列(颜色/光标控制),保留可读文本。
pub fn strip_ansi(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
while let Some(c) = chars.next() {
if c == '\u{1b}' {
// CSI 序列:ESC [ ... 终字母
if chars.peek() == Some(&'[') {
chars.next();
while let Some(&n) = chars.peek() {
if n.is_ascii_digit() || matches!(n, ';' | '?' | '=' | '!') {
chars.next();
} else {
break;
}
}
if let Some(&n) = chars.peek() {
if n.is_ascii_alphabetic() {
chars.next();
}
}
} else if chars.peek() == Some(&']') {
// OSC 序列:ESC ] ... BEL 或 ESC \
chars.next();
for n in chars.by_ref() {
if n == '\u{7}' || n == '\u{1b}' {
break;
}
}
}
continue;
}
out.push(c);
}
out
}
/// 从命令输出解析设备码 + 验证链接(device_code 流程)。
/// 返回 (user_code, verification_url)。
pub fn parse_device_code(text: &str) -> Option<(String, String)> {
let url = find_verification_url(text)?;
let code = find_device_code(text)?;
Some((code, url))
}
/// 从输出里挑验证链接:优先含 device/auth/login 关键字的 URL,否则取首个 URL。
fn find_verification_url(text: &str) -> Option<String> {
let re = regex::Regex::new(r#"https?://[^\s<>"'\]]+"#).ok()?;
let mut first: Option<String> = None;
for m in re.find_iter(text) {
let u = m.as_str().trim_end_matches(['.', ',', ')']);
if first.is_none() {
first = Some(u.to_string());
}
let lower = u.to_lowercase();
if lower.contains("device") || lower.contains("auth") || lower.contains("login") {
return Some(u.to_string());
}
}
first
}
/// 从输出里解析设备码:优先 `XXXX-XXXX` 带连字符形态(各段 4–8 位大写字母数字),
/// 其次 6–10 位大写字母数字。
fn find_device_code(text: &str) -> Option<String> {
let dashed = regex::Regex::new(r"\b[A-Z0-9]{4,8}-[A-Z0-9]{4,8}\b").ok()?;
if let Some(m) = dashed.find(text) {
return Some(m.as_str().to_string());
}
let plain = regex::Regex::new(r"\b[A-Z0-9]{6,10}\b").ok()?;
plain.find(text).map(|m| m.as_str().to_string())
}
// =====================================================================
// 授权(旧同步入口移除;交互式授权走 Engine::authorize_stream
// =====================================================================
// =====================================================================
// 诊断
// =====================================================================
@@ -685,7 +996,7 @@ pub fn diagnose(
// 4. 配置损坏类
if let Some(cfg) = adapter.configuration.as_ref() {
for f in &cfg.files {
for f in cfg.files.iter().filter(|f| file_applies_to_current_platform(f)) {
let path = cfg::resolve_path(&f.path);
if !path.exists() {
continue;
@@ -814,18 +1125,22 @@ where
let args: Vec<String> = cmd[1..].to_vec();
agentdock_exec::validate_argv(prog, &args)?;
// Windows 上解析到 npm.cmd / winget.exe 等实际路径再执行(避免 CreateProcess 找不到无扩展名脚本)
let exe = which_all(prog)
.first()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| prog.clone());
let exe = resolve_exe(prog);
emit(ActionEvent::step_phase(
"exec",
format!("执行命令:{} {}", prog, args.join(" ")),
));
// 实时流式输出(stdout/stderr 并发上屏);同时保留 stderr 尾部用于失败人话化
let mut stderr_tail = String::new();
let result = run_streaming(&exe, &args, |is_err, line| {
let r = redact(line);
if is_err {
stderr_tail.push_str(&r);
stderr_tail.push('\n');
if stderr_tail.len() > 4000 {
stderr_tail.drain(..stderr_tail.len() - 4000);
}
emit(ActionEvent::stderr(r));
} else {
emit(ActionEvent::stdout(r));
@@ -835,20 +1150,16 @@ where
let ok = match result {
Ok(ok) => ok,
Err(e) => {
let hint = diag_hint_for_install_failure(adapter, &e.to_string());
emit(ActionEvent::error(format!(
"{label}失败:无法启动命令({e})。{hint}"
)));
return Err(EngineError::Exec(format!("{label} 命令启动失败: {e}")));
let hint = map_exec_error(prog, Some(&e), &stderr_tail);
emit(ActionEvent::error_hint(&hint));
return Err(EngineError::Exec(hint.friendly_zh.clone()));
}
};
if !ok {
let hint = diag_hint_for_install_failure(adapter, "");
emit(ActionEvent::error(format!(
"{label}失败:命令退出码非 0(原始错误见上方 stderr 输出)。{hint}"
)));
return Err(EngineError::Exec(format!("{label} 命令退出码非 0")));
let hint = map_exec_error(prog, None, &stderr_tail);
emit(ActionEvent::error_hint(&hint));
return Err(EngineError::Exec(hint.friendly_zh.clone()));
}
}
@@ -864,25 +1175,6 @@ where
Ok(())
}
/// 安装失败时的中文诊断建议(按适配器声明的依赖/渠道给针对性提示,不吞原始错误)。
fn diag_hint_for_install_failure(adapter: &Adapter, spawn_err: &str) -> String {
let mut parts: Vec<String> = Vec::new();
if !spawn_err.is_empty() {
parts.push("请确认所需命令已安装并加入 PATH(可到「诊断」页排查)".into());
}
let deps: Vec<&str> = adapter
.runtime_deps
.iter()
.filter(|d| d.required_for.iter().any(|r| r == "install" || r == "run"))
.map(|d| d.id.as_str())
.collect();
if !deps.is_empty() {
parts.push(format!("依赖检查:{}", deps.join("")));
}
parts.push("常见原因:网络不通 / 依赖版本不符 / 需要管理员权限,可在「诊断」页查看详细结论。".into());
format!("{}", parts.join(""))
}
// =====================================================================
// 测试
// =====================================================================
@@ -1058,6 +1350,47 @@ configuration:
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn write_goose_yaml_config_fields() {
// goose 用 yaml 配置 + files[].platforms 按平台选型;验证 yaml 编解码已接进 write_config
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../adapters");
let adapters = load_adapters(&dir).expect("真实适配器目录应可加载");
let mut adapter = adapters.into_iter().find(|a| a.id == "goose").unwrap();
let tmp = std::env::temp_dir().join(format!("agentdock-core-goose-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap();
let file = tmp.join("config.yaml");
if let Some(c) = adapter.configuration.as_mut() {
for f in &mut c.files {
if file_applies_to_current_platform(f) {
f.path = file.to_string_lossy().to_string();
}
}
}
let secrets = MockSecretStore::new();
let mut patch = std::collections::BTreeMap::new();
patch.insert("GOOSE_PROVIDER".to_string(), "anthropic".to_string());
patch.insert("GOOSE_MODEL".to_string(), "claude-sonnet-4".to_string());
patch.insert("api_key".to_string(), "sk-test-xxx".to_string());
let res = write_config(&adapter, &patch, &secrets).unwrap();
assert_eq!(res.written_fields.len(), 3);
assert!(res.written_file.is_some(), "应写入当前平台配置文件");
let text = std::fs::read_to_string(&file).unwrap();
assert!(text.contains("anthropic"), "provider 应写入 yaml: {text}");
assert!(text.contains("claude-sonnet-4"), "model 应写入 yaml: {text}");
assert!(!text.contains("sk-test-xxx"), "API Key 不得写入配置文件");
assert!(secrets.has(&service_name("goose"), "api_key"));
let state = read_config(&adapter, &secrets).unwrap();
let provider = state.fields.iter().find(|f| f.id == "GOOSE_PROVIDER").unwrap();
assert_eq!(provider.value.as_deref(), Some("anthropic"));
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn write_config_creates_backup_on_second_write() {
let adapter = codex_adapter();
@@ -1258,6 +1591,31 @@ configuration:
}
}
/// 真机检测记录:对 Wave 3 九个新工具逐项 detect 并打印结果(自测记录用)。
/// 运行:cargo test -p agentdock-core real_machine_detect_wave3 -- --ignored --nocapture
#[test]
#[ignore]
fn real_machine_detect_wave3() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../adapters");
let adapters = load_adapters(&dir).expect("适配器目录应可加载");
let nine = ["qwen", "codebuddy", "cline", "copilot", "crush", "goose", "aider", "cursor", "warp"];
for id in nine {
let Some(adapter) = adapters.iter().find(|a| a.id == id) else {
panic!("缺少 {id}");
};
let r = detect_one(adapter);
println!(
"{} ({}): status={} version={:?} exe={:?}",
id, adapter.name_zh, r.status, r.version, r.executable
);
assert!([
"installed", "not_installed", "not_in_path", "permission_denied",
"exec_failed", "version_unparseable",
]
.contains(&r.status.as_str()));
}
}
/// 真机安装/卸载往返:gemini(npm 渠道,用户目录、可逆)。
/// 运行:cargo test -p agentdock-core real_machine_install_uninstall_gemini -- --ignored --nocapture
#[test]
@@ -1337,4 +1695,142 @@ configuration:
println!("重装后 opencode: status={} version={:?}", after.status, after.version);
assert_eq!(after.status, "installed", "重装后应恢复为已安装");
}
// ---- Wave 2.2:设备码解析 / ANSI 去色 / 错误人话化 ----
#[test]
fn strip_ansi_removes_color_codes() {
let raw = "code \u{1b}[94mBFEZ-121LZ\u{1b}[0m end";
assert_eq!(strip_ansi(raw), "code BFEZ-121LZ end");
}
#[test]
fn parse_device_code_extracts_code_and_url() {
let text = "\n1. Open this link in your browser and sign in to your account\n https://auth.openai.com/codex/device\n\n2. Enter this one-time code (expires in 15 minutes)\n BFEZ-121LZ\n";
let (code, url) = parse_device_code(text).expect("应解析出设备码与链接");
assert_eq!(code, "BFEZ-121LZ");
assert!(url.contains("auth.openai.com"));
}
#[test]
fn parse_device_code_handles_ansi() {
let text = "\u{1b}[94mhttps://auth.openai.com/codex/device\u{1b}[0m\n\u{1b}[94mBFEZ-121LZ\u{1b}[0m\n";
let (code, url) = parse_device_code(&strip_ansi(text)).expect("去色后应能解析");
assert_eq!(code, "BFEZ-121LZ");
assert!(url.contains("auth.openai.com"));
}
#[test]
fn error_event_carries_hint_data() {
use crate::errors_zh::map_exec_error;
let e = std::io::Error::new(std::io::ErrorKind::NotFound, "program not found");
let hint = map_exec_error("npm", Some(&e), "");
let ev = ActionEvent::error_hint(&hint);
assert_eq!(ev.kind, "error");
assert!(ev.message.to_lowercase().contains("node"));
let data = ev.data.expect("error_hint 应携带 data");
assert_eq!(data["code"], "program_not_found");
assert_eq!(data["missing_runtime"], "node");
}
// ---- Wave 2.2 真机自测(--ignored):实时滚动留证 / 设备码到出码步骤留证 ----
/// 实时滚动留证:运行交错输出 stdout/stderr 的命令,断言 stderr 行在
/// stdout 行之间到达(证明是并发实时上屏,而非「先 stdout 后 stderr」的结束回放)。
/// 运行:cargo test -p agentdock-core real_machine_streaming_evidence -- --ignored --nocapture
#[test]
#[ignore]
fn real_machine_streaming_evidence() {
#[cfg(windows)]
let (exe, args) = (
"powershell".to_string(),
vec![
"-NoProfile".to_string(),
"-Command".to_string(),
"1..4 | ForEach-Object { Write-Output ('out'+$_); [Console]::Error.WriteLine('err'+$_); Start-Sleep -Milliseconds 400 }".to_string(),
],
);
#[cfg(not(windows))]
let (exe, args) = (
"sh".to_string(),
vec![
"-c".to_string(),
"for i in 1 2 3 4; do echo out$i; echo err$i 1>&2; sleep 0.4; done".to_string(),
],
);
let mut sequence: Vec<(bool, String, u128)> = Vec::new();
let start = std::time::Instant::now();
let ok = run_streaming(&exe, &args, |is_err, line| {
sequence.push((is_err, line.to_string(), start.elapsed().as_millis()));
})
.expect("流式命令应可执行");
assert!(ok);
for (is_err, line, ms) in &sequence {
println!(" [{:>5}ms] {} {}", ms, if *is_err { "stderr" } else { "stdout" }, line);
}
// 关键断言:首个 stderr 行必须早于最后一个 stdout 行(即交错而非结尾回放)
let first_stderr_idx = sequence.iter().position(|(e, _, _)| *e);
let last_stdout_idx = sequence.iter().rposition(|(e, _, _)| !*e);
if let (Some(fe), Some(lo)) = (first_stderr_idx, last_stdout_idx) {
assert!(fe < lo, "stderr 应在 stdout 结束前交错到达(实时),而非全部延迟到结尾");
}
assert!(sequence.len() >= 8, "应收到全部 8 行");
}
/// 软件内授权留证(设备码):真机跑 codex login --device-auth,解析出设备码 +
/// 验证链接后取消(不要求真实账号完成最后一步,走到「等待用户确认/出码」即达标)。
/// 运行:cargo test -p agentdock-core real_machine_device_code_evidence -- --ignored --nocapture
#[test]
#[ignore]
fn real_machine_device_code_evidence() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../adapters");
let adapters = load_adapters(&dir).expect("适配器目录应可加载");
let codex = adapters.iter().find(|a| a.id == "codex").expect("缺少 codex");
let target = codex
.authorization
.as_ref()
.and_then(|a| a.modes.iter().find(|m| m.mode == "device_code"))
.expect("codex 应声明 device_code 授权");
if target.command.is_empty() {
println!("codex 未声明 device_code 命令,跳过");
return;
}
let exe = resolve_exe(&target.command[0]);
let args: Vec<String> = target.command[1..].to_vec();
let rp = RunningProcess::new();
let found: Arc<Mutex<Option<(String, String)>>> = Arc::new(Mutex::new(None));
let found2 = found.clone();
let cancel = rp.cancel_flag();
// 8 秒后自动取消(走到出码即可,不等真实登录)
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_secs(8));
cancel.store(true, std::sync::atomic::Ordering::SeqCst);
});
let mut captured = String::new();
let _ = run_streaming_cancellable(&rp, &exe, &args, |is_err, line| {
let clean = strip_ansi(line);
if !is_err {
captured.push_str(&clean);
captured.push('\n');
}
if found2.lock().unwrap().is_none() {
if let Some((code, url)) = parse_device_code(&captured) {
*found2.lock().unwrap() = Some((code, url));
}
}
});
let (code, url) = found.lock().unwrap().clone().expect("应在 8 秒内解析出设备码");
println!("=== codex 设备码流程(到出码步骤) ===");
println!("验证链接: {url}");
println!("设备码: {code}");
assert!(url.contains("openai.com") || url.contains("auth.openai"), "验证链接应指向官方域名: {url}");
assert!(!code.is_empty(), "设备码不应为空");
}
}
+174
View File
@@ -0,0 +1,174 @@
//! 执行失败的错误人话化映射(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,
}
}
/// 运行时 id → 中文显示名(与前端 RuntimeInstallModal 的 RUNTIME_LABELS 对齐)。
/// 「node」统一显示为「Node.js」,避免人话提示里出现 id 与按钮文案不一致。
pub fn runtime_label_zh(id: &str) -> &'static str {
match id {
"node" => "Node.js",
"python" => "Python",
"git" => "Git",
"winget" => "winget",
"uv" => "uv",
"choco" => "choco",
"scoop" => "scoop",
_ => "运行时",
}
}
/// 主入口:结合 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} 命令:需要先安装 {}(可在下方「本机环境」区一键安装)", runtime_label_zh(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"));
// 文案统一为「Node.js」(与按钮/运行时中文名一致,而非裸 id「node」)
assert!(h.friendly_zh.contains("Node.js"), "人话提示应显示 Node.js{}", h.friendly_zh);
assert!(!h.friendly_zh.contains("安装 node"), "不应出现裸 id「node」:{}", h.friendly_zh);
}
#[test]
fn runtime_label_zh_maps_node() {
assert_eq!(runtime_label_zh("node"), "Node.js");
assert_eq!(runtime_label_zh("python"), "Python");
assert_eq!(runtime_label_zh("git"), "Git");
assert_eq!(runtime_label_zh("winget"), "winget");
}
#[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);
}
}
+13 -3
View File
@@ -6,12 +6,22 @@
pub mod engine;
pub mod error;
pub mod errors_zh;
pub mod process;
pub mod runtime_install;
pub mod types;
pub use engine::{ActionOpts, Engine, auth_status, authorize, detect_one, diagnose, parse_version, read_config, run_action, verify_config, write_config};
pub use engine::{
ActionOpts, Engine, auth_mode_label_zh, auth_status, detect_one, diagnose, parse_device_code,
parse_version, read_config, run_action, strip_ansi, verify_config, write_config,
};
pub use error::EngineError;
pub use errors_zh::{map_exec_error, runtime_for_prog};
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, AuthStatus, AuthModeInfo, ConfigFieldState, ConfigFileState, ConfigFormState, DetectResult,
EnvFieldState, WriteResult, ConfigVerifyResult, now_secs,
ActionEvent, AuthFlowEvent, AuthStatus, AuthModeInfo, ConfigFieldState, ConfigFileState, ConfigFormState,
DetectResult, EnvFieldState, ErrorHint, WriteResult, ConfigVerifyResult, now_secs,
};
+254 -14
View File
@@ -2,10 +2,16 @@
//!
//! 所有命令经 `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::{Command, Stdio};
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, mpsc};
/// 按平台分隔符拆分 PATH。
fn path_entries() -> Vec<String> {
@@ -37,6 +43,14 @@ pub fn which_all(program: &str) -> Vec<PathBuf> {
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);
@@ -54,9 +68,84 @@ pub fn run_capture(exe: &Path, args: &[String]) -> std::io::Result<(bool, String
Ok((out.status.success(), text))
}
/// 流式执行命令,逐行回调(stdout 与 stderr 均按行输出)。返回是否成功。
/// 顺序读:先 stdoutstderr;对长任务足够,且实现简单可靠、无闭包 Send 负担
pub fn run_streaming<F>(exe: &str, args: &[String], mut on_line: F) -> std::io::Result<bool>
/// 运行命令并把一段文本写入其 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),
{
@@ -71,19 +160,170 @@ where
}
let mut child = cmd.spawn()?;
if let Some(stdout) = child.stdout.take() {
let reader = std::io::BufReader::new(stdout);
for line in reader.lines().map_while(Result::ok) {
on_line(false, &line);
}
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;
}
}
});
}
if let Some(stderr) = child.stderr.take() {
let reader = std::io::BufReader::new(stderr);
for line in reader.lines().map_while(Result::ok) {
on_line(true, &line);
// 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 = child.wait()?;
// 等待子进程退出并取状态
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);
}
}
+119
View File
@@ -163,6 +163,125 @@ impl ActionEvent {
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 秒。
+23
View File
@@ -91,3 +91,26 @@ impl SecretStore for KeyringSecretStore {
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 已清理)");
}
}