Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01996a80fe | ||
|
|
c47494180b | ||
|
|
7208586850 |
Generated
+4880
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://agentdock.local/schemas/adapter.schema.json",
|
||||
"title": "AgentDock Adapter",
|
||||
"description": "Agent CLI 适配器定义(v1)。Wave 0 仅含占位字段;完整 schema 见架构 §3.1,随 Wave 1 落地。",
|
||||
"description": "Agent CLI 适配器定义(v1,对齐架构 §3.1)。首批 14 工具在 Wave 1 仅保留 id/name/name_zh/vendor/status 五字段占位;其余字段(platforms/official/runtime_deps/install/detect/update/uninstall/authorization/configuration/diagnostics/documentation)为可选,供 Wave 2 起逐工具填充。所有 command 必须是 argv 数组,禁止 shell 元字符与字符串拼接。",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "name", "name_zh", "vendor", "status"],
|
||||
@@ -28,6 +28,267 @@
|
||||
"type": "string",
|
||||
"enum": ["available", "watch"],
|
||||
"description": "目录状态:available=可安装列表;watch=观察中(第二批)"
|
||||
},
|
||||
"adapter_version": {
|
||||
"type": "string",
|
||||
"description": "适配器自身版本(semver,如 1.2.0)",
|
||||
"pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\\+[0-9A-Za-z.-]+)?$"
|
||||
},
|
||||
"license": {
|
||||
"type": "string",
|
||||
"description": "展示用许可;专有许可注明「仅官方渠道安装、不重打包」"
|
||||
},
|
||||
"platforms": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"windows": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"architectures": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "enum": ["x64", "arm64"] }
|
||||
},
|
||||
"notes": {
|
||||
"type": "string",
|
||||
"description": "如 Gemini 要求 Win11 24H2+"
|
||||
}
|
||||
}
|
||||
},
|
||||
"linux": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"distributions": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "enum": ["ubuntu", "debian"] }
|
||||
},
|
||||
"architectures": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "enum": ["x64", "arm64"] }
|
||||
},
|
||||
"min_ubuntu": {
|
||||
"type": "string",
|
||||
"description": "最低 Ubuntu 版本,如 22.04"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"official": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"homepage": { "type": "string", "format": "uri" },
|
||||
"docs": { "type": "string", "format": "uri" },
|
||||
"allowed_hosts": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "网络白名单(诊断/下载仅可访问这些主机)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"runtime_deps": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "enum": ["node", "python", "git", "powershell", "uv", "bash"] },
|
||||
"semver_range": { "type": "string", "description": "如 >=20" },
|
||||
"required_for": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "enum": ["install", "run"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"install": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"preferred": { "type": "string", "description": "默认渠道 channel id" },
|
||||
"channels": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/definitions/channel" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"detect": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["executable"],
|
||||
"properties": {
|
||||
"executable": { "type": "string", "description": "PATH 上的命令名;Cursor 为 agent" },
|
||||
"version_args": { "type": "array", "items": { "type": "string" } },
|
||||
"version_regex": { "type": "string" },
|
||||
"version_unconfirmed": { "type": "boolean", "description": "调研标注「文档未能确认」时 true" },
|
||||
"path_hints": { "type": "array", "items": { "type": "string" }, "description": "非 PATH 常见位置" }
|
||||
}
|
||||
},
|
||||
"update": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": ["npm_update", "self_update_cmd", "channel_reinstall", "winget_upgrade", "pypi_upgrade", "manual"]
|
||||
},
|
||||
"command": { "type": "array", "items": { "type": "string" } }
|
||||
}
|
||||
},
|
||||
"uninstall": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"method": { "type": "string", "enum": ["npm_uninstall", "package_manager", "manual_delete"] },
|
||||
"command": { "type": "array", "items": { "type": "string" } },
|
||||
"keep_config_default": { "type": "boolean", "default": true }
|
||||
}
|
||||
},
|
||||
"authorization": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"modes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["mode"],
|
||||
"properties": {
|
||||
"mode": { "type": "string", "enum": ["browser_oauth", "device_code", "api_key", "local_tui"] },
|
||||
"command": { "type": "array", "items": { "type": "string" } },
|
||||
"env_keys": { "type": "array", "items": { "type": "string" } },
|
||||
"status_command": { "type": "array", "items": { "type": "string" } },
|
||||
"notes_zh": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"configuration": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"files": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["path", "format"],
|
||||
"properties": {
|
||||
"path": { "type": "string", "description": "支持 ~ 与平台变量" },
|
||||
"format": { "type": "string", "enum": ["toml", "json", "jsonc", "yaml", "env", "crushrc"] },
|
||||
"scope": { "type": "string", "enum": ["user", "project", "system"] }
|
||||
}
|
||||
}
|
||||
},
|
||||
"environment": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["key"],
|
||||
"properties": {
|
||||
"key": { "type": "string" },
|
||||
"sensitive": { "type": "boolean", "default": false },
|
||||
"maps_to_field": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "label_zh", "type", "storage"],
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"label_zh": { "type": "string" },
|
||||
"help_zh": { "type": "string" },
|
||||
"required": { "type": "boolean", "default": false },
|
||||
"sensitive": { "type": "boolean", "default": false },
|
||||
"type": { "type": "string", "enum": ["string", "url", "enum", "bool"] },
|
||||
"storage": { "type": "string", "enum": ["file", "env", "keyring"], "description": "keyring 永不进普通备份" },
|
||||
"platforms": { "type": "array", "items": { "type": "string", "enum": ["windows", "linux"] } },
|
||||
"docs_url": { "type": "string", "format": "uri" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"diagnostics": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["rule_id"],
|
||||
"properties": {
|
||||
"rule_id": { "type": "string", "description": "引用规则库或内联" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"documentation": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"quickstart_zh": { "type": "string" },
|
||||
"commands": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"cmd": { "type": "string" },
|
||||
"desc_zh": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"updated_at": { "type": "string", "format": "date" },
|
||||
"risks_zh": { "type": "array", "items": { "type": "string" } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"channel": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"enum": ["npm", "official_script", "winget", "choco", "scoop", "brew", "apt", "pypi_uv", "github_release"]
|
||||
},
|
||||
"platforms": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "enum": ["windows", "linux"] }
|
||||
},
|
||||
"command": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "命令必须是 argv 数组,禁止字符串拼接与 shell 元字符(| & ; $ \\ > < ( `)"
|
||||
},
|
||||
"script": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"url": { "type": "string", "format": "uri" },
|
||||
"kind": { "type": "string", "enum": ["powershell_irm", "bash_pipe", "ps1_file"] },
|
||||
"integrity": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": { "sha256": { "type": "string", "pattern": "^[0-9a-fA-F]{64}$" } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"package": { "type": "string", "description": "npm/pypi 包名" },
|
||||
"elevate": { "type": "string", "enum": ["never", "if_needed", "required"] },
|
||||
"elevate_reason_zh": { "type": "string" },
|
||||
"post_checks": { "type": "array", "items": { "type": "string" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,26 @@
|
||||
# aider 适配器占位(Wave 0)
|
||||
# ============================================================
|
||||
# AgentDock 适配器占位(Wave 1,对齐架构 §3.1 完整 schema)
|
||||
# 本波仅填五字段(id/name/name_zh/vendor/status);其余字段留空,
|
||||
# Wave 2 起按调研底稿 agent-cli-survey-2026-08-24.md 逐项填充,禁止编造。
|
||||
#
|
||||
# 完整字段(全部留空占位,Wave 2 填写):
|
||||
# adapter_version # semver,如 1.2.0
|
||||
# license # 展示用;专有许可注明「仅官方渠道安装、不重打包」
|
||||
# platforms # windows/linux:architectures、notes、min_ubuntu
|
||||
# official # homepage / docs / allowed_hosts(网络白名单)
|
||||
# runtime_deps # [ { id, semver_range, required_for: [install|run] } ]
|
||||
# install # preferred + channels[ {id, platforms, command[], script, package, elevate, elevate_reason_zh, post_checks} ]
|
||||
# detect # executable / version_args / version_regex / version_unconfirmed / path_hints
|
||||
# update # method + command[]
|
||||
# uninstall # method + command[] + keep_config_default
|
||||
# authorization # modes[ {mode, command[], env_keys[], status_command[], notes_zh} ]
|
||||
# configuration # files[] / environment[] / fields[]
|
||||
# diagnostics # [ { rule_id } ]
|
||||
# documentation # quickstart_zh / commands[] / updated_at / risks_zh[]
|
||||
#
|
||||
# 铁律:所有 command 一律 argv 数组,禁止 shell 元字符(| & ; $ \ > < (`);
|
||||
# 本波不实现任何真实安装/检测/配置/授权命令(那是 Wave 2 的事)。
|
||||
# ============================================================
|
||||
id: aider
|
||||
name: Aider
|
||||
name_zh: Aider
|
||||
|
||||
@@ -1,6 +1,127 @@
|
||||
# claude-code 适配器占位(Wave 0)
|
||||
# ============================================================
|
||||
# AgentDock 适配器 · Anthropic Claude Code(Wave 2 全字段)
|
||||
# 数据依据:调研底稿 agent-cli-survey-2026-08-24.md §2 + 架构 §3.3
|
||||
# ============================================================
|
||||
id: claude-code
|
||||
name: Claude Code
|
||||
name_zh: Claude Code
|
||||
vendor: Anthropic
|
||||
status: available
|
||||
adapter_version: 1.0.0
|
||||
license: 专有(仅官方渠道安装、不重打包、不修改二进制)
|
||||
|
||||
platforms:
|
||||
windows:
|
||||
architectures: [x64]
|
||||
notes: 原生支持,Windows 10 1809+ / Windows Server 2019+
|
||||
linux:
|
||||
distributions: [ubuntu]
|
||||
architectures: [x64]
|
||||
min_ubuntu: "20.04"
|
||||
|
||||
official:
|
||||
homepage: https://claude.ai
|
||||
docs: https://code.claude.com/docs/en/setup
|
||||
allowed_hosts: [claude.ai, registry.npmjs.org, github.com]
|
||||
|
||||
runtime_deps: []
|
||||
|
||||
install:
|
||||
preferred: winget
|
||||
channels:
|
||||
- id: winget
|
||||
platforms: [windows]
|
||||
command: [winget, install, Anthropic.ClaudeCode]
|
||||
package: Anthropic.ClaudeCode
|
||||
elevate: never
|
||||
post_checks: [detect]
|
||||
- id: official_script
|
||||
platforms: [windows]
|
||||
script:
|
||||
url: https://claude.ai/install.ps1
|
||||
kind: powershell_irm
|
||||
elevate: never
|
||||
post_checks: [detect]
|
||||
- id: npm
|
||||
platforms: [windows, linux]
|
||||
command: [npm, install, -g, "@anthropic-ai/claude-code"]
|
||||
package: "@anthropic-ai/claude-code"
|
||||
elevate: never
|
||||
post_checks: [detect]
|
||||
|
||||
detect:
|
||||
executable: claude
|
||||
version_args: ["--version"]
|
||||
version_regex: "^(\\d+\\.\\d+\\.\\d+)"
|
||||
|
||||
update:
|
||||
method: winget_upgrade
|
||||
command: [winget, upgrade, Anthropic.ClaudeCode]
|
||||
|
||||
uninstall:
|
||||
method: package_manager
|
||||
command: [winget, uninstall, Anthropic.ClaudeCode]
|
||||
keep_config_default: true
|
||||
|
||||
authorization:
|
||||
modes:
|
||||
- mode: browser_oauth
|
||||
command: [claude]
|
||||
notes_zh: 运行 claude 后按浏览器提示登录(Pro/Max/Team/Enterprise/Console 账号)
|
||||
- mode: api_key
|
||||
env_keys: [ANTHROPIC_API_KEY]
|
||||
notes_zh: 设置 ANTHROPIC_API_KEY 后 CLI 优先使用 API Key
|
||||
|
||||
configuration:
|
||||
files:
|
||||
- path: "~/.claude/settings.json"
|
||||
format: json
|
||||
scope: user
|
||||
environment:
|
||||
- key: ANTHROPIC_API_KEY
|
||||
sensitive: true
|
||||
maps_to_field: api_key
|
||||
- key: ANTHROPIC_BASE_URL
|
||||
sensitive: false
|
||||
maps_to_field: base_url
|
||||
fields:
|
||||
- id: model
|
||||
label_zh: 默认模型
|
||||
help_zh: Claude Code 使用的默认模型
|
||||
required: false
|
||||
sensitive: false
|
||||
type: string
|
||||
storage: file
|
||||
- id: env.ANTHROPIC_BASE_URL
|
||||
label_zh: Base URL
|
||||
help_zh: 自定义 API 端点 / 网关 / 代理(写入 settings.json 的 env 块)
|
||||
required: false
|
||||
sensitive: false
|
||||
type: url
|
||||
storage: file
|
||||
- id: api_key
|
||||
label_zh: API Key
|
||||
help_zh: 存入系统密钥库(对应 ANTHROPIC_API_KEY)
|
||||
required: false
|
||||
sensitive: true
|
||||
type: string
|
||||
storage: keyring
|
||||
|
||||
diagnostics:
|
||||
- rule_id: path.not_installed
|
||||
- rule_id: path.not_in_path
|
||||
- rule_id: version_conflict.multiple_copies
|
||||
- rule_id: config.corrupt
|
||||
|
||||
documentation:
|
||||
quickstart_zh: 安装后运行 `claude` 登录,或用 `claude -p "提示词"` 无头执行。
|
||||
commands:
|
||||
- cmd: claude
|
||||
desc_zh: 启动交互式会话
|
||||
- cmd: claude -p "提示词"
|
||||
desc_zh: 无头单次执行
|
||||
- cmd: claude doctor
|
||||
desc_zh: 环境诊断
|
||||
updated_at: "2026-08-24"
|
||||
risks_zh:
|
||||
- 专有许可:只引导官方渠道安装,不重打包、不修改二进制
|
||||
|
||||
@@ -1,4 +1,26 @@
|
||||
# cline 适配器占位(Wave 0)
|
||||
# ============================================================
|
||||
# AgentDock 适配器占位(Wave 1,对齐架构 §3.1 完整 schema)
|
||||
# 本波仅填五字段(id/name/name_zh/vendor/status);其余字段留空,
|
||||
# Wave 2 起按调研底稿 agent-cli-survey-2026-08-24.md 逐项填充,禁止编造。
|
||||
#
|
||||
# 完整字段(全部留空占位,Wave 2 填写):
|
||||
# adapter_version # semver,如 1.2.0
|
||||
# license # 展示用;专有许可注明「仅官方渠道安装、不重打包」
|
||||
# platforms # windows/linux:architectures、notes、min_ubuntu
|
||||
# official # homepage / docs / allowed_hosts(网络白名单)
|
||||
# runtime_deps # [ { id, semver_range, required_for: [install|run] } ]
|
||||
# install # preferred + channels[ {id, platforms, command[], script, package, elevate, elevate_reason_zh, post_checks} ]
|
||||
# detect # executable / version_args / version_regex / version_unconfirmed / path_hints
|
||||
# update # method + command[]
|
||||
# uninstall # method + command[] + keep_config_default
|
||||
# authorization # modes[ {mode, command[], env_keys[], status_command[], notes_zh} ]
|
||||
# configuration # files[] / environment[] / fields[]
|
||||
# diagnostics # [ { rule_id } ]
|
||||
# documentation # quickstart_zh / commands[] / updated_at / risks_zh[]
|
||||
#
|
||||
# 铁律:所有 command 一律 argv 数组,禁止 shell 元字符(| & ; $ \ > < (`);
|
||||
# 本波不实现任何真实安装/检测/配置/授权命令(那是 Wave 2 的事)。
|
||||
# ============================================================
|
||||
id: cline
|
||||
name: Cline
|
||||
name_zh: Cline
|
||||
|
||||
@@ -1,4 +1,26 @@
|
||||
# codebuddy 适配器占位(Wave 0)
|
||||
# ============================================================
|
||||
# AgentDock 适配器占位(Wave 1,对齐架构 §3.1 完整 schema)
|
||||
# 本波仅填五字段(id/name/name_zh/vendor/status);其余字段留空,
|
||||
# Wave 2 起按调研底稿 agent-cli-survey-2026-08-24.md 逐项填充,禁止编造。
|
||||
#
|
||||
# 完整字段(全部留空占位,Wave 2 填写):
|
||||
# adapter_version # semver,如 1.2.0
|
||||
# license # 展示用;专有许可注明「仅官方渠道安装、不重打包」
|
||||
# platforms # windows/linux:architectures、notes、min_ubuntu
|
||||
# official # homepage / docs / allowed_hosts(网络白名单)
|
||||
# runtime_deps # [ { id, semver_range, required_for: [install|run] } ]
|
||||
# install # preferred + channels[ {id, platforms, command[], script, package, elevate, elevate_reason_zh, post_checks} ]
|
||||
# detect # executable / version_args / version_regex / version_unconfirmed / path_hints
|
||||
# update # method + command[]
|
||||
# uninstall # method + command[] + keep_config_default
|
||||
# authorization # modes[ {mode, command[], env_keys[], status_command[], notes_zh} ]
|
||||
# configuration # files[] / environment[] / fields[]
|
||||
# diagnostics # [ { rule_id } ]
|
||||
# documentation # quickstart_zh / commands[] / updated_at / risks_zh[]
|
||||
#
|
||||
# 铁律:所有 command 一律 argv 数组,禁止 shell 元字符(| & ; $ \ > < (`);
|
||||
# 本波不实现任何真实安装/检测/配置/授权命令(那是 Wave 2 的事)。
|
||||
# ============================================================
|
||||
id: codebuddy
|
||||
name: CodeBuddy Code
|
||||
name_zh: CodeBuddy Code
|
||||
|
||||
+126
-1
@@ -1,6 +1,131 @@
|
||||
# codex 适配器占位(Wave 0)
|
||||
# ============================================================
|
||||
# AgentDock 适配器 · OpenAI Codex CLI(Wave 2 全字段)
|
||||
# 数据依据:调研底稿 agent-cli-survey-2026-08-24.md §1 + 架构 §3.3
|
||||
# ============================================================
|
||||
id: codex
|
||||
name: Codex CLI
|
||||
name_zh: Codex CLI
|
||||
vendor: OpenAI
|
||||
status: available
|
||||
adapter_version: 1.0.0
|
||||
license: Apache-2.0
|
||||
|
||||
platforms:
|
||||
windows:
|
||||
architectures: [x64]
|
||||
notes: 原生支持(PowerShell 脚本安装,非 WSL);亦可走 npm
|
||||
linux:
|
||||
distributions: [ubuntu]
|
||||
architectures: [x64]
|
||||
|
||||
official:
|
||||
homepage: https://github.com/openai/codex
|
||||
docs: https://learn.chatgpt.com/docs/codex/cli
|
||||
allowed_hosts: [chatgpt.com, github.com, releases.openai.com, registry.npmjs.org]
|
||||
|
||||
runtime_deps: []
|
||||
|
||||
install:
|
||||
preferred: npm
|
||||
channels:
|
||||
- id: npm
|
||||
platforms: [windows, linux]
|
||||
command: [npm, install, -g, "@openai/codex"]
|
||||
package: "@openai/codex"
|
||||
elevate: never
|
||||
post_checks: [detect]
|
||||
- id: official_script
|
||||
platforms: [windows]
|
||||
script:
|
||||
url: https://chatgpt.com/codex/install.ps1
|
||||
kind: powershell_irm
|
||||
elevate: never
|
||||
post_checks: [detect]
|
||||
- id: github_release
|
||||
platforms: [windows, linux]
|
||||
package: codex
|
||||
elevate: never
|
||||
post_checks: [detect]
|
||||
|
||||
detect:
|
||||
executable: codex
|
||||
version_args: ["--version"]
|
||||
version_regex: "^codex-cli (\\d+\\.\\d+\\.\\d+)"
|
||||
version_unconfirmed: true
|
||||
|
||||
update:
|
||||
method: npm_update
|
||||
command: [npm, update, -g, "@openai/codex"]
|
||||
|
||||
uninstall:
|
||||
method: npm_uninstall
|
||||
command: [npm, uninstall, -g, "@openai/codex"]
|
||||
keep_config_default: true
|
||||
|
||||
authorization:
|
||||
modes:
|
||||
- mode: browser_oauth
|
||||
command: [codex, login]
|
||||
status_command: [codex, login, status]
|
||||
notes_zh: 浏览器登录 ChatGPT 账号(需 Plus/Pro/Business/EDU/Enterprise 计划)
|
||||
- mode: device_code
|
||||
command: [codex, login, --device-auth]
|
||||
notes_zh: 设备码登录,不弹浏览器
|
||||
- mode: api_key
|
||||
command: [codex, login, --with-api-key]
|
||||
env_keys: [OPENAI_API_KEY]
|
||||
notes_zh: API Key 从系统密钥库读取,经 stdin 注入,不进 argv
|
||||
|
||||
configuration:
|
||||
files:
|
||||
- path: "~/.codex/config.toml"
|
||||
format: toml
|
||||
scope: user
|
||||
environment:
|
||||
- key: OPENAI_API_KEY
|
||||
sensitive: true
|
||||
maps_to_field: api_key
|
||||
- key: OPENAI_BASE_URL
|
||||
sensitive: false
|
||||
maps_to_field: openai_base_url
|
||||
fields:
|
||||
- id: model
|
||||
label_zh: 默认模型
|
||||
help_zh: Codex 使用的默认模型(如 gpt-5.6-terra)
|
||||
required: false
|
||||
sensitive: false
|
||||
type: string
|
||||
storage: file
|
||||
- id: openai_base_url
|
||||
label_zh: Base URL
|
||||
help_zh: 自定义 OpenAI 兼容端点(简化键)
|
||||
required: false
|
||||
sensitive: false
|
||||
type: url
|
||||
storage: file
|
||||
- id: api_key
|
||||
label_zh: API Key
|
||||
help_zh: 存入系统密钥库,写入后仅显示「已保存」
|
||||
required: false
|
||||
sensitive: true
|
||||
type: string
|
||||
storage: keyring
|
||||
|
||||
diagnostics:
|
||||
- rule_id: path.not_installed
|
||||
- rule_id: path.not_in_path
|
||||
- rule_id: version_conflict.multiple_copies
|
||||
- rule_id: config.corrupt
|
||||
|
||||
documentation:
|
||||
quickstart_zh: 安装后运行 `codex` 登录账号,或用 `codex exec "任务"` 无头执行。
|
||||
commands:
|
||||
- cmd: codex
|
||||
desc_zh: 启动交互式会话
|
||||
- cmd: codex exec "提示词"
|
||||
desc_zh: 无头单次执行
|
||||
- cmd: codex login status
|
||||
desc_zh: 查询登录状态
|
||||
updated_at: "2026-08-24"
|
||||
risks_zh:
|
||||
- --version 官方文档未确认,适配器已标 version_unconfirmed 并实测兜底
|
||||
|
||||
@@ -1,4 +1,26 @@
|
||||
# copilot 适配器占位(Wave 0)
|
||||
# ============================================================
|
||||
# AgentDock 适配器占位(Wave 1,对齐架构 §3.1 完整 schema)
|
||||
# 本波仅填五字段(id/name/name_zh/vendor/status);其余字段留空,
|
||||
# Wave 2 起按调研底稿 agent-cli-survey-2026-08-24.md 逐项填充,禁止编造。
|
||||
#
|
||||
# 完整字段(全部留空占位,Wave 2 填写):
|
||||
# adapter_version # semver,如 1.2.0
|
||||
# license # 展示用;专有许可注明「仅官方渠道安装、不重打包」
|
||||
# platforms # windows/linux:architectures、notes、min_ubuntu
|
||||
# official # homepage / docs / allowed_hosts(网络白名单)
|
||||
# runtime_deps # [ { id, semver_range, required_for: [install|run] } ]
|
||||
# install # preferred + channels[ {id, platforms, command[], script, package, elevate, elevate_reason_zh, post_checks} ]
|
||||
# detect # executable / version_args / version_regex / version_unconfirmed / path_hints
|
||||
# update # method + command[]
|
||||
# uninstall # method + command[] + keep_config_default
|
||||
# authorization # modes[ {mode, command[], env_keys[], status_command[], notes_zh} ]
|
||||
# configuration # files[] / environment[] / fields[]
|
||||
# diagnostics # [ { rule_id } ]
|
||||
# documentation # quickstart_zh / commands[] / updated_at / risks_zh[]
|
||||
#
|
||||
# 铁律:所有 command 一律 argv 数组,禁止 shell 元字符(| & ; $ \ > < (`);
|
||||
# 本波不实现任何真实安装/检测/配置/授权命令(那是 Wave 2 的事)。
|
||||
# ============================================================
|
||||
id: copilot
|
||||
name: Copilot CLI
|
||||
name_zh: Copilot CLI
|
||||
|
||||
@@ -1,4 +1,26 @@
|
||||
# crush 适配器占位(Wave 0)
|
||||
# ============================================================
|
||||
# AgentDock 适配器占位(Wave 1,对齐架构 §3.1 完整 schema)
|
||||
# 本波仅填五字段(id/name/name_zh/vendor/status);其余字段留空,
|
||||
# Wave 2 起按调研底稿 agent-cli-survey-2026-08-24.md 逐项填充,禁止编造。
|
||||
#
|
||||
# 完整字段(全部留空占位,Wave 2 填写):
|
||||
# adapter_version # semver,如 1.2.0
|
||||
# license # 展示用;专有许可注明「仅官方渠道安装、不重打包」
|
||||
# platforms # windows/linux:architectures、notes、min_ubuntu
|
||||
# official # homepage / docs / allowed_hosts(网络白名单)
|
||||
# runtime_deps # [ { id, semver_range, required_for: [install|run] } ]
|
||||
# install # preferred + channels[ {id, platforms, command[], script, package, elevate, elevate_reason_zh, post_checks} ]
|
||||
# detect # executable / version_args / version_regex / version_unconfirmed / path_hints
|
||||
# update # method + command[]
|
||||
# uninstall # method + command[] + keep_config_default
|
||||
# authorization # modes[ {mode, command[], env_keys[], status_command[], notes_zh} ]
|
||||
# configuration # files[] / environment[] / fields[]
|
||||
# diagnostics # [ { rule_id } ]
|
||||
# documentation # quickstart_zh / commands[] / updated_at / risks_zh[]
|
||||
#
|
||||
# 铁律:所有 command 一律 argv 数组,禁止 shell 元字符(| & ; $ \ > < (`);
|
||||
# 本波不实现任何真实安装/检测/配置/授权命令(那是 Wave 2 的事)。
|
||||
# ============================================================
|
||||
id: crush
|
||||
name: Crush
|
||||
name_zh: Crush
|
||||
|
||||
@@ -1,4 +1,26 @@
|
||||
# cursor 适配器占位(Wave 0)
|
||||
# ============================================================
|
||||
# AgentDock 适配器占位(Wave 1,对齐架构 §3.1 完整 schema)
|
||||
# 本波仅填五字段(id/name/name_zh/vendor/status);其余字段留空,
|
||||
# Wave 2 起按调研底稿 agent-cli-survey-2026-08-24.md 逐项填充,禁止编造。
|
||||
#
|
||||
# 完整字段(全部留空占位,Wave 2 填写):
|
||||
# adapter_version # semver,如 1.2.0
|
||||
# license # 展示用;专有许可注明「仅官方渠道安装、不重打包」
|
||||
# platforms # windows/linux:architectures、notes、min_ubuntu
|
||||
# official # homepage / docs / allowed_hosts(网络白名单)
|
||||
# runtime_deps # [ { id, semver_range, required_for: [install|run] } ]
|
||||
# install # preferred + channels[ {id, platforms, command[], script, package, elevate, elevate_reason_zh, post_checks} ]
|
||||
# detect # executable / version_args / version_regex / version_unconfirmed / path_hints
|
||||
# update # method + command[]
|
||||
# uninstall # method + command[] + keep_config_default
|
||||
# authorization # modes[ {mode, command[], env_keys[], status_command[], notes_zh} ]
|
||||
# configuration # files[] / environment[] / fields[]
|
||||
# diagnostics # [ { rule_id } ]
|
||||
# documentation # quickstart_zh / commands[] / updated_at / risks_zh[]
|
||||
#
|
||||
# 铁律:所有 command 一律 argv 数组,禁止 shell 元字符(| & ; $ \ > < (`);
|
||||
# 本波不实现任何真实安装/检测/配置/授权命令(那是 Wave 2 的事)。
|
||||
# ============================================================
|
||||
id: cursor
|
||||
name: Cursor CLI
|
||||
name_zh: Cursor CLI
|
||||
|
||||
+111
-1
@@ -1,6 +1,116 @@
|
||||
# gemini 适配器占位(Wave 0)
|
||||
# ============================================================
|
||||
# AgentDock 适配器 · Google Gemini CLI(Wave 2 全字段)
|
||||
# 数据依据:调研底稿 agent-cli-survey-2026-08-24.md §3 + 架构 §3.3
|
||||
# ============================================================
|
||||
id: gemini
|
||||
name: Gemini CLI
|
||||
name_zh: Gemini CLI
|
||||
vendor: Google
|
||||
status: available
|
||||
adapter_version: 1.0.0
|
||||
license: Apache-2.0
|
||||
|
||||
platforms:
|
||||
windows:
|
||||
architectures: [x64]
|
||||
notes: 官方要求 Windows 11 24H2+
|
||||
linux:
|
||||
distributions: [ubuntu]
|
||||
architectures: [x64]
|
||||
min_ubuntu: "20.04"
|
||||
|
||||
official:
|
||||
homepage: https://github.com/google-gemini/gemini-cli
|
||||
docs: https://geminicli.com/docs/get-started/installation/
|
||||
allowed_hosts: [registry.npmjs.org, geminicli.com, github.com]
|
||||
|
||||
runtime_deps:
|
||||
- id: node
|
||||
semver_range: ">=20"
|
||||
required_for: [install, run]
|
||||
|
||||
install:
|
||||
preferred: npm
|
||||
channels:
|
||||
- id: npm
|
||||
platforms: [windows, linux]
|
||||
command: [npm, install, -g, "@google/gemini-cli"]
|
||||
package: "@google/gemini-cli"
|
||||
elevate: never
|
||||
post_checks: [detect]
|
||||
|
||||
detect:
|
||||
executable: gemini
|
||||
version_args: ["--version"]
|
||||
|
||||
update:
|
||||
method: npm_update
|
||||
command: [npm, update, -g, "@google/gemini-cli"]
|
||||
|
||||
uninstall:
|
||||
method: npm_uninstall
|
||||
command: [npm, uninstall, -g, "@google/gemini-cli"]
|
||||
keep_config_default: true
|
||||
|
||||
authorization:
|
||||
modes:
|
||||
- mode: browser_oauth
|
||||
command: [gemini]
|
||||
notes_zh: 运行 gemini 选「Sign in with Google」浏览器登录
|
||||
- mode: api_key
|
||||
env_keys: [GEMINI_API_KEY]
|
||||
notes_zh: 设置 GEMINI_API_KEY 环境变量(AI Studio 申请)
|
||||
|
||||
configuration:
|
||||
files:
|
||||
- path: "~/.gemini/settings.json"
|
||||
format: json
|
||||
scope: user
|
||||
environment:
|
||||
- key: GEMINI_API_KEY
|
||||
sensitive: true
|
||||
maps_to_field: api_key
|
||||
- key: GOOGLE_GEMINI_BASE_URL
|
||||
sensitive: false
|
||||
maps_to_field: base_url
|
||||
fields:
|
||||
- id: model.name
|
||||
label_zh: 默认模型
|
||||
help_zh: Gemini CLI 使用的默认模型
|
||||
required: false
|
||||
sensitive: false
|
||||
type: string
|
||||
storage: file
|
||||
- id: env.GOOGLE_GEMINI_BASE_URL
|
||||
label_zh: Base URL
|
||||
help_zh: 覆盖 Gemini API 默认地址(须 HTTPS,代理/网关场景)
|
||||
required: false
|
||||
sensitive: false
|
||||
type: url
|
||||
storage: file
|
||||
- id: api_key
|
||||
label_zh: API Key
|
||||
help_zh: 存入系统密钥库(对应 GEMINI_API_KEY)
|
||||
required: false
|
||||
sensitive: true
|
||||
type: string
|
||||
storage: keyring
|
||||
|
||||
diagnostics:
|
||||
- rule_id: path.not_installed
|
||||
- rule_id: path.not_in_path
|
||||
- rule_id: dependency.node_below_min
|
||||
- rule_id: version_conflict.multiple_copies
|
||||
- rule_id: config.corrupt
|
||||
|
||||
documentation:
|
||||
quickstart_zh: 安装后运行 `gemini` 登录,或用 `gemini -p "提示词"` 无头执行。
|
||||
commands:
|
||||
- cmd: gemini
|
||||
desc_zh: 启动交互式会话
|
||||
- cmd: gemini -p "提示词"
|
||||
desc_zh: 强制非交互单次执行
|
||||
updated_at: "2026-08-24"
|
||||
risks_zh:
|
||||
- 官方发行形态为 npm 包,依赖 Node 20+,适配器已声明运行时门槛
|
||||
- Windows 官方要求 11 24H2+,低版本 Win10 显示「官方未支持」
|
||||
|
||||
@@ -1,4 +1,26 @@
|
||||
# goose 适配器占位(Wave 0)
|
||||
# ============================================================
|
||||
# AgentDock 适配器占位(Wave 1,对齐架构 §3.1 完整 schema)
|
||||
# 本波仅填五字段(id/name/name_zh/vendor/status);其余字段留空,
|
||||
# Wave 2 起按调研底稿 agent-cli-survey-2026-08-24.md 逐项填充,禁止编造。
|
||||
#
|
||||
# 完整字段(全部留空占位,Wave 2 填写):
|
||||
# adapter_version # semver,如 1.2.0
|
||||
# license # 展示用;专有许可注明「仅官方渠道安装、不重打包」
|
||||
# platforms # windows/linux:architectures、notes、min_ubuntu
|
||||
# official # homepage / docs / allowed_hosts(网络白名单)
|
||||
# runtime_deps # [ { id, semver_range, required_for: [install|run] } ]
|
||||
# install # preferred + channels[ {id, platforms, command[], script, package, elevate, elevate_reason_zh, post_checks} ]
|
||||
# detect # executable / version_args / version_regex / version_unconfirmed / path_hints
|
||||
# update # method + command[]
|
||||
# uninstall # method + command[] + keep_config_default
|
||||
# authorization # modes[ {mode, command[], env_keys[], status_command[], notes_zh} ]
|
||||
# configuration # files[] / environment[] / fields[]
|
||||
# diagnostics # [ { rule_id } ]
|
||||
# documentation # quickstart_zh / commands[] / updated_at / risks_zh[]
|
||||
#
|
||||
# 铁律:所有 command 一律 argv 数组,禁止 shell 元字符(| & ; $ \ > < (`);
|
||||
# 本波不实现任何真实安装/检测/配置/授权命令(那是 Wave 2 的事)。
|
||||
# ============================================================
|
||||
id: goose
|
||||
name: Goose
|
||||
name_zh: Goose
|
||||
|
||||
+131
-1
@@ -1,6 +1,136 @@
|
||||
# kimi 适配器占位(Wave 0)
|
||||
# ============================================================
|
||||
# AgentDock 适配器 · Kimi CLI(月之暗面 Moonshot)(Wave 2 全字段)
|
||||
# 数据依据:调研底稿 agent-cli-survey-2026-08-24.md(Kimi 章节)+ 架构 §3.3
|
||||
# 铁律:只走官方 code.kimi.com 脚本 / PyPI kimi-cli,严禁 npm(仿名包)。
|
||||
# ============================================================
|
||||
id: kimi
|
||||
name: Kimi CLI
|
||||
name_zh: Kimi CLI
|
||||
vendor: 月之暗面
|
||||
status: available
|
||||
adapter_version: 1.0.0
|
||||
license: Apache-2.0
|
||||
|
||||
platforms:
|
||||
windows:
|
||||
architectures: [x64]
|
||||
notes: 原生支持(PowerShell 脚本安装,非 WSL)
|
||||
linux:
|
||||
distributions: [ubuntu]
|
||||
architectures: [x64]
|
||||
|
||||
official:
|
||||
homepage: https://github.com/MoonshotAI/kimi-cli
|
||||
docs: https://moonshotai.github.io/kimi-cli/en/guides/getting-started.html
|
||||
allowed_hosts: [code.kimi.com, pypi.org, github.com]
|
||||
|
||||
runtime_deps:
|
||||
- id: uv
|
||||
semver_range: ">=0"
|
||||
required_for: [install]
|
||||
- id: python
|
||||
semver_range: ">=3.12"
|
||||
required_for: [run]
|
||||
|
||||
install:
|
||||
preferred: pypi_uv
|
||||
channels:
|
||||
- id: pypi_uv
|
||||
platforms: [windows, linux]
|
||||
command: [uv, tool, install, kimi-cli]
|
||||
package: kimi-cli
|
||||
elevate: never
|
||||
post_checks: [detect]
|
||||
- id: official_script
|
||||
platforms: [windows]
|
||||
script:
|
||||
url: https://code.kimi.com/install.ps1
|
||||
kind: powershell_irm
|
||||
elevate: never
|
||||
post_checks: [detect]
|
||||
- id: official_script
|
||||
platforms: [linux]
|
||||
script:
|
||||
url: https://code.kimi.com/install.sh
|
||||
kind: bash_pipe
|
||||
elevate: never
|
||||
post_checks: [detect]
|
||||
|
||||
detect:
|
||||
executable: kimi
|
||||
version_args: ["--version"]
|
||||
|
||||
update:
|
||||
method: pypi_upgrade
|
||||
command: [uv, tool, upgrade, kimi-cli]
|
||||
|
||||
uninstall:
|
||||
method: package_manager
|
||||
command: [uv, tool, uninstall, kimi-cli]
|
||||
keep_config_default: true
|
||||
|
||||
authorization:
|
||||
modes:
|
||||
- mode: browser_oauth
|
||||
command: [kimi, login]
|
||||
notes_zh: Kimi Code 浏览器 OAuth(推荐)
|
||||
- mode: api_key
|
||||
env_keys: [KIMI_API_KEY]
|
||||
notes_zh: Moonshot 开放平台 API Key
|
||||
|
||||
configuration:
|
||||
files:
|
||||
- path: "~/.kimi/config.toml"
|
||||
format: toml
|
||||
scope: user
|
||||
environment:
|
||||
- key: KIMI_API_KEY
|
||||
sensitive: true
|
||||
maps_to_field: api_key
|
||||
- key: KIMI_BASE_URL
|
||||
sensitive: false
|
||||
maps_to_field: base_url
|
||||
fields:
|
||||
- id: default_model
|
||||
label_zh: 默认模型
|
||||
help_zh: Kimi CLI 使用的默认模型
|
||||
required: false
|
||||
sensitive: false
|
||||
type: string
|
||||
storage: file
|
||||
- id: env.KIMI_BASE_URL
|
||||
label_zh: Base URL
|
||||
help_zh: 自定义 OpenAI 兼容端点
|
||||
required: false
|
||||
sensitive: false
|
||||
type: url
|
||||
storage: file
|
||||
- id: api_key
|
||||
label_zh: API Key
|
||||
help_zh: 存入系统密钥库(对应 KIMI_API_KEY)
|
||||
required: false
|
||||
sensitive: true
|
||||
type: string
|
||||
storage: keyring
|
||||
|
||||
diagnostics:
|
||||
- rule_id: path.not_installed
|
||||
- rule_id: path.not_in_path
|
||||
- rule_id: dependency.uv
|
||||
- rule_id: dependency.python_below_min
|
||||
- rule_id: version_conflict.multiple_copies
|
||||
- rule_id: config.corrupt
|
||||
|
||||
documentation:
|
||||
quickstart_zh: 安装后运行 `kimi login` 登录,或用 `kimi -p "提示词"` 无头执行。
|
||||
commands:
|
||||
- cmd: kimi
|
||||
desc_zh: 启动交互式会话
|
||||
- cmd: kimi login
|
||||
desc_zh: 登录(浏览器 OAuth 或 API Key)
|
||||
- cmd: kimi -p "提示词"
|
||||
desc_zh: 无头单次执行
|
||||
updated_at: "2026-08-24"
|
||||
risks_zh:
|
||||
- 严禁 npm:npm 上的 kimi-code 是第三方仿名包,与月之暗面无关
|
||||
- 官方脚本会自动安装 uv 并从 PyPI kimi-cli 安装
|
||||
|
||||
@@ -1,6 +1,128 @@
|
||||
# opencode 适配器占位(Wave 0)
|
||||
# ============================================================
|
||||
# AgentDock 适配器 · OpenCode(opencode.ai)(Wave 2 全字段)
|
||||
# 数据依据:调研底稿 agent-cli-survey-2026-08-24.md(OpenCode 章节)+ 架构 §3.3
|
||||
# ============================================================
|
||||
id: opencode
|
||||
name: OpenCode
|
||||
name_zh: OpenCode
|
||||
vendor: opencode.ai
|
||||
status: available
|
||||
adapter_version: 1.0.0
|
||||
license: MIT
|
||||
|
||||
platforms:
|
||||
windows:
|
||||
architectures: [x64]
|
||||
notes: 原生支持(choco/scoop/exe,无需 WSL)
|
||||
linux:
|
||||
distributions: [ubuntu]
|
||||
architectures: [x64]
|
||||
|
||||
official:
|
||||
homepage: https://opencode.ai
|
||||
docs: https://opencode.ai/docs
|
||||
allowed_hosts: [opencode.ai, registry.npmjs.org, github.com, community.chocolatey.org, get.scoop.sh]
|
||||
|
||||
runtime_deps: []
|
||||
|
||||
install:
|
||||
preferred: npm
|
||||
channels:
|
||||
- id: npm
|
||||
platforms: [windows, linux]
|
||||
command: [npm, install, -g, opencode-ai]
|
||||
package: opencode-ai
|
||||
elevate: never
|
||||
post_checks: [detect]
|
||||
- id: choco
|
||||
platforms: [windows]
|
||||
command: [choco, install, opencode]
|
||||
package: opencode
|
||||
elevate: required
|
||||
elevate_reason_zh: choco 安装需要管理员权限写入系统目录
|
||||
post_checks: [detect]
|
||||
- id: scoop
|
||||
platforms: [windows]
|
||||
command: [scoop, install, opencode]
|
||||
package: opencode
|
||||
elevate: never
|
||||
post_checks: [detect]
|
||||
- id: official_script
|
||||
platforms: [linux]
|
||||
script:
|
||||
url: https://opencode.ai/install
|
||||
kind: bash_pipe
|
||||
elevate: never
|
||||
post_checks: [detect]
|
||||
|
||||
detect:
|
||||
executable: opencode
|
||||
version_args: ["--version"]
|
||||
|
||||
update:
|
||||
method: self_update_cmd
|
||||
command: [opencode, upgrade]
|
||||
|
||||
uninstall:
|
||||
method: npm_uninstall
|
||||
command: [npm, uninstall, -g, opencode-ai]
|
||||
keep_config_default: true
|
||||
|
||||
authorization:
|
||||
modes:
|
||||
- mode: browser_oauth
|
||||
command: [opencode, auth, login]
|
||||
notes_zh: 浏览器 OAuth(Claude Pro/Max、ChatGPT 订阅等)
|
||||
- mode: api_key
|
||||
status_command: [opencode, auth, list]
|
||||
env_keys: [OPENAI_API_KEY, ANTHROPIC_API_KEY]
|
||||
notes_zh: 各家 API Key;opencode auth list 可查看凭据
|
||||
- mode: device_code
|
||||
notes_zh: GitHub Copilot 设备码登录(github.com/login/device 输码)
|
||||
|
||||
configuration:
|
||||
files:
|
||||
- path: "~/.config/opencode/opencode.json"
|
||||
format: json
|
||||
scope: user
|
||||
environment:
|
||||
- key: OPENAI_API_KEY
|
||||
sensitive: true
|
||||
maps_to_field: api_key
|
||||
- key: ANTHROPIC_API_KEY
|
||||
sensitive: true
|
||||
maps_to_field: api_key
|
||||
fields:
|
||||
- id: model
|
||||
label_zh: 默认模型
|
||||
help_zh: OpenCode 使用的默认模型
|
||||
required: false
|
||||
sensitive: false
|
||||
type: string
|
||||
storage: file
|
||||
- id: api_key
|
||||
label_zh: API Key
|
||||
help_zh: 存入系统密钥库(对应 OPENAI_API_KEY / ANTHROPIC_API_KEY)
|
||||
required: false
|
||||
sensitive: true
|
||||
type: string
|
||||
storage: keyring
|
||||
|
||||
diagnostics:
|
||||
- rule_id: path.not_installed
|
||||
- rule_id: path.not_in_path
|
||||
- rule_id: version_conflict.multiple_copies
|
||||
- rule_id: config.corrupt
|
||||
|
||||
documentation:
|
||||
quickstart_zh: 安装后运行 `opencode` 或 `opencode run "提示词"` 无头执行。
|
||||
commands:
|
||||
- cmd: opencode
|
||||
desc_zh: 启动交互式会话
|
||||
- cmd: opencode run "提示词"
|
||||
desc_zh: 无头单次执行
|
||||
- cmd: opencode auth list
|
||||
desc_zh: 查看已配置凭据
|
||||
updated_at: "2026-08-24"
|
||||
risks_zh:
|
||||
- 官方未文档化安装包校验(SHA256/签名),适配器不强制校验
|
||||
|
||||
@@ -1,4 +1,26 @@
|
||||
# qwen 适配器占位(Wave 0)
|
||||
# ============================================================
|
||||
# AgentDock 适配器占位(Wave 1,对齐架构 §3.1 完整 schema)
|
||||
# 本波仅填五字段(id/name/name_zh/vendor/status);其余字段留空,
|
||||
# Wave 2 起按调研底稿 agent-cli-survey-2026-08-24.md 逐项填充,禁止编造。
|
||||
#
|
||||
# 完整字段(全部留空占位,Wave 2 填写):
|
||||
# adapter_version # semver,如 1.2.0
|
||||
# license # 展示用;专有许可注明「仅官方渠道安装、不重打包」
|
||||
# platforms # windows/linux:architectures、notes、min_ubuntu
|
||||
# official # homepage / docs / allowed_hosts(网络白名单)
|
||||
# runtime_deps # [ { id, semver_range, required_for: [install|run] } ]
|
||||
# install # preferred + channels[ {id, platforms, command[], script, package, elevate, elevate_reason_zh, post_checks} ]
|
||||
# detect # executable / version_args / version_regex / version_unconfirmed / path_hints
|
||||
# update # method + command[]
|
||||
# uninstall # method + command[] + keep_config_default
|
||||
# authorization # modes[ {mode, command[], env_keys[], status_command[], notes_zh} ]
|
||||
# configuration # files[] / environment[] / fields[]
|
||||
# diagnostics # [ { rule_id } ]
|
||||
# documentation # quickstart_zh / commands[] / updated_at / risks_zh[]
|
||||
#
|
||||
# 铁律:所有 command 一律 argv 数组,禁止 shell 元字符(| & ; $ \ > < (`);
|
||||
# 本波不实现任何真实安装/检测/配置/授权命令(那是 Wave 2 的事)。
|
||||
# ============================================================
|
||||
id: qwen
|
||||
name: Qwen Code
|
||||
name_zh: Qwen Code
|
||||
|
||||
@@ -1,4 +1,26 @@
|
||||
# warp 适配器占位(Wave 0)
|
||||
# ============================================================
|
||||
# AgentDock 适配器占位(Wave 1,对齐架构 §3.1 完整 schema)
|
||||
# 本波仅填五字段(id/name/name_zh/vendor/status);其余字段留空,
|
||||
# Wave 2 起按调研底稿 agent-cli-survey-2026-08-24.md 逐项填充,禁止编造。
|
||||
#
|
||||
# 完整字段(全部留空占位,Wave 2 填写):
|
||||
# adapter_version # semver,如 1.2.0
|
||||
# license # 展示用;专有许可注明「仅官方渠道安装、不重打包」
|
||||
# platforms # windows/linux:architectures、notes、min_ubuntu
|
||||
# official # homepage / docs / allowed_hosts(网络白名单)
|
||||
# runtime_deps # [ { id, semver_range, required_for: [install|run] } ]
|
||||
# install # preferred + channels[ {id, platforms, command[], script, package, elevate, elevate_reason_zh, post_checks} ]
|
||||
# detect # executable / version_args / version_regex / version_unconfirmed / path_hints
|
||||
# update # method + command[]
|
||||
# uninstall # method + command[] + keep_config_default
|
||||
# authorization # modes[ {mode, command[], env_keys[], status_command[], notes_zh} ]
|
||||
# configuration # files[] / environment[] / fields[]
|
||||
# diagnostics # [ { rule_id } ]
|
||||
# documentation # quickstart_zh / commands[] / updated_at / risks_zh[]
|
||||
#
|
||||
# 铁律:所有 command 一律 argv 数组,禁止 shell 元字符(| & ; $ \ > < (`);
|
||||
# 本波不实现任何真实安装/检测/配置/授权命令(那是 Wave 2 的事)。
|
||||
# ============================================================
|
||||
id: warp
|
||||
name: Warp Agent CLI
|
||||
name_zh: Warp Agent CLI
|
||||
|
||||
@@ -19,3 +19,6 @@ serde_json = "1"
|
||||
agentdock-platform = { path = "../../../crates/agentdock-platform" }
|
||||
agentdock-adapter = { path = "../../../crates/agentdock-adapter" }
|
||||
agentdock-store = { path = "../../../crates/agentdock-store" }
|
||||
agentdock-core = { path = "../../../crates/agentdock-core" }
|
||||
agentdock-secrets = { path = "../../../crates/agentdock-secrets" }
|
||||
agentdock-diag = { path = "../../../crates/agentdock-diag" }
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
//! IPC 命令:CLI 全链路(detectCli / previewAction / runAction / readConfig /
|
||||
//! writeConfig / authStatus / authorize / diagnose,架构 §2 IPC 契约)
|
||||
//!
|
||||
//! 编排全部走 `agentdock-core::Engine`。runAction 通过 Tauri 事件流式回传
|
||||
//! stdout/stderr;敏感输出经 `agentdock-secrets::redact` 脱敏后才进事件。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use agentdock_adapter::{AdapterAction, DryRunPlan};
|
||||
use agentdock_core::{ActionEvent, ActionOpts, AuthStatus, ConfigFormState, DetectResult, Engine, WriteResult};
|
||||
use agentdock_diag::DiagnosticReport;
|
||||
use serde_json::json;
|
||||
use tauri::Emitter;
|
||||
|
||||
/// 把 action 字符串映射为 AdapterAction(snake_case,与 IPC 契约一致)。
|
||||
fn parse_action(action: &str) -> Result<AdapterAction, String> {
|
||||
match action {
|
||||
"install" => Ok(AdapterAction::Install),
|
||||
"update" => Ok(AdapterAction::Update),
|
||||
"uninstall" => Ok(AdapterAction::Uninstall),
|
||||
"write_config" => Ok(AdapterAction::WriteConfig),
|
||||
"authorize" => Ok(AdapterAction::Authorize),
|
||||
"repair" => Ok(AdapterAction::Repair),
|
||||
other => Err(format!("未知动作: {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取单个适配器完整定义(CLI 详情页展示文档/渠道/许可用)。
|
||||
#[tauri::command(rename = "getAdapter")]
|
||||
pub fn get_adapter(id: String, state: tauri::State<'_, Engine>) -> Result<agentdock_adapter::Adapter, String> {
|
||||
state.adapter(&id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 检测单个 CLI(detectCli)。
|
||||
#[tauri::command(rename = "detectCli")]
|
||||
pub fn detect_cli(id: String, state: tauri::State<'_, Engine>) -> Result<DetectResult, String> {
|
||||
state.detect(&id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 干燥运行(previewAction):返回将执行的命令与影响面,不真正执行。
|
||||
#[tauri::command(rename = "previewAction")]
|
||||
pub fn preview_action(
|
||||
id: String,
|
||||
action: String,
|
||||
channel: Option<String>,
|
||||
state: tauri::State<'_, Engine>,
|
||||
) -> Result<DryRunPlan, String> {
|
||||
let action = parse_action(&action)?;
|
||||
let mut plan = state.preview(&id, action).map_err(|e| e.to_string())?;
|
||||
// 渠道覆盖时调整计划里的命令为所选渠道(供确认弹窗展示)
|
||||
if let Some(ch) = channel {
|
||||
if let Some(adapter) = state.adapter(&id).ok() {
|
||||
if let Some(install) = adapter.install.as_ref() {
|
||||
if let Some(target) = install.channels.iter().find(|c| c.id == ch) {
|
||||
if !target.command.is_empty() {
|
||||
plan.commands = vec![target.command.clone()];
|
||||
plan.elevate = matches!(target.elevate.as_deref(), Some("if_needed") | Some("required"));
|
||||
plan.elevate_reason_zh = target.elevate_reason_zh.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
/// 流式执行动作(runAction):事件经 `cli-action-event` 回传。
|
||||
#[tauri::command(rename = "runAction")]
|
||||
pub fn run_action(
|
||||
app: tauri::AppHandle,
|
||||
id: String,
|
||||
action: String,
|
||||
channel: Option<String>,
|
||||
state: tauri::State<'_, Engine>,
|
||||
) -> Result<(), String> {
|
||||
let action = parse_action(&action)?;
|
||||
let engine = state.inner().clone();
|
||||
let opts = ActionOpts { channel };
|
||||
std::thread::spawn(move || {
|
||||
let cli_id = id.clone();
|
||||
let result = engine.run(&id, action, &opts, |ev| {
|
||||
let _ = app.emit("cli-action-event", json!({ "cli_id": cli_id, "event": ev }));
|
||||
});
|
||||
if let Err(e) = result {
|
||||
let _ = app.emit(
|
||||
"cli-action-event",
|
||||
json!({ "cli_id": cli_id, "event": ActionEvent::error(e.to_string()) }),
|
||||
);
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 读取配置表单状态(readConfig)。
|
||||
#[tauri::command(rename = "readConfig")]
|
||||
pub fn read_config(id: String, state: tauri::State<'_, Engine>) -> Result<ConfigFormState, String> {
|
||||
state.read_config(&id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 写配置(writeConfig):patch 为 { field_id: value }。
|
||||
#[tauri::command(rename = "writeConfig")]
|
||||
pub fn write_config(
|
||||
id: String,
|
||||
patch: BTreeMap<String, String>,
|
||||
state: tauri::State<'_, Engine>,
|
||||
) -> Result<WriteResult, String> {
|
||||
state.write_config(&id, &patch).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 授权状态(authStatus)。
|
||||
#[tauri::command(rename = "authStatus")]
|
||||
pub fn auth_status(id: String, state: tauri::State<'_, Engine>) -> Result<AuthStatus, String> {
|
||||
state.auth_status(&id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 诊断(diagnose):内部实时取本机环境快照。
|
||||
#[tauri::command(rename = "diagnose")]
|
||||
pub fn diagnose(id: String, state: tauri::State<'_, Engine>) -> Result<DiagnosticReport, String> {
|
||||
let env = agentdock_platform::detect::detect_env();
|
||||
state.diagnose(&id, &env).map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod catalog;
|
||||
pub mod cli;
|
||||
pub mod env;
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
mod commands;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tauri::Manager;
|
||||
|
||||
use agentdock_core::Engine;
|
||||
use agentdock_secrets::KeyringSecretStore;
|
||||
|
||||
/// 解析适配器目录。
|
||||
/// 优先级:环境变量 AGENTDOCK_ADAPTERS_DIR > CWD 相对路径(tauri dev 时 CWD=apps/desktop)
|
||||
@@ -26,16 +32,29 @@ fn adapters_dir() -> PathBuf {
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.setup(|_app| {
|
||||
.setup(|app| {
|
||||
// 自测标记:`tauri dev` 启动后日志出现该行即代表壳已就绪
|
||||
println!("[agentdock] window-ready");
|
||||
// Wave 0:初始化 SQLite 空库(架构 §2 状态存储层)
|
||||
let _ = agentdock_store::Store::open_default();
|
||||
|
||||
// Wave 2:初始化编排引擎(适配器目录 + 系统密钥库)
|
||||
let secrets: Arc<dyn agentdock_secrets::SecretStore> = Arc::new(KeyringSecretStore::new());
|
||||
let engine = Engine::new(adapters_dir(), secrets);
|
||||
app.manage(engine);
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::env::detect_env,
|
||||
commands::catalog::list_catalog,
|
||||
commands::cli::detect_cli,
|
||||
commands::cli::get_adapter,
|
||||
commands::cli::preview_action,
|
||||
commands::cli::run_action,
|
||||
commands::cli::read_config,
|
||||
commands::cli::write_config,
|
||||
commands::cli::auth_status,
|
||||
commands::cli::diagnose,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
+21
-10
@@ -7,6 +7,7 @@ import { MyCliPage } from "./pages/MyCli";
|
||||
import { ConfigCenterPage } from "./pages/ConfigCenter";
|
||||
import { BackupPage } from "./pages/Backup";
|
||||
import { SettingsPage } from "./pages/Settings";
|
||||
import { CliDetailPage } from "./pages/CliDetail";
|
||||
import { useEnv } from "./hooks/useEnv";
|
||||
import type { PlatformEnv, RuntimeInfo } from "./ipc/types";
|
||||
|
||||
@@ -27,35 +28,45 @@ function envWarningCount(env: PlatformEnv | null): number {
|
||||
|
||||
export default function App() {
|
||||
const [page, setPage] = useState<PageKey>("overview");
|
||||
const [detailCliId, setDetailCliId] = useState<string | null>(null);
|
||||
const { env } = useEnv();
|
||||
const warningCount = envWarningCount(env);
|
||||
|
||||
function navigate(next: PageKey) {
|
||||
setDetailCliId(null);
|
||||
setPage(next);
|
||||
}
|
||||
|
||||
const content = useMemo(() => {
|
||||
if (detailCliId) {
|
||||
return <CliDetailPage id={detailCliId} onBack={() => setDetailCliId(null)} />;
|
||||
}
|
||||
switch (page) {
|
||||
case "overview":
|
||||
return <OverviewPage />;
|
||||
return <OverviewPage onNavigate={navigate} />;
|
||||
case "catalog":
|
||||
return <CatalogPage />;
|
||||
return <CatalogPage onOpenDetail={setDetailCliId} />;
|
||||
case "my-cli":
|
||||
return <MyCliPage />;
|
||||
return <MyCliPage onNavigate={navigate} />;
|
||||
case "config":
|
||||
return <ConfigCenterPage />;
|
||||
return <ConfigCenterPage onNavigate={navigate} />;
|
||||
case "backup":
|
||||
return <BackupPage />;
|
||||
case "settings":
|
||||
return <SettingsPage />;
|
||||
}
|
||||
}, [page]);
|
||||
}, [page, detailCliId, env]);
|
||||
|
||||
const title = detailCliId ? "CLI 详情" : PAGE_META[page].title;
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<div className="bg-grid grid-drift" aria-hidden="true" />
|
||||
<Sidebar current={page} onNavigate={setPage} />
|
||||
<Sidebar current={page} onNavigate={navigate} />
|
||||
<div className="app-main">
|
||||
<Header title={PAGE_META[page].title} warningCount={warningCount} />
|
||||
<Header title={title} warningCount={warningCount} />
|
||||
<main className="app-content">
|
||||
{/* key=page 触发 §4.2 页面切换动效:translateY(8px)+淡入 */}
|
||||
<div key={page} className="page-enter">
|
||||
{/* key 触发 §4.2 页面切换动效:translateY(8px)+淡入 */}
|
||||
<div key={detailCliId ?? page} className="page-enter">
|
||||
{content}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -1,12 +1,46 @@
|
||||
/**
|
||||
* CLI 双字母头像缩写注册表(视觉规范 v1.3 §7:全系统唯一来源)
|
||||
* 规则:① 首两个字母(大写);② 冲突时保留先收录者,后收录方取「首字母 + 辨识辅音」;
|
||||
* ③ 仍冲突升级「首字母 + 末字母」。新收录 CLI 必须先在此注册,不得现场发明。
|
||||
*/
|
||||
const CLI_MONOGRAMS: Record<string, string> = {
|
||||
codex: "CO",
|
||||
"claude-code": "CL",
|
||||
gemini: "GE",
|
||||
copilot: "CP",
|
||||
kimi: "KI",
|
||||
qwen: "QW",
|
||||
codebuddy: "CB",
|
||||
opencode: "OC",
|
||||
crush: "CR",
|
||||
goose: "GO",
|
||||
aider: "AI",
|
||||
cursor: "CU",
|
||||
cline: "CN",
|
||||
warp: "WA",
|
||||
/* 第二批 4 个预注册(暂缓接入,登记防碰撞) */
|
||||
grok: "GR",
|
||||
amp: "AM",
|
||||
"deepseek-dsh": "DS",
|
||||
plandex: "PL",
|
||||
};
|
||||
|
||||
/** 未注册时的兜底:取主名单词首两个字母(大写),仅用于未来新增且尚未登记的过渡态 */
|
||||
function fallbackAbbr(name: string): string {
|
||||
return name.replace(/\s+/g, "").slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
/** CLI 单色字母头像(视觉规范 §7:双字母单色,--ad-bg-2 底 + 1px --ad-border) */
|
||||
export function CliMonogram({
|
||||
id,
|
||||
name,
|
||||
size = 32,
|
||||
}: {
|
||||
id: string;
|
||||
name: string;
|
||||
size?: number;
|
||||
}) {
|
||||
const chars = name.replace(/\s+/g, "").slice(0, 2).toUpperCase();
|
||||
const chars = CLI_MONOGRAMS[id] ?? fallbackAbbr(name);
|
||||
return (
|
||||
<span
|
||||
className="monogram"
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Check, Eye, EyeOff, Lock } from "lucide-react";
|
||||
import { readConfig, writeConfig } from "../ipc";
|
||||
import type { ConfigFieldState, ConfigFormState, WriteResult } from "../ipc/types";
|
||||
import { MonoChip } from "./MonoChip";
|
||||
|
||||
/** 配置表单(视觉规范 §3.4:单列 ≤640px、敏感字段密码框 + 密钥库说明、吸底保存条) */
|
||||
export function ConfigForm({ id }: { id: string }) {
|
||||
const [state, setState] = useState<ConfigFormState | null>(null);
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [revealed, setRevealed] = useState<Record<string, boolean>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [result, setResult] = useState<WriteResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
readConfig(id)
|
||||
.then((s) => {
|
||||
if (cancelled) return;
|
||||
setState(s);
|
||||
const init: Record<string, string> = {};
|
||||
for (const f of s.fields) {
|
||||
if (!f.sensitive && f.value != null) init[f.id] = f.value;
|
||||
}
|
||||
setValues(init);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) {
|
||||
setError(String(e));
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
const sensitiveSaved = useMemo(() => {
|
||||
const m: Record<string, boolean> = {};
|
||||
state?.fields.forEach((f) => {
|
||||
if (f.sensitive) m[f.id] = f.has_value;
|
||||
});
|
||||
return m;
|
||||
}, [state]);
|
||||
|
||||
if (loading) return <p className="panel-empty">配置加载中…</p>;
|
||||
if (error) return <p className="panel-empty">配置加载失败:{error}</p>;
|
||||
if (!state) return null;
|
||||
|
||||
const hasFields = state.fields.length > 0;
|
||||
|
||||
async function onSave() {
|
||||
setSaving(true);
|
||||
setSaved(false);
|
||||
setResult(null);
|
||||
setError(null);
|
||||
const patch: Record<string, string> = {};
|
||||
for (const f of state!.fields) {
|
||||
const v = values[f.id];
|
||||
if (v != null && v !== "") {
|
||||
patch[f.id] = v;
|
||||
}
|
||||
}
|
||||
if (Object.keys(patch).length === 0) {
|
||||
setError("没有需要保存的改动");
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await writeConfig(id, patch);
|
||||
setResult(r);
|
||||
setSaved(true);
|
||||
setValues({});
|
||||
// 刷新回显
|
||||
const s = await readConfig(id);
|
||||
setState(s);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="config-form">
|
||||
{!hasFields && <p className="panel-empty">该 CLI 未声明可配置的中文表单字段。</p>}
|
||||
|
||||
{state.fields.map((field) => (
|
||||
<Field
|
||||
key={field.id}
|
||||
field={field}
|
||||
value={values[field.id] ?? ""}
|
||||
saved={sensitiveSaved[field.id] ?? false}
|
||||
revealed={!!revealed[field.id]}
|
||||
onToggleReveal={() => setRevealed((r) => ({ ...r, [field.id]: !r[field.id] }))}
|
||||
onChange={(v) => setValues((x) => ({ ...x, [field.id]: v }))}
|
||||
/>
|
||||
))}
|
||||
|
||||
{state.files.length > 0 && (
|
||||
<div className="config-file-info">
|
||||
<span className="config-file-label">配置文件</span>
|
||||
{state.files.map((f) => (
|
||||
<span key={f.path} className="config-file-path">
|
||||
<MonoChip>{f.path}</MonoChip>
|
||||
{f.exists ? (f.parse_ok ? " · 已解析" : " · 解析异常") : " · 尚未创建"}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasFields && (
|
||||
<div className="config-savebar">
|
||||
<div className="config-save-msg">
|
||||
{saved && (
|
||||
<span className="save-ok">
|
||||
<Check size={14} strokeWidth={1.5} aria-hidden="true" /> 已保存
|
||||
</span>
|
||||
)}
|
||||
{result?.backup_path && (
|
||||
<span className="save-detail">
|
||||
已备份原文件 · {result.backup_path.split(/[\\/]/).pop()}
|
||||
</span>
|
||||
)}
|
||||
{error && <span className="save-error">{error}</span>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={onSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? "保存中…" : "保存配置"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
field,
|
||||
value,
|
||||
saved,
|
||||
revealed,
|
||||
onChange,
|
||||
onToggleReveal,
|
||||
}: {
|
||||
field: ConfigFieldState;
|
||||
value: string;
|
||||
saved: boolean;
|
||||
revealed: boolean;
|
||||
onChange: (v: string) => void;
|
||||
onToggleReveal: () => void;
|
||||
}) {
|
||||
const isPassword = field.sensitive;
|
||||
const inputType = isPassword ? (revealed ? "text" : "password") : field.field_type === "url" ? "text" : "text";
|
||||
return (
|
||||
<div className="field">
|
||||
<label className="field-label" htmlFor={`field-${field.id}`}>
|
||||
{field.label_zh}
|
||||
{field.required && <span className="field-required"> *</span>}
|
||||
</label>
|
||||
<div className="field-control">
|
||||
{isPassword && saved && (
|
||||
<span className="field-lock" title="已加密保存于系统密钥库">
|
||||
<Lock size={12} strokeWidth={1.5} aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
id={`field-${field.id}`}
|
||||
type={inputType}
|
||||
value={value}
|
||||
placeholder={isPassword ? (saved ? "已保存 · 留空则不变" : "输入 API Key") : ""}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
{isPassword && (
|
||||
<button
|
||||
type="button"
|
||||
className="field-eye"
|
||||
onClick={onToggleReveal}
|
||||
aria-label={revealed ? "隐藏" : "显示"}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{revealed ? <EyeOff size={16} strokeWidth={1.5} /> : <Eye size={16} strokeWidth={1.5} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="field-help">
|
||||
{field.help_zh}
|
||||
{isPassword && (
|
||||
<span className="field-keyring-note">
|
||||
{saved ? " · 已加密保存于系统密钥库" : " · 保存后写入系统密钥库,绝不明文落盘"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,24 +10,25 @@ interface HeaderProps {
|
||||
export function Header({ title, warningCount = 0 }: HeaderProps) {
|
||||
return (
|
||||
<header className="header">
|
||||
<h1 className="header-title">{title}</h1>
|
||||
<div className="header-actions">
|
||||
<div className="search-box">
|
||||
<Search size={16} strokeWidth={1.5} className="search-icon" aria-hidden="true" />
|
||||
<input
|
||||
type="search"
|
||||
placeholder="搜索 CLI…"
|
||||
aria-label="搜索 CLI"
|
||||
// 搜索功能属于后续波次,Wave 0 仅为占位
|
||||
/>
|
||||
<div className="header-inner">
|
||||
<h1 className="header-title">{title}</h1>
|
||||
<div className="header-actions">
|
||||
<div className="search-box">
|
||||
<Search size={16} strokeWidth={1.5} className="search-icon" aria-hidden="true" />
|
||||
<input
|
||||
type="search"
|
||||
placeholder="搜索 CLI…"
|
||||
aria-label="搜索 CLI"
|
||||
/>
|
||||
</div>
|
||||
{warningCount > 0 && (
|
||||
<button type="button" className="env-chip">
|
||||
<span className="status-dot warn breathe" aria-hidden="true" />
|
||||
<span>{warningCount} 项环境警告</span>
|
||||
<ChevronDown size={12} strokeWidth={1.5} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{warningCount > 0 && (
|
||||
<button type="button" className="env-chip">
|
||||
<span className="status-dot warn breathe" aria-hidden="true" />
|
||||
<span>{warningCount} 项环境警告</span>
|
||||
<ChevronDown size={12} strokeWidth={1.5} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -11,17 +11,19 @@ interface KpiCardProps {
|
||||
label: string;
|
||||
value: number;
|
||||
tone: KpiTone;
|
||||
/** 副标题:对象点名句式(视觉规范 §3.1.1) */
|
||||
/** 副标题:对象点名句式(视觉规范 §3.1.1);0 值用「确认句 + 行动」短句 */
|
||||
subtitle: string;
|
||||
/** 0 值行动后缀(如「去目录看看」),仅当存在对应页面动作时出现 */
|
||||
action?: { label: string; onClick: () => void };
|
||||
}
|
||||
|
||||
/**
|
||||
* KPI 卡(视觉规范 §3.1.1):
|
||||
* - 径向渐变玻璃板 + 上缘高光 + --ad-shadow-panel
|
||||
* - 顶部色条贯通整宽;数值为 0 时色条降灰、数字回 --ad-text-1
|
||||
* - 数字随状态色贯穿
|
||||
* - 数字随状态色贯穿;0 值副文案 = 确认句 + 青字行动后缀
|
||||
*/
|
||||
export function KpiCard({ label, value, tone, subtitle }: KpiCardProps) {
|
||||
export function KpiCard({ label, value, tone, subtitle, action }: KpiCardProps) {
|
||||
const color = value > 0 ? TONE_COLOR[tone] : "var(--ad-border)";
|
||||
const valueColor = value > 0 ? TONE_COLOR[tone] : "var(--ad-text-1)";
|
||||
return (
|
||||
@@ -32,7 +34,17 @@ export function KpiCard({ label, value, tone, subtitle }: KpiCardProps) {
|
||||
{value}
|
||||
</div>
|
||||
<div className="kpi-label">{label}</div>
|
||||
<div className="kpi-subtitle">{subtitle}</div>
|
||||
<div className="kpi-subtitle">
|
||||
{subtitle}
|
||||
{action && value === 0 && (
|
||||
<>
|
||||
{" · "}
|
||||
<button type="button" className="link-btn" onClick={action.onClick}>
|
||||
{action.label}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
interface ModalProps {
|
||||
title: string;
|
||||
onClose?: () => void;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
}
|
||||
|
||||
/** 弹窗(视觉规范 §3.6:--ad-bg-2 底 + --ad-radius-l + --ad-shadow-pop + 遮罩) */
|
||||
export function Modal({ title, onClose, children, footer }: ModalProps) {
|
||||
return (
|
||||
<div className="modal-mask" role="dialog" aria-modal="true" aria-label={title}>
|
||||
<div className="modal">
|
||||
<div className="modal-head">
|
||||
<h3 className="modal-title">{title}</h3>
|
||||
{onClose && (
|
||||
<button type="button" className="modal-close" onClick={onClose} aria-label="关闭">
|
||||
<X size={16} strokeWidth={1.5} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-body">{children}</div>
|
||||
{footer && <div className="modal-foot">{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { authStatus, detectCli, getAdapter } from "../ipc";
|
||||
import type { Adapter, AuthStatus, DetectResult } from "../ipc/types";
|
||||
|
||||
export interface UseCliDetailResult {
|
||||
adapter: Adapter | null;
|
||||
detect: DetectResult | null;
|
||||
auth: AuthStatus | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** 加载单个 CLI 的适配器定义 + 检测结果 + 授权状态(CLI 详情页) */
|
||||
export function useCliDetail(id: string): UseCliDetailResult {
|
||||
const [adapter, setAdapter] = useState<Adapter | null>(null);
|
||||
const [detect, setDetect] = useState<DetectResult | null>(null);
|
||||
const [auth, setAuth] = useState<AuthStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const [a, d, s] = await Promise.all([getAdapter(id), detectCli(id), authStatus(id)]);
|
||||
setAdapter(a);
|
||||
setDetect(d);
|
||||
setAuth(s);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return { adapter, detect, auth, loading, error, refresh };
|
||||
}
|
||||
@@ -1,6 +1,19 @@
|
||||
// 类型化 invoke 封装(唯一前端 → Rust 入口,架构 §2「界面层禁直接拼命令」)
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { CatalogEntry, PlatformEnv } from "./types";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import type {
|
||||
Adapter,
|
||||
AuthStatus,
|
||||
CatalogEntry,
|
||||
CliAction,
|
||||
CliActionEvent,
|
||||
ConfigFormState,
|
||||
DetectResult,
|
||||
DiagnosticReport,
|
||||
DryRunPlan,
|
||||
PlatformEnv,
|
||||
WriteResult,
|
||||
} from "./types";
|
||||
|
||||
/** 是否运行在 Tauri 环境(否则走 mock,便于纯浏览器联调 UI) */
|
||||
function isTauri(): boolean {
|
||||
@@ -8,20 +21,68 @@ function isTauri(): boolean {
|
||||
}
|
||||
|
||||
export async function detectEnv(): Promise<PlatformEnv> {
|
||||
if (!isTauri()) {
|
||||
return mockPlatformEnv();
|
||||
}
|
||||
if (!isTauri()) return mockPlatformEnv();
|
||||
return invoke<PlatformEnv>("detectEnv");
|
||||
}
|
||||
|
||||
export async function listCatalog(): Promise<CatalogEntry[]> {
|
||||
if (!isTauri()) {
|
||||
return mockCatalog();
|
||||
}
|
||||
if (!isTauri()) return mockCatalog();
|
||||
return invoke<CatalogEntry[]>("listCatalog");
|
||||
}
|
||||
|
||||
// ---- Wave 2:CLI 全链路 ----
|
||||
|
||||
export async function getAdapter(id: string): Promise<Adapter> {
|
||||
return invoke<Adapter>("getAdapter", { id });
|
||||
}
|
||||
|
||||
export async function detectCli(id: string): Promise<DetectResult> {
|
||||
if (!isTauri()) return mockDetect(id);
|
||||
return invoke<DetectResult>("detectCli", { id });
|
||||
}
|
||||
|
||||
export async function previewAction(
|
||||
id: string,
|
||||
action: CliAction,
|
||||
channel?: string,
|
||||
): Promise<DryRunPlan> {
|
||||
return invoke<DryRunPlan>("previewAction", { id, action, channel });
|
||||
}
|
||||
|
||||
export async function runAction(id: string, action: CliAction, channel?: string): Promise<void> {
|
||||
return invoke<void>("runAction", { id, action, channel });
|
||||
}
|
||||
|
||||
export async function readConfig(id: string): Promise<ConfigFormState> {
|
||||
return invoke<ConfigFormState>("readConfig", { id });
|
||||
}
|
||||
|
||||
export async function writeConfig(
|
||||
id: string,
|
||||
patch: Record<string, string>,
|
||||
): Promise<WriteResult> {
|
||||
return invoke<WriteResult>("writeConfig", { id, patch });
|
||||
}
|
||||
|
||||
export async function authStatus(id: string): Promise<AuthStatus> {
|
||||
if (!isTauri()) return mockAuth(id);
|
||||
return invoke<AuthStatus>("authStatus", { id });
|
||||
}
|
||||
|
||||
export async function diagnose(id: string): Promise<DiagnosticReport> {
|
||||
return invoke<DiagnosticReport>("diagnose", { id });
|
||||
}
|
||||
|
||||
/** 订阅 runAction 的流式事件(cli-action-event)。返回取消订阅函数。 */
|
||||
export async function onCliAction(
|
||||
handler: (payload: CliActionEvent) => void,
|
||||
): Promise<UnlistenFn> {
|
||||
if (!isTauri()) return () => {};
|
||||
return listen<CliActionEvent>("cli-action-event", (e) => handler(e.payload));
|
||||
}
|
||||
|
||||
// ---- 浏览器联调用 mock(仅当不在 Tauri 内时生效,不影响真实数据) ----
|
||||
|
||||
const mockCatalog = (): CatalogEntry[] => [
|
||||
{ id: "codex", name: "Codex CLI", name_zh: "Codex CLI", vendor: "OpenAI", status: "available" },
|
||||
{ id: "claude-code", name: "Claude Code", name_zh: "Claude Code", vendor: "Anthropic", status: "available" },
|
||||
@@ -39,10 +100,29 @@ const mockCatalog = (): CatalogEntry[] => [
|
||||
{ id: "warp", name: "Warp Agent CLI", name_zh: "Warp Agent CLI", vendor: "Warp", status: "available" },
|
||||
];
|
||||
|
||||
const mockDetect = (id: string): DetectResult => ({
|
||||
cli_id: id,
|
||||
status: "not_installed",
|
||||
version: null,
|
||||
executable: null,
|
||||
version_unconfirmed: false,
|
||||
checked_at: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
|
||||
const mockAuth = (id: string): AuthStatus => ({
|
||||
cli_id: id,
|
||||
status: "unknown",
|
||||
via: "unknown",
|
||||
detail_zh: "尚未检测到授权信息",
|
||||
checked_at: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
|
||||
const mockPlatformEnv = (): PlatformEnv => ({
|
||||
os: "windows",
|
||||
os_version: "Windows 11 24H2 (Build 26100)",
|
||||
arch: "x86_64",
|
||||
distro: null,
|
||||
distro_version: null,
|
||||
shells: { powershell_version: "5.1.26100.1", pwsh_version: null, bash_available: true },
|
||||
runtimes: {
|
||||
node: { status: "installed", version: "24.18.0", path: "C:\\Program Files\\nodejs\\node.exe" },
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
// IPC 契约类型(对齐架构 §5 PlatformEnv / listCatalog)
|
||||
// IPC 契约类型(对齐架构 §2「关键 IPC 契约」与 core/types.rs)
|
||||
// 字段命名与 Rust 侧 serde 输出一致(snake_case)。
|
||||
|
||||
export interface PlatformEnv {
|
||||
os: string;
|
||||
os_version: string;
|
||||
arch: string;
|
||||
distro: string | null;
|
||||
distro_version: string | null;
|
||||
shells: Shells;
|
||||
runtimes: Runtimes;
|
||||
path_entries: string[];
|
||||
@@ -31,6 +33,7 @@ export type RuntimeStatus =
|
||||
| "installed"
|
||||
| "not_installed"
|
||||
| "not_in_path"
|
||||
| "permission_denied"
|
||||
| "exec_failed"
|
||||
| "version_unparseable";
|
||||
|
||||
@@ -52,3 +55,177 @@ export interface CatalogEntry {
|
||||
vendor: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
// ---- Wave 2:CLI 全链路契约 ----
|
||||
|
||||
export type CliAction = "install" | "update" | "uninstall" | "write_config" | "authorize" | "repair";
|
||||
|
||||
export type DetectStatus =
|
||||
| "installed"
|
||||
| "not_installed"
|
||||
| "not_in_path"
|
||||
| "permission_denied"
|
||||
| "exec_failed"
|
||||
| "version_unparseable";
|
||||
|
||||
export interface DetectResult {
|
||||
cli_id: string;
|
||||
status: DetectStatus;
|
||||
version: string | null;
|
||||
executable: string | null;
|
||||
version_unconfirmed: boolean;
|
||||
checked_at: number;
|
||||
}
|
||||
|
||||
export type AuthState = "authorized" | "unauthorized" | "unknown" | "possibly_expired";
|
||||
|
||||
export interface AuthStatus {
|
||||
cli_id: string;
|
||||
status: AuthState;
|
||||
via: string;
|
||||
detail_zh: string;
|
||||
checked_at: number;
|
||||
}
|
||||
|
||||
export interface ConfigFieldState {
|
||||
id: string;
|
||||
label_zh: string;
|
||||
help_zh: string | null;
|
||||
required: boolean;
|
||||
sensitive: boolean;
|
||||
field_type: string;
|
||||
storage: string;
|
||||
value: string | null;
|
||||
has_value: boolean;
|
||||
}
|
||||
|
||||
export interface ConfigFileState {
|
||||
path: string;
|
||||
format: string;
|
||||
scope: string | null;
|
||||
exists: boolean;
|
||||
parse_ok: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface EnvFieldState {
|
||||
key: string;
|
||||
sensitive: boolean;
|
||||
maps_to_field: string | null;
|
||||
}
|
||||
|
||||
export interface ConfigFormState {
|
||||
cli_id: string;
|
||||
files: ConfigFileState[];
|
||||
fields: ConfigFieldState[];
|
||||
environment: EnvFieldState[];
|
||||
}
|
||||
|
||||
export interface WriteResult {
|
||||
backup_path: string | null;
|
||||
written_file: string | null;
|
||||
written_fields: string[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface DryRunPlan {
|
||||
commands: string[][];
|
||||
elevate: boolean;
|
||||
elevate_reason_zh: string | null;
|
||||
affected_files: string[];
|
||||
rollback_zh: string;
|
||||
}
|
||||
|
||||
export type ActionEventKind = "step" | "stdout" | "stderr" | "done" | "error";
|
||||
|
||||
export interface ActionEvent {
|
||||
kind: ActionEventKind;
|
||||
message: string;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
export interface CliActionEvent {
|
||||
cli_id: string;
|
||||
event: ActionEvent;
|
||||
}
|
||||
|
||||
export interface DiagnosticFinding {
|
||||
rule_id: string;
|
||||
severity: "info" | "warn" | "error";
|
||||
message_zh: string;
|
||||
evidence: string | null;
|
||||
}
|
||||
|
||||
export interface DiagnosticReport {
|
||||
cli_id: string;
|
||||
findings: DiagnosticFinding[];
|
||||
}
|
||||
|
||||
// ---- 适配器完整定义(getAdapter 返回,detail 页展示用) ----
|
||||
|
||||
export interface AdapterChannel {
|
||||
id: string;
|
||||
platforms?: string[];
|
||||
command?: string[];
|
||||
script?: { url?: string | null; kind?: string | null } | null;
|
||||
package?: string | null;
|
||||
elevate?: string | null;
|
||||
elevate_reason_zh?: string | null;
|
||||
post_checks?: string[];
|
||||
}
|
||||
|
||||
export interface Adapter {
|
||||
id: string;
|
||||
name: string;
|
||||
name_zh: string;
|
||||
vendor: string;
|
||||
status: string;
|
||||
adapter_version?: string | null;
|
||||
license?: string | null;
|
||||
platforms?: {
|
||||
windows?: { architectures?: string[]; notes?: string | null } | null;
|
||||
linux?: { distributions?: string[]; architectures?: string[]; min_ubuntu?: string | null } | null;
|
||||
} | null;
|
||||
official?: { homepage?: string | null; docs?: string | null; allowed_hosts?: string[] } | null;
|
||||
runtime_deps?: { id: string; semver_range?: string | null; required_for?: string[] }[];
|
||||
install?: { preferred?: string | null; channels: AdapterChannel[] } | null;
|
||||
detect?: {
|
||||
executable: string;
|
||||
version_args?: string[];
|
||||
version_regex?: string | null;
|
||||
version_unconfirmed?: boolean | null;
|
||||
path_hints?: string[];
|
||||
} | null;
|
||||
update?: { method?: string | null; command?: string[] } | null;
|
||||
uninstall?: { method?: string | null; command?: string[]; keep_config_default?: boolean } | null;
|
||||
authorization?: {
|
||||
modes: {
|
||||
mode: string;
|
||||
command?: string[];
|
||||
env_keys?: string[];
|
||||
status_command?: string[];
|
||||
notes_zh?: string | null;
|
||||
}[];
|
||||
} | null;
|
||||
configuration?: {
|
||||
files: { path: string; format: string; scope?: string | null }[];
|
||||
environment?: { key: string; sensitive?: boolean; maps_to_field?: string | null }[];
|
||||
fields?: {
|
||||
id: string;
|
||||
label_zh: string;
|
||||
help_zh?: string | null;
|
||||
required?: boolean;
|
||||
sensitive?: boolean;
|
||||
type: string;
|
||||
storage: string;
|
||||
platforms?: string[];
|
||||
docs_url?: string | null;
|
||||
}[];
|
||||
} | null;
|
||||
documentation?: {
|
||||
quickstart_zh?: string | null;
|
||||
commands?: { cmd: string; desc_zh?: string | null }[];
|
||||
updated_at?: string | null;
|
||||
risks_zh?: string[];
|
||||
} | null;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import { Archive } from "lucide-react";
|
||||
|
||||
/** 备份与迁移(PRD §7):创建备份、查看内容、恢复。Wave 0 占位。 */
|
||||
/** 备份与迁移(PRD §7):创建备份、查看内容、恢复。空态按 v1.3 §3.7。 */
|
||||
export function BackupPage() {
|
||||
return (
|
||||
<div className="placeholder-page">
|
||||
<div className="placeholder-icon">
|
||||
<Archive size={40} strokeWidth={1.5} aria-hidden="true" />
|
||||
</div>
|
||||
<h2 className="placeholder-title">备份与迁移</h2>
|
||||
<h2 className="placeholder-title">还没有可备份的内容</h2>
|
||||
<p className="placeholder-desc">
|
||||
备份功能在 Wave 4 落地。备份将明确标注是否包含敏感信息,密钥永不进备份。
|
||||
安装 CLI 并完成配置后,可以在这里创建备份与迁移。密钥永不进备份。
|
||||
</p>
|
||||
<button type="button" className="btn btn-secondary" disabled>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled
|
||||
title="安装 CLI 后才能创建备份"
|
||||
>
|
||||
创建备份
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useMemo, useState } from "react";
|
||||
import { Search } from "lucide-react";
|
||||
import { useCatalog } from "../hooks/useCatalog";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { MonoChip } from "../components/MonoChip";
|
||||
import { CliMonogram } from "../components/CliMonogram";
|
||||
|
||||
type StatusFilter = "all" | "installed" | "uninstalled" | "update";
|
||||
@@ -22,7 +21,7 @@ const PLATFORM_FILTERS: { key: PlatformFilter; label: string }[] = [
|
||||
];
|
||||
|
||||
/** CLI 目录页(视觉规范 §3.2 + PRD §7):搜索 + 筛选 + 14 张卡片(数据来自 catalog.yaml) */
|
||||
export function CatalogPage() {
|
||||
export function CatalogPage({ onOpenDetail }: { onOpenDetail: (id: string) => void }) {
|
||||
const { entries, loading, error } = useCatalog();
|
||||
const [query, setQuery] = useState("");
|
||||
const [status, setStatus] = useState<StatusFilter>("all");
|
||||
@@ -88,19 +87,30 @@ export function CatalogPage() {
|
||||
|
||||
<div className="catalog-grid">
|
||||
{filtered.map((entry) => (
|
||||
<article className="catalog-card" key={entry.id}>
|
||||
<article
|
||||
className="catalog-card"
|
||||
key={entry.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onOpenDetail(entry.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onOpenDetail(entry.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="catalog-card-top">
|
||||
<CliMonogram name={entry.name} size={40} />
|
||||
<CliMonogram id={entry.id} name={entry.name} size={40} />
|
||||
<div className="catalog-card-head">
|
||||
<div className="catalog-card-name">{entry.name_zh}</div>
|
||||
<div className="catalog-card-vendor">{entry.vendor}</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="catalog-card-desc">官方渠道安装 · 适配器随 Wave 1 落地</p>
|
||||
<p className="catalog-card-desc">官方渠道安装 · 全平台支持</p>
|
||||
<div className="catalog-card-bottom">
|
||||
<StatusBadge kind="uninstalled" label="未安装" />
|
||||
<span className="catalog-platforms">Windows · Linux</span>
|
||||
<MonoChip>v1</MonoChip>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
BookOpen,
|
||||
ChevronDown,
|
||||
CircleHelp,
|
||||
ClipboardList,
|
||||
Loader2,
|
||||
ShieldAlert,
|
||||
Terminal,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useCliDetail } from "../hooks/useCliDetail";
|
||||
import { diagnose, onCliAction, previewAction, runAction } from "../ipc";
|
||||
import type {
|
||||
ActionEvent,
|
||||
Adapter,
|
||||
AuthStatus,
|
||||
CliAction,
|
||||
DetectResult,
|
||||
DiagnosticReport,
|
||||
DryRunPlan,
|
||||
} from "../ipc/types";
|
||||
import { CliMonogram } from "../components/CliMonogram";
|
||||
import { MonoChip } from "../components/MonoChip";
|
||||
import { Modal } from "../components/Modal";
|
||||
import { ConfigForm } from "../components/ConfigForm";
|
||||
|
||||
type Tab = "overview" | "config" | "docs" | "diag";
|
||||
|
||||
const TABS: { key: Tab; label: string }[] = [
|
||||
{ key: "overview", label: "概览" },
|
||||
{ key: "config", label: "配置" },
|
||||
{ key: "docs", label: "中文文档" },
|
||||
{ key: "diag", label: "诊断" },
|
||||
];
|
||||
|
||||
/** 授权状态灯(§3.3:已授权=绿、未授权=紫、可能过期=黄呼吸、未知=灰) */
|
||||
function AuthLight({ auth }: { auth: AuthStatus | null }) {
|
||||
if (!auth) return <span className="status-dot unknown" aria-hidden="true" />;
|
||||
const cls =
|
||||
auth.status === "authorized"
|
||||
? "status-dot ok"
|
||||
: auth.status === "unauthorized"
|
||||
? "status-dot auth"
|
||||
: auth.status === "possibly_expired"
|
||||
? "status-dot warn breathe"
|
||||
: "status-dot unknown";
|
||||
const label =
|
||||
auth.status === "authorized"
|
||||
? "已授权"
|
||||
: auth.status === "unauthorized"
|
||||
? "未授权"
|
||||
: auth.status === "possibly_expired"
|
||||
? "授权可能过期"
|
||||
: "未知";
|
||||
return (
|
||||
<span className="auth-light">
|
||||
<span className={cls} aria-hidden="true" />
|
||||
<span className="auth-light-text">{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** 检测状态中文 */
|
||||
function detectLabel(d: DetectResult | null): string {
|
||||
if (!d) return "未检测";
|
||||
switch (d.status) {
|
||||
case "installed":
|
||||
return "已安装";
|
||||
case "not_installed":
|
||||
return "未安装";
|
||||
case "not_in_path":
|
||||
return "不在 PATH";
|
||||
case "version_unparseable":
|
||||
return "版本未知";
|
||||
case "permission_denied":
|
||||
return "无访问权限";
|
||||
case "exec_failed":
|
||||
return "执行失败";
|
||||
default:
|
||||
return d.status;
|
||||
}
|
||||
}
|
||||
|
||||
export function CliDetailPage({ id, onBack }: { id: string; onBack: () => void }) {
|
||||
const { adapter, detect, auth, loading, error, refresh } = useCliDetail(id);
|
||||
const [tab, setTab] = useState<Tab>("overview");
|
||||
const [confirm, setConfirm] = useState<{ action: CliAction; plan: DryRunPlan } | null>(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [runTitle, setRunTitle] = useState("");
|
||||
const [log, setLog] = useState<ActionEvent[]>([]);
|
||||
const logEndRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const installed = detect?.status === "installed";
|
||||
|
||||
// 订阅流式事件
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
onCliAction((payload) => {
|
||||
if (payload.cli_id !== id) return;
|
||||
const ev = payload.event;
|
||||
if (ev.kind === "done") {
|
||||
setRunning(false);
|
||||
void refresh();
|
||||
return;
|
||||
}
|
||||
if (ev.kind === "error") {
|
||||
setRunning(false);
|
||||
setLog((l) => [...l, { kind: "error", message: ev.message, data: null }]);
|
||||
return;
|
||||
}
|
||||
setLog((l) => [...l, ev]);
|
||||
}).then((fn) => {
|
||||
unlisten = fn;
|
||||
});
|
||||
return () => unlisten?.();
|
||||
}, [id, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
logEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [log]);
|
||||
|
||||
async function openConfirm(action: CliAction) {
|
||||
const plan = await previewAction(id, action);
|
||||
setConfirm({ action, plan });
|
||||
}
|
||||
|
||||
async function confirmRun() {
|
||||
if (!confirm) return;
|
||||
const action = confirm.action;
|
||||
setConfirm(null);
|
||||
setRunning(true);
|
||||
setLog([]);
|
||||
setRunTitle(action === "install" ? "正在安装" : action === "uninstall" ? "正在卸载" : "正在执行");
|
||||
await runAction(id, action);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <p className="panel-empty">加载中…</p>;
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div className="cli-detail">
|
||||
<p className="panel-empty">加载失败:{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!adapter) return null;
|
||||
|
||||
return (
|
||||
<div className="cli-detail">
|
||||
<button type="button" className="link-btn detail-back" onClick={onBack}>
|
||||
<ArrowLeft size={14} strokeWidth={1.5} aria-hidden="true" /> 返回目录
|
||||
</button>
|
||||
|
||||
{/* 头卡(§3.3) */}
|
||||
<section className="panel cli-head">
|
||||
<div className="cli-head-main">
|
||||
<CliMonogram id={adapter.id} name={adapter.name} size={56} />
|
||||
<div className="cli-head-info">
|
||||
<div className="cli-head-name">{adapter.name_zh}</div>
|
||||
<div className="cli-head-vendor">
|
||||
{adapter.vendor}
|
||||
{adapter.license && <span className="cli-head-license"> · {adapter.license}</span>}
|
||||
</div>
|
||||
<div className="cli-head-meta">
|
||||
<span className="status-badge-mini">{detectLabel(detect)}</span>
|
||||
{detect?.version && <MonoChip>{detect.version}</MonoChip>}
|
||||
{detect?.executable && (
|
||||
<span className="cli-head-path">
|
||||
<MonoChip>{detect.executable}</MonoChip>
|
||||
</span>
|
||||
)}
|
||||
{detect?.version_unconfirmed && (
|
||||
<span className="cli-head-unconfirmed">版本号实测兜底</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="cli-head-side">
|
||||
<AuthLight auth={auth} />
|
||||
<div className="cli-head-actions">
|
||||
{!installed ? (
|
||||
<button type="button" className="btn btn-primary" onClick={() => openConfirm("install")}>
|
||||
安装
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setTab("config")}>
|
||||
打开配置
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-text-danger"
|
||||
disabled={!installed}
|
||||
title={!installed ? "尚未安装" : "卸载(默认保留配置)"}
|
||||
onClick={() => openConfirm("uninstall")}
|
||||
>
|
||||
<Trash2 size={14} strokeWidth={1.5} aria-hidden="true" /> 卸载
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 标签页 */}
|
||||
<nav className="tabs" role="tablist" aria-label="CLI 详情标签页">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === t.key}
|
||||
className={tab === t.key ? "tab active" : "tab"}
|
||||
onClick={() => setTab(t.key)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<section className="cli-detail-body">
|
||||
{tab === "overview" && <OverviewTab adapter={adapter} detect={detect} />}
|
||||
{tab === "config" && <ConfigForm id={id} />}
|
||||
{tab === "docs" && <DocsTab adapter={adapter} />}
|
||||
{tab === "diag" && <DiagTab id={id} />}
|
||||
</section>
|
||||
|
||||
{/* 安装/卸载确认弹窗(§3.3:完整展示命令/权限/影响,确认才执行) */}
|
||||
{confirm && (
|
||||
<Modal
|
||||
title={confirm.action === "install" ? `确认安装 ${adapter.name_zh}` : `确认卸载 ${adapter.name_zh}`}
|
||||
onClose={() => setConfirm(null)}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setConfirm(null)}>
|
||||
取消
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" onClick={confirmRun}>
|
||||
确认{confirm.action === "install" ? "安装" : "卸载"}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ConfirmBody plan={confirm.plan} action={confirm.action} />
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* 流式执行日志弹窗 */}
|
||||
{running && (
|
||||
<Modal title={`${runTitle} ${adapter.name_zh}…`} footer={null}>
|
||||
<div className="run-log" aria-live="polite">
|
||||
{log.length === 0 && (
|
||||
<div className="run-log-wait">
|
||||
<Loader2 size={16} strokeWidth={1.5} className="spin" aria-hidden="true" />
|
||||
正在启动命令…
|
||||
</div>
|
||||
)}
|
||||
{log.map((l, i) => (
|
||||
<div key={i} className={`run-log-line ${l.kind}`}>
|
||||
{l.kind === "step" ? <ClipboardList size={13} strokeWidth={1.5} aria-hidden="true" /> : null}
|
||||
{l.kind === "stdout" ? <Terminal size={13} strokeWidth={1.5} aria-hidden="true" /> : null}
|
||||
{l.kind === "stderr" ? <ShieldAlert size={13} strokeWidth={1.5} aria-hidden="true" /> : null}
|
||||
<span>{l.message}</span>
|
||||
</div>
|
||||
))}
|
||||
<div ref={logEndRef} />
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfirmBody({ plan, action }: { plan: DryRunPlan; action: CliAction }) {
|
||||
return (
|
||||
<div className="confirm-body">
|
||||
<div className="confirm-section">
|
||||
<div className="confirm-label">将执行的命令</div>
|
||||
{plan.commands.length === 0 ? (
|
||||
<p className="confirm-empty">无外部命令(本动作仅本地落盘配置)</p>
|
||||
) : (
|
||||
plan.commands.map((cmd, i) => (
|
||||
<div key={i} className="confirm-cmd">
|
||||
<MonoChip>{cmd.join(" ")}</MonoChip>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="confirm-section">
|
||||
<div className="confirm-label">权限</div>
|
||||
<p className="confirm-text">
|
||||
{plan.elevate ? (
|
||||
<>需要管理员权限{plan.elevate_reason_zh ? `:${plan.elevate_reason_zh}` : ""}</>
|
||||
) : (
|
||||
"无需管理员权限(安装到用户目录)"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{plan.affected_files.length > 0 && (
|
||||
<div className="confirm-section">
|
||||
<div className="confirm-label">影响文件</div>
|
||||
<div className="confirm-files">
|
||||
{plan.affected_files.map((f) => (
|
||||
<span key={f} className="confirm-file">
|
||||
<MonoChip>{f}</MonoChip>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="confirm-section">
|
||||
<div className="confirm-label">回滚说明</div>
|
||||
<p className="confirm-text">{plan.rollback_zh}</p>
|
||||
</div>
|
||||
{action === "uninstall" && (
|
||||
<p className="confirm-note">卸载默认保留配置文件与密钥库条目,可随时重新安装恢复。</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewTab({ adapter, detect }: { adapter: Adapter; detect: DetectResult | null }) {
|
||||
const channels = adapter.install?.channels ?? [];
|
||||
return (
|
||||
<div className="detail-overview">
|
||||
<div className="detail-block">
|
||||
<h3 className="detail-block-title">快速上手</h3>
|
||||
<p className="detail-block-text">
|
||||
{adapter.documentation?.quickstart_zh ?? "参见官方文档。"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="detail-block">
|
||||
<h3 className="detail-block-title">安装渠道</h3>
|
||||
{channels.length === 0 ? (
|
||||
<p className="detail-block-text">未声明安装渠道。</p>
|
||||
) : (
|
||||
<ul className="detail-channels">
|
||||
{channels.map((c) => (
|
||||
<li key={c.id} className="detail-channel">
|
||||
<MonoChip>{c.id}</MonoChip>
|
||||
{c.command && c.command.length > 0 && <MonoChip>{c.command.join(" ")}</MonoChip>}
|
||||
{c.package && <span className="detail-channel-pkg">包名 {c.package}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
{detect && detect.status !== "installed" && detect.status !== "not_installed" && (
|
||||
<div className="detail-block detail-note">
|
||||
<CircleHelp size={14} strokeWidth={1.5} aria-hidden="true" />
|
||||
检测状态:{detectLabel(detect)}。可尝试重新检测或检查 PATH。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DocsTab({ adapter }: { adapter: Adapter }) {
|
||||
const doc = adapter.documentation;
|
||||
return (
|
||||
<div className="detail-docs">
|
||||
<div className="detail-block">
|
||||
<h3 className="detail-block-title">
|
||||
<BookOpen size={14} strokeWidth={1.5} aria-hidden="true" /> 常用命令
|
||||
</h3>
|
||||
{(doc?.commands ?? []).length === 0 ? (
|
||||
<p className="detail-block-text">暂无命令文档。</p>
|
||||
) : (
|
||||
<ul className="detail-commands">
|
||||
{doc!.commands!.map((c) => (
|
||||
<li key={c.cmd} className="detail-command">
|
||||
<MonoChip>{c.cmd}</MonoChip>
|
||||
{c.desc_zh && <span className="detail-command-desc">{c.desc_zh}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
{(doc?.risks_zh ?? []).length > 0 && (
|
||||
<div className="detail-block detail-note">
|
||||
<ShieldAlert size={14} strokeWidth={1.5} aria-hidden="true" />
|
||||
<div>
|
||||
{doc!.risks_zh!.map((r) => (
|
||||
<p key={r}>{r}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{adapter.official?.homepage && (
|
||||
<div className="detail-block detail-block-text">
|
||||
官方主页:<MonoChip>{adapter.official.homepage}</MonoChip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function severityMeta(sev: "info" | "warn" | "error") {
|
||||
switch (sev) {
|
||||
case "info":
|
||||
return { cls: "diag-sev-info", label: "提示" };
|
||||
case "warn":
|
||||
return { cls: "diag-sev-warn", label: "警告" };
|
||||
case "error":
|
||||
return { cls: "diag-sev-error", label: "错误" };
|
||||
}
|
||||
}
|
||||
|
||||
function DiagTab({ id }: { id: string }) {
|
||||
const [report, setReport] = useState<DiagnosticReport | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState<Record<number, boolean>>({});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
diagnose(id)
|
||||
.then((r) => {
|
||||
if (!cancelled) {
|
||||
setReport(r);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) {
|
||||
setError(String(e));
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
if (loading) return <p className="panel-empty">诊断中…</p>;
|
||||
if (error) return <p className="panel-empty">诊断失败:{error}</p>;
|
||||
if (!report) return null;
|
||||
|
||||
const findings = useMemo(() => report.findings, [report]);
|
||||
|
||||
return (
|
||||
<div className="diag-result">
|
||||
<div className="diag-summary">
|
||||
{findings.length === 0 ? (
|
||||
<>
|
||||
<span className="status-dot ok" aria-hidden="true" />
|
||||
<span>未发现问题</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className={`status-dot ${findings.some((f) => f.severity === "error") ? "error" : "warn"}`} aria-hidden="true" />
|
||||
<span>
|
||||
发现 {findings.filter((f) => f.severity === "error").length} 个错误、{" "}
|
||||
{findings.filter((f) => f.severity === "warn").length} 个警告、{" "}
|
||||
{findings.filter((f) => f.severity === "info").length} 个提示
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{findings.map((f, i) => {
|
||||
const meta = severityMeta(f.severity);
|
||||
return (
|
||||
<div key={i} className="diag-finding">
|
||||
<div className={`diag-sev-bar ${meta.cls}`} aria-hidden="true" />
|
||||
<div className="diag-finding-body">
|
||||
<div className="diag-finding-head">
|
||||
<span className={`diag-sev-tag ${meta.cls}`}>{meta.label}</span>
|
||||
<span className="diag-finding-rule">{f.rule_id}</span>
|
||||
</div>
|
||||
<p className="diag-finding-msg">{f.message_zh}</p>
|
||||
{f.evidence && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="link-btn diag-evidence-toggle"
|
||||
onClick={() => setOpen((o) => ({ ...o, [i]: !o[i] }))}
|
||||
>
|
||||
原始证据 <ChevronDown size={14} strokeWidth={1.5} aria-hidden="true" />
|
||||
</button>
|
||||
{open[i] && <pre className="diag-evidence">{f.evidence}</pre>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,18 @@
|
||||
import { SlidersHorizontal } from "lucide-react";
|
||||
import type { PageKey } from "../components/Sidebar";
|
||||
|
||||
/** 配置中心(PRD §7):按 CLI 展示中文配置表单。Wave 0 占位。 */
|
||||
export function ConfigCenterPage() {
|
||||
/** 配置中心(PRD §7):按 CLI 展示中文配置表单。空态按 v1.3 §3.7。 */
|
||||
export function ConfigCenterPage({ onNavigate }: { onNavigate: (p: PageKey) => void }) {
|
||||
return (
|
||||
<div className="placeholder-page">
|
||||
<div className="placeholder-icon">
|
||||
<SlidersHorizontal size={40} strokeWidth={1.5} aria-hidden="true" />
|
||||
</div>
|
||||
<h2 className="placeholder-title">配置中心</h2>
|
||||
<h2 className="placeholder-title">还没有可配置的 CLI</h2>
|
||||
<p className="placeholder-desc">
|
||||
中文配置表单在 Wave 2 落地。敏感字段将使用密码输入与系统密钥库。
|
||||
安装 CLI 后,在这里用中文表单管理配置。敏感字段会用密码输入并存入系统密钥库。
|
||||
</p>
|
||||
<button type="button" className="btn btn-secondary" disabled>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => onNavigate("catalog")}>
|
||||
选择 CLI 开始配置
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { SquareTerminal } from "lucide-react";
|
||||
import type { PageKey } from "../components/Sidebar";
|
||||
|
||||
/** 我的 CLI(PRD §7):版本 / 路径 / 授权状态。Wave 0 尚无已安装 CLI,展示空态。 */
|
||||
export function MyCliPage() {
|
||||
/** 我的 CLI(PRD §7):版本 / 路径 / 授权状态。空态按 v1.3 §3.7。 */
|
||||
export function MyCliPage({ onNavigate }: { onNavigate: (p: PageKey) => void }) {
|
||||
return (
|
||||
<div className="placeholder-page">
|
||||
<div className="placeholder-icon">
|
||||
<SquareTerminal size={40} strokeWidth={1.5} aria-hidden="true" />
|
||||
</div>
|
||||
<h2 className="placeholder-title">暂无已安装 CLI</h2>
|
||||
<h2 className="placeholder-title">还没有安装任何 CLI</h2>
|
||||
<p className="placeholder-desc">
|
||||
安装功能在 Wave 1 开放。届时这里会展示每个 CLI 的版本、路径与授权状态。
|
||||
安装后,这里会展示每个 CLI 的版本、路径与授权状态。
|
||||
</p>
|
||||
<button type="button" className="btn btn-primary" disabled>
|
||||
<button type="button" className="btn btn-primary" onClick={() => onNavigate("catalog")}>
|
||||
去 CLI 目录看看
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useState } from "react";
|
||||
import { Activity, Archive, ChevronRight, Lock, Plus } from "lucide-react";
|
||||
import { Activity, Archive, Lock, Plus, ScanSearch } from "lucide-react";
|
||||
import { useEnv } from "../hooks/useEnv";
|
||||
import { useCatalog } from "../hooks/useCatalog";
|
||||
import { KpiCard } from "../components/KpiCard";
|
||||
import { MonoChip } from "../components/MonoChip";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { CliMonogram } from "../components/CliMonogram";
|
||||
import type { PlatformEnv, RuntimeInfo } from "../ipc/types";
|
||||
import type { PlatformEnv, RuntimeInfo, RuntimeStatus, CatalogEntry } from "../ipc/types";
|
||||
import type { PageKey } from "../components/Sidebar";
|
||||
|
||||
/** 平台相关运行时集合(Windows 不看 apt,Linux 不看 winget) */
|
||||
const WINDOWS_RUNTIMES = ["node", "npm", "python", "uv", "git", "winget"] as const;
|
||||
@@ -24,42 +25,61 @@ function runtimeProblems(env: PlatformEnv): { name: string; info: RuntimeInfo }[
|
||||
return problems;
|
||||
}
|
||||
|
||||
function runtimeStatusText(status: string): string {
|
||||
/** 本机环境运行时项四元组(v1.3 §3.1.6):状态点 + 名称 + 版本位 + 行动位 */
|
||||
function runtimeMeta(status: RuntimeStatus): {
|
||||
dot: string;
|
||||
missingText: string | null;
|
||||
needsGuide: boolean;
|
||||
} {
|
||||
switch (status) {
|
||||
case "not_in_path":
|
||||
return "不在 PATH";
|
||||
case "installed":
|
||||
return { dot: "status-dot ok", missingText: null, needsGuide: false };
|
||||
case "not_installed":
|
||||
return "未安装";
|
||||
return { dot: "status-dot unknown", missingText: "未检测到", needsGuide: true };
|
||||
case "not_in_path":
|
||||
return { dot: "status-dot warn breathe", missingText: "不在 PATH", needsGuide: false };
|
||||
case "permission_denied":
|
||||
return { dot: "status-dot warn breathe", missingText: "无访问权限", needsGuide: false };
|
||||
case "exec_failed":
|
||||
return "执行失败";
|
||||
return { dot: "status-dot warn breathe", missingText: "执行失败", needsGuide: false };
|
||||
case "version_unparseable":
|
||||
return "版本未知";
|
||||
return { dot: "status-dot warn breathe", missingText: "版本未知", needsGuide: false };
|
||||
default:
|
||||
return "未知";
|
||||
return { dot: "status-dot unknown", missingText: "未知", needsGuide: false };
|
||||
}
|
||||
}
|
||||
|
||||
function RuntimeItem({ name, info }: { name: string; info: RuntimeInfo }) {
|
||||
const dotClass =
|
||||
info.status === "installed"
|
||||
? "status-dot ok"
|
||||
: info.status === "exec_failed"
|
||||
? "status-dot error"
|
||||
: "status-dot warn";
|
||||
function RuntimeItem({
|
||||
name,
|
||||
info,
|
||||
onNavigate,
|
||||
}: {
|
||||
name: string;
|
||||
info: RuntimeInfo;
|
||||
onNavigate: (p: PageKey) => void;
|
||||
}) {
|
||||
const meta = runtimeMeta(info.status);
|
||||
return (
|
||||
<div className="runtime-item">
|
||||
<span className={dotClass} aria-hidden="true" />
|
||||
<span className={meta.dot} aria-hidden="true" />
|
||||
<span className="runtime-name">{name}</span>
|
||||
{info.version ? (
|
||||
<MonoChip>{info.version}</MonoChip>
|
||||
<span className="runtime-version">
|
||||
<MonoChip>{info.version}</MonoChip>
|
||||
</span>
|
||||
) : (
|
||||
<span className="runtime-missing">{runtimeStatusText(info.status)}</span>
|
||||
<span className="runtime-missing">{meta.missingText}</span>
|
||||
)}
|
||||
{meta.needsGuide && (
|
||||
<button type="button" className="link-btn runtime-action" onClick={() => onNavigate("catalog")}>
|
||||
安装指引
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EnvPanel() {
|
||||
function EnvPanel({ onNavigate }: { onNavigate: (p: PageKey) => void }) {
|
||||
const { env, loading, error } = useEnv();
|
||||
const [showAllPath, setShowAllPath] = useState(false);
|
||||
|
||||
@@ -122,7 +142,7 @@ function EnvPanel() {
|
||||
(key) => {
|
||||
const info = env.runtimes[key];
|
||||
if (!info) return null;
|
||||
return <RuntimeItem key={key} name={key} info={info} />;
|
||||
return <RuntimeItem key={key} name={key} info={info} onNavigate={onNavigate} />;
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
@@ -155,100 +175,222 @@ function EnvPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
/** 总览页(视觉规范 §3.1 + PRD §7):KPI + 最近诊断/快速操作 + CLI 状态网格 + 本机环境 */
|
||||
export function OverviewPage() {
|
||||
/** 首跑空态引导插画(v1.3 §7:等距线框终端 + 集装箱 Dock,1px 线,霓虹点缀) */
|
||||
function OnboardingArt() {
|
||||
return (
|
||||
<svg width="200" height="150" viewBox="0 0 200 150" fill="none" aria-hidden="true">
|
||||
<rect x="24" y="18" width="120" height="86" rx="8" stroke="currentColor" strokeWidth="1" />
|
||||
<line x1="24" y1="40" x2="144" y2="40" stroke="currentColor" strokeWidth="1" />
|
||||
<circle cx="36" cy="29" r="2" style={{ fill: "var(--ad-primary)" }} />
|
||||
<circle cx="46" cy="29" r="2" fill="currentColor" opacity="0.55" />
|
||||
<circle cx="56" cy="29" r="2" fill="currentColor" opacity="0.55" />
|
||||
<rect x="36" y="52" width="58" height="6" rx="3" fill="currentColor" opacity="0.5" />
|
||||
<rect x="36" y="66" width="84" height="6" rx="3" fill="currentColor" opacity="0.32" />
|
||||
<rect x="36" y="80" width="46" height="6" rx="3" style={{ fill: "var(--ad-accent)" }} opacity="0.8" />
|
||||
<rect x="24" y="116" width="36" height="26" rx="4" stroke="currentColor" strokeWidth="1" />
|
||||
<rect x="66" y="116" width="36" height="26" rx="4" stroke="currentColor" strokeWidth="1" />
|
||||
<rect x="108" y="116" width="36" height="26" rx="4" stroke="currentColor" strokeWidth="1" />
|
||||
<line x1="24" y1="142" x2="144" y2="142" stroke="currentColor" strokeWidth="1" opacity="0.4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** 首跑引导区(v1.3 §3.1.5:插画 + 三句式文案 + 主行动按钮) */
|
||||
function Onboarding({ onNavigate }: { onNavigate: (p: PageKey) => void }) {
|
||||
return (
|
||||
<div className="panel onboarding">
|
||||
<div className="onboarding-art">
|
||||
<OnboardingArt />
|
||||
</div>
|
||||
<div className="onboarding-copy">
|
||||
<h2 className="onboarding-title">你的工具箱还是空的</h2>
|
||||
<p className="onboarding-desc">
|
||||
从目录安装第一个 Agent CLI,安装、配置、授权、诊断都帮你盯好。
|
||||
</p>
|
||||
<div className="onboarding-actions">
|
||||
<button type="button" className="btn btn-primary" onClick={() => onNavigate("catalog")}>
|
||||
浏览 CLI 目录
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary">
|
||||
先诊断本机环境
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 最近诊断卡(v1.3 §3.1.2:无记录时展示空态,不渲染占位行与「查看全部」) */
|
||||
function DiagPanel() {
|
||||
// Wave 0.5 尚无诊断引擎数据,恒为空态(语义按 §3.7,不虚构记录)
|
||||
const records: { cli: string; text: string; time: string }[] = [];
|
||||
return (
|
||||
<div className="panel diag-panel">
|
||||
<div className="panel-head">
|
||||
<h2 className="panel-title">最近诊断</h2>
|
||||
</div>
|
||||
{records.length === 0 ? (
|
||||
<div className="diag-empty">
|
||||
<span className="diag-empty-text">暂无诊断记录</span>
|
||||
<button type="button" className="btn btn-secondary">
|
||||
<ScanSearch size={16} strokeWidth={1.5} aria-hidden="true" /> 立即诊断
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="diag-list">
|
||||
{records.map((r) => (
|
||||
<li className="diag-row" key={r.cli}>
|
||||
<span className="status-dot ok" aria-hidden="true" />
|
||||
<span className="diag-cli">{r.cli}</span>
|
||||
<span className="diag-text">{r.text}</span>
|
||||
<span className="diag-time">{r.time}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 快速操作卡(v1.3 §3.1.3:三按钮竖向操作组,创建备份依赖数据置灰) */
|
||||
function QuickActions({
|
||||
firstRun,
|
||||
onNavigate,
|
||||
}: {
|
||||
firstRun: boolean;
|
||||
onNavigate: (p: PageKey) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="panel quick-card">
|
||||
<h2 className="panel-title">快速操作</h2>
|
||||
<button type="button" className="btn btn-primary" onClick={() => onNavigate("catalog")}>
|
||||
<Plus size={16} strokeWidth={1.5} aria-hidden="true" /> 添加 CLI
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary">
|
||||
<Activity size={16} strokeWidth={1.5} aria-hidden="true" /> 立即诊断
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled
|
||||
title={firstRun ? "安装 CLI 后才能创建备份" : "暂无内容可备份"}
|
||||
>
|
||||
<Archive size={16} strokeWidth={1.5} aria-hidden="true" /> 创建备份
|
||||
</button>
|
||||
<div className="recent-installs">
|
||||
<div className="group-label">最近安装</div>
|
||||
<p className="group-empty">暂无记录</p>
|
||||
</div>
|
||||
<p className="safety-note">
|
||||
<Lock size={12} strokeWidth={1.5} aria-hidden="true" />
|
||||
所有操作在本地完成,密钥只存系统保险柜,不上传。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** CLI 状态网格块(v1.3 §3.1.4;首跑精简为两行 + 安装入口,§3.1.5) */
|
||||
function CliBlock({
|
||||
entry,
|
||||
compact,
|
||||
onNavigate,
|
||||
}: {
|
||||
entry: CatalogEntry;
|
||||
compact: boolean;
|
||||
onNavigate: (p: PageKey) => void;
|
||||
}) {
|
||||
const installed = entry.status === "installed";
|
||||
return (
|
||||
<div className="cli-block">
|
||||
<div className="cli-block-top">
|
||||
<CliMonogram id={entry.id} name={entry.name} size={32} />
|
||||
<span className="cli-block-name">{entry.name_zh}</span>
|
||||
<StatusBadge kind={installed ? "installed" : "uninstalled"} label={installed ? "已安装" : "未安装"} />
|
||||
</div>
|
||||
<div className="cli-block-version">
|
||||
{installed ? null : <span className="version-missing">未安装</span>}
|
||||
</div>
|
||||
{!compact && (
|
||||
<div className="cli-block-auth">
|
||||
{installed ? (
|
||||
<>
|
||||
<span className="status-dot ok" aria-hidden="true" />
|
||||
<span>已安装</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="status-dot unknown" aria-hidden="true" />
|
||||
<span>未检测 · 安装后自动检测</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{compact && (
|
||||
<div className="cli-block-install">
|
||||
<button type="button" className="link-btn" onClick={() => onNavigate("catalog")}>
|
||||
安装
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface OverviewProps {
|
||||
onNavigate: (p: PageKey) => void;
|
||||
}
|
||||
|
||||
/** 总览页(视觉规范 §3.1 + PRD §7):首跑空态 / KPI + 诊断/快速操作 + CLI 状态网格 + 本机环境 */
|
||||
export function OverviewPage({ onNavigate }: OverviewProps) {
|
||||
const { env } = useEnv();
|
||||
const { entries } = useCatalog();
|
||||
|
||||
const installedCount = entries.filter((e) => e.status === "installed").length;
|
||||
const firstRun = installedCount === 0; // 无安装且无诊断记录(当前无诊断引擎)
|
||||
|
||||
const warningCount = env ? runtimeProblems(env).length : 0;
|
||||
const warningNames = env ? runtimeProblems(env).map((p) => p.name) : [];
|
||||
|
||||
return (
|
||||
<div className="overview">
|
||||
{/* ① KPI 一排 4 张 */}
|
||||
<section className="kpi-grid" aria-label="概览指标">
|
||||
<KpiCard label="已安装" value={0} tone="primary" subtitle="暂无已安装 CLI" />
|
||||
<KpiCard label="待授权" value={0} tone="attention" subtitle="暂无待授权项" />
|
||||
<KpiCard label="可更新" value={0} tone="accent" subtitle="暂无可用更新" />
|
||||
<KpiCard
|
||||
label="环境异常"
|
||||
value={warningCount}
|
||||
tone="warning"
|
||||
subtitle={warningCount > 0 ? warningNames.join(" · ") : "环境正常"}
|
||||
/>
|
||||
</section>
|
||||
{/* ① KPI 一排 4 张(首跑空态隐藏,v1.3 §3.1.5) */}
|
||||
{!firstRun && (
|
||||
<section className="kpi-grid" aria-label="概览指标">
|
||||
<KpiCard label="已安装" value={installedCount} tone="primary" subtitle="暂无安装" action={{ label: "去目录看看", onClick: () => onNavigate("catalog") }} />
|
||||
<KpiCard label="待授权" value={0} tone="attention" subtitle="暂无待授权" />
|
||||
<KpiCard label="可更新" value={0} tone="accent" subtitle="全部已是最新" />
|
||||
<KpiCard
|
||||
label="环境异常"
|
||||
value={warningCount}
|
||||
tone="warning"
|
||||
subtitle={warningCount > 0 ? warningNames.join(" · ") : "环境正常"}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ② 中段:左 2/3 最近诊断 + 右 1/3 快速操作 */}
|
||||
{/* ② 中段:诊断/引导 + 快速操作(超宽三栏见 global.css) */}
|
||||
<section className="overview-mid">
|
||||
<div className="panel diag-panel">
|
||||
<div className="panel-head">
|
||||
<h2 className="panel-title">最近诊断</h2>
|
||||
<button type="button" className="link-btn">
|
||||
查看全部 <ChevronRight size={14} strokeWidth={1.5} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<ul className="diag-list">
|
||||
<li className="diag-row">
|
||||
<span className="status-dot ok" aria-hidden="true" />
|
||||
<span className="diag-cli">检测引擎</span>
|
||||
<span className="diag-text">诊断功能将在 Wave 1 接入</span>
|
||||
<span className="diag-time">刚刚</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="panel quick-card">
|
||||
<h2 className="panel-title">快速操作</h2>
|
||||
<button type="button" className="btn btn-primary" disabled>
|
||||
<Plus size={16} strokeWidth={1.5} aria-hidden="true" /> 添加 CLI
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" disabled>
|
||||
<Activity size={16} strokeWidth={1.5} aria-hidden="true" /> 立即诊断
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" disabled>
|
||||
<Archive size={16} strokeWidth={1.5} aria-hidden="true" /> 创建备份
|
||||
</button>
|
||||
<div className="recent-installs">
|
||||
<div className="group-label">最近安装</div>
|
||||
<p className="group-empty">暂无记录</p>
|
||||
</div>
|
||||
<p className="safety-note">
|
||||
<Lock size={12} strokeWidth={1.5} aria-hidden="true" />
|
||||
所有操作在本地完成,密钥只存系统保险柜,不上传。
|
||||
</p>
|
||||
</div>
|
||||
{firstRun ? <Onboarding onNavigate={onNavigate} /> : <DiagPanel />}
|
||||
<QuickActions firstRun={firstRun} onNavigate={onNavigate} />
|
||||
</section>
|
||||
|
||||
{/* ③ 下段:CLI 状态网格通栏 */}
|
||||
{/* ③ 下段:CLI 状态网格通栏(首跑精简:可安装列表 + 安装入口) */}
|
||||
<section className="panel cli-status-panel">
|
||||
<div className="panel-head">
|
||||
<h2 className="panel-title">CLI 状态</h2>
|
||||
<button type="button" className="link-btn">
|
||||
管理全部 {entries.length} 个
|
||||
<ChevronRight size={14} strokeWidth={1.5} aria-hidden="true" />
|
||||
</button>
|
||||
<h2 className="panel-title">
|
||||
{firstRun ? `可安装的 CLI(${entries.length})` : "CLI 状态"}
|
||||
</h2>
|
||||
{/* 「管理全部 N 个」入口:仅当未全量展示时才渲染(v1.3 §3.1.4),本页恒全量展示,故不渲染 */}
|
||||
</div>
|
||||
<div className="cli-status-grid">
|
||||
{entries.map((entry) => (
|
||||
<div className="cli-block" key={entry.id}>
|
||||
<div className="cli-block-top">
|
||||
<CliMonogram name={entry.name} size={32} />
|
||||
<span className="cli-block-name">{entry.name_zh}</span>
|
||||
<StatusBadge kind="uninstalled" label="未安装" />
|
||||
</div>
|
||||
<div className="cli-block-version">
|
||||
<MonoChip>—</MonoChip>
|
||||
</div>
|
||||
<div className="cli-block-auth">
|
||||
<span className="status-dot unknown" aria-hidden="true" />
|
||||
<span>未检测 · 可一键安装</span>
|
||||
</div>
|
||||
</div>
|
||||
<CliBlock key={entry.id} entry={entry} compact={firstRun} onNavigate={onNavigate} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ④ 本机环境(真实检测结果,架构 §5) */}
|
||||
<EnvPanel />
|
||||
{/* ④ 本机环境(真实检测结果,架构 §5;超宽断点上提为第三栏) */}
|
||||
<EnvPanel onNavigate={onNavigate} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,14 +9,14 @@ export function SettingsPage() {
|
||||
<div className="setting-row">
|
||||
<div className="setting-info">
|
||||
<div className="setting-label">下载源策略</div>
|
||||
<div className="setting-hint">选择 CLI 安装时的首选下载渠道(Wave 1 生效)</div>
|
||||
<div className="setting-hint">选择 CLI 安装时的首选下载渠道</div>
|
||||
</div>
|
||||
<span className="setting-value">官方渠道</span>
|
||||
</div>
|
||||
<div className="setting-row">
|
||||
<div className="setting-info">
|
||||
<div className="setting-label">自动检查更新</div>
|
||||
<div className="setting-hint">启动时检查 AgentDock 自身与 CLI 的更新(Wave 4 生效)</div>
|
||||
<div className="setting-hint">启动时检查 AgentDock 自身与 CLI 的更新</div>
|
||||
</div>
|
||||
<span className="setting-value">开</span>
|
||||
</div>
|
||||
@@ -30,7 +30,7 @@ export function SettingsPage() {
|
||||
<div className="setting-row">
|
||||
<div className="setting-info">
|
||||
<div className="setting-label">日志与隐私</div>
|
||||
<div className="setting-hint">日志本地保存,敏感值自动脱敏(Wave 1 生效)</div>
|
||||
<div className="setting-hint">日志本地保存,敏感值自动脱敏</div>
|
||||
</div>
|
||||
<span className="setting-value">默认</span>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,10 @@
|
||||
--ad-border: #22304A; /* 常规 1px 分隔线 */
|
||||
--ad-border-bright: #35507A; /* 悬浮/选中的边框 */
|
||||
|
||||
/* ---- 自定义滚动条(v1.3 §2.7:灰蓝滑块、透明轨道、不发光)---- */
|
||||
--ad-scrollbar-thumb: #1A2438; /* = --ad-bg-3 */
|
||||
--ad-scrollbar-thumb-hover: #35507A; /* = --ad-border-bright */
|
||||
|
||||
/* ---- 霓虹强调色(全系统只有这两个霓虹色)---- */
|
||||
--ad-primary: #00E5FF; /* 主操作、链接、聚焦、选中态(青) */
|
||||
--ad-primary-hover: #4DEFFF; /* 主操作悬浮 */
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
--ad-glow-accent: 0 0 12px rgba(255, 77, 219, 0.35);
|
||||
--ad-glow-warning: 0 0 10px rgba(255, 176, 32, 0.28); /* 仅警告 chip / 警告状态灯 */
|
||||
--ad-glow-danger: 0 0 12px rgba(255, 77, 94, 0.35);
|
||||
--ad-glow-balanced: 0 0 6px rgba(0, 229, 255, 0.3); /* 中档单层辉光(§5 半径减半),tiers.css 引用 */
|
||||
|
||||
/* ---- 主按钮(§2.6,v1.2 hover 双层扩散辉光)---- */
|
||||
--ad-btn-primary-bg: linear-gradient(180deg, #33D9EE 0%, #06BCD9 55%, #049EBB 100%);
|
||||
@@ -29,10 +30,8 @@
|
||||
--ad-btn-accent-glow-hover: 0 0 14px rgba(255, 77, 219, 0.30),
|
||||
0 0 36px rgba(255, 77, 219, 0.12);
|
||||
|
||||
/* ---- 背景网格(§3.0:要么可见要么不做)---- */
|
||||
--ad-grid-color: rgba(0, 229, 255, 0.05); /* 透明度固定 5%,施工员不许再调 */
|
||||
--ad-grid-size: 40px;
|
||||
--ad-grid-mask: radial-gradient(120% 90% at 50% 40%, #000 30%, transparent 75%);
|
||||
/* ---- 背景氛围(v1.3 §3.0:网格移除,降级为静态深空渐变,三档通用)---- */
|
||||
--ad-bg-gradient: radial-gradient(120% 90% at 50% 0%, #0D1420 0%, #070B14 70%);
|
||||
|
||||
/* ---- 弹窗遮罩(§3.6)---- */
|
||||
--ad-modal-mask: rgba(4, 7, 13, 0.7);
|
||||
|
||||
@@ -13,6 +13,11 @@
|
||||
--ad-space-6: 24px;
|
||||
--ad-space-8: 32px;
|
||||
--ad-space-10: 40px;
|
||||
--ad-space-12: 48px; /* v1.3 §3.0 超宽断点内容区边距 */
|
||||
|
||||
/* ---- 内容区宽度体系(v1.3 §6:标准 1440 / 宽阔 1760 / 超宽全宽)---- */
|
||||
--ad-frame-max: 1440px;
|
||||
--ad-frame-pad-x: var(--ad-space-8);
|
||||
|
||||
/* ---- 圆角 ---- */
|
||||
--ad-radius-s: 4px; /* 标签、小按钮、chip */
|
||||
|
||||
@@ -31,19 +31,7 @@
|
||||
|
||||
/* ---- 中档:只保留单层辉光(半径减半),循环动画停用 ---- */
|
||||
[data-fx-tier="balanced"] .fx-multilayer-glow {
|
||||
box-shadow: 0 0 6px rgba(0, 229, 255, 0.3);
|
||||
}
|
||||
|
||||
/* 背景网格漂移(§4.8,60s 一周,仅高档/中档) */
|
||||
[data-fx-tier="low"] .grid-drift {
|
||||
animation: none;
|
||||
}
|
||||
.grid-drift {
|
||||
animation: grid-drift 60s linear infinite;
|
||||
}
|
||||
@keyframes grid-drift {
|
||||
from { background-position: 0 0; }
|
||||
to { background-position: 40px 40px; }
|
||||
box-shadow: var(--ad-glow-balanced);
|
||||
}
|
||||
|
||||
/* 状态点呼吸(§4.5:仅警告/错误/授权可能过期呼吸;低档停用) */
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! 目录索引与占位条目加载(Wave 0)
|
||||
//! 目录索引与条目加载(Wave 1)
|
||||
//!
|
||||
//! 从 `adapters/catalog.yaml` 读取索引,再逐个读取 `tools/*.yaml`,
|
||||
//! 返回目录条目。仅消费 `id / name / name_zh / vendor / status` 五个字段。
|
||||
//! 从 `adapters/catalog.yaml` 读取索引,再逐个读取 `tools/*.yaml`,对每个工具
|
||||
//! 做完整 schema 校验(含危险命令拒载、版本号校验),最后返回 UI 侧的五字段
|
||||
//! `CatalogEntry` 视图(`load_catalog`)或完整 `Adapter`(`load_adapters`)。
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
@@ -9,6 +10,7 @@ use std::path::Path;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::AdapterError;
|
||||
use crate::schema::{Adapter, parse_adapter};
|
||||
|
||||
/// 目录索引(adapters/catalog.yaml)
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
@@ -24,7 +26,7 @@ pub struct CatalogRef {
|
||||
pub file: String,
|
||||
}
|
||||
|
||||
/// 目录条目(占位 schema,Wave 0 仅五字段;完整字段见架构 §3.1)
|
||||
/// 目录条目(五字段视图,供 UI 展示)
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
pub struct CatalogEntry {
|
||||
pub id: String,
|
||||
@@ -36,40 +38,58 @@ pub struct CatalogEntry {
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
/// 加载目录索引与全部工具占位 YAML
|
||||
pub fn load_catalog<P: AsRef<Path>>(adapters_dir: P) -> Result<Vec<CatalogEntry>, AdapterError> {
|
||||
impl From<Adapter> for CatalogEntry {
|
||||
fn from(a: Adapter) -> Self {
|
||||
CatalogEntry {
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
name_zh: a.name_zh,
|
||||
vendor: a.vendor,
|
||||
status: a.status,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取目录索引。
|
||||
fn read_index<P: AsRef<Path>>(adapters_dir: P) -> Result<Catalog, AdapterError> {
|
||||
let dir = adapters_dir.as_ref();
|
||||
let catalog_text = fs::read_to_string(dir.join("catalog.yaml"))
|
||||
.map_err(|e| AdapterError::Io(format!("读取 catalog.yaml 失败: {e}")))?;
|
||||
let catalog: Catalog = serde_yaml::from_str(&catalog_text)
|
||||
.map_err(|e| AdapterError::Parse(format!("解析 catalog.yaml 失败: {e}")))?;
|
||||
serde_yaml::from_str(&catalog_text)
|
||||
.map_err(|e| AdapterError::Parse(format!("解析 catalog.yaml 失败: {e}")))
|
||||
}
|
||||
|
||||
let mut entries = Vec::with_capacity(catalog.tools.len());
|
||||
/// 加载并校验全部工具适配器(完整 schema)。
|
||||
pub fn load_adapters<P: AsRef<Path>>(adapters_dir: P) -> Result<Vec<Adapter>, AdapterError> {
|
||||
let dir = adapters_dir.as_ref();
|
||||
let catalog = read_index(dir)?;
|
||||
|
||||
let mut adapters = Vec::with_capacity(catalog.tools.len());
|
||||
for r in &catalog.tools {
|
||||
let text = fs::read_to_string(dir.join(&r.file))
|
||||
.map_err(|e| AdapterError::Io(format!("读取 {} 失败: {e}", r.file)))?;
|
||||
let entry: CatalogEntry = serde_yaml::from_str(&text)
|
||||
.map_err(|e| AdapterError::Parse(format!("解析 {} 失败: {e}", r.file)))?;
|
||||
if entry.id != r.id {
|
||||
return Err(AdapterError::Parse(format!(
|
||||
let adapter = parse_adapter(&text)
|
||||
.map_err(|e| AdapterError::Parse(format!("{} 校验未通过: {e}", r.file)))?;
|
||||
if adapter.id != r.id {
|
||||
return Err(AdapterError::Validation(format!(
|
||||
"索引 id 与文件内 id 不一致: 索引={} 文件={}",
|
||||
r.id, entry.id
|
||||
r.id, adapter.id
|
||||
)));
|
||||
}
|
||||
if entry.status != "available" && entry.status != "watch" {
|
||||
return Err(AdapterError::Parse(format!(
|
||||
"{} 的 status 非法: {}(应为 available | watch)",
|
||||
entry.id, entry.status
|
||||
)));
|
||||
}
|
||||
entries.push(entry);
|
||||
adapters.push(adapter);
|
||||
}
|
||||
Ok(entries)
|
||||
Ok(adapters)
|
||||
}
|
||||
|
||||
/// 加载目录并返回 UI 五字段视图(内部已做完整 schema 校验)。
|
||||
pub fn load_catalog<P: AsRef<Path>>(adapters_dir: P) -> Result<Vec<CatalogEntry>, AdapterError> {
|
||||
Ok(load_adapters(adapters_dir)?.into_iter().map(CatalogEntry::from).collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::error::AdapterError;
|
||||
|
||||
#[test]
|
||||
fn parses_minimal_tool_yaml() {
|
||||
@@ -81,19 +101,11 @@ mod tests {
|
||||
assert_eq!(entry.status, "available");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_status() {
|
||||
let yaml = "id: x\nname: X\nname_zh: X\nvendor: V\nstatus: unknown\n";
|
||||
let entry: Result<CatalogEntry, _> = serde_yaml::from_str(yaml);
|
||||
// 解析本身成功,非法 status 由 load_catalog 校验;此处确认 schema 字段可读
|
||||
assert!(entry.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_real_catalog_from_repo() {
|
||||
// 以真实 adapters/ 目录做集成测试(相对本 crate 位于 ../../adapters)
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../adapters");
|
||||
let entries = load_catalog(&dir).expect("真实目录应可加载");
|
||||
let entries = load_catalog(&dir).expect("真实目录应可加载并通过校验");
|
||||
assert_eq!(entries.len(), 14, "第一批应为 14 个工具");
|
||||
for e in &entries {
|
||||
assert!(!e.id.is_empty());
|
||||
@@ -107,4 +119,50 @@ mod tests {
|
||||
assert!(ids.contains(&want), "目录应包含 {want}");
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载器拒载危险适配器(含 shell 元字符)并给中文错误。
|
||||
#[test]
|
||||
fn loader_rejects_dangerous_adapter() {
|
||||
let dir = std::env::temp_dir().join(format!("agentdock-adapters-danger-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(dir.join("tools")).unwrap();
|
||||
fs::write(
|
||||
dir.join("catalog.yaml"),
|
||||
"catalog_version: 1\ntools:\n - id: evil\n file: tools/evil.yaml\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
dir.join("tools/evil.yaml"),
|
||||
"id: evil\nname: Evil\nname_zh: Evil\nvendor: X\nstatus: available\ninstall:\n channels:\n - id: official_script\n command: [\"curl\", \"x | sh\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
match load_adapters(&dir) {
|
||||
Err(AdapterError::Parse(m)) => assert!(m.contains('|'), "错误应含元字符: {m}"),
|
||||
other => panic!("应因危险命令拒载,实际 {other:?}"),
|
||||
}
|
||||
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// 加载器校验版本号:非法 adapter_version 拒载。
|
||||
#[test]
|
||||
fn loader_rejects_bad_version() {
|
||||
let dir = std::env::temp_dir().join(format!("agentdock-adapters-ver-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(dir.join("tools")).unwrap();
|
||||
fs::write(
|
||||
dir.join("catalog.yaml"),
|
||||
"catalog_version: 1\ntools:\n - id: bad\n file: tools/bad.yaml\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
dir.join("tools/bad.yaml"),
|
||||
"id: bad\nname: Bad\nname_zh: Bad\nvendor: X\nstatus: available\nadapter_version: not-semver\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(load_adapters(&dir), Err(AdapterError::Parse(_))));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
//! 危险命令检测(架构 §4.2)
|
||||
//!
|
||||
//! 默认禁止 shell 拼接:管道、重定向、`$()`、反引号、`&&` 链、`;`、`\`。
|
||||
//! 适配器声明里的所有 `command` argv 都必须在加载期通过本检测,
|
||||
//! 否则整条适配器拒载并给出中文错误。
|
||||
|
||||
/// shell 元字符集合(架构 §4.2;`(` `)` 覆盖 `$()`,反引号覆盖命令替换)
|
||||
const SHELL_METACHARS: &[char] = &['|', '&', ';', '$', '\\', '>', '<', '(', ')', '`'];
|
||||
|
||||
/// 返回第一个命中的 shell 元字符;无则返回 None。
|
||||
pub fn first_shell_metachar(s: &str) -> Option<char> {
|
||||
s.chars().find(|c| SHELL_METACHARS.contains(c))
|
||||
}
|
||||
|
||||
/// 判断一个字符串是否含 shell 元字符。
|
||||
pub fn contains_shell_metachar(s: &str) -> bool {
|
||||
first_shell_metachar(s).is_some()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn detects_common_metachars() {
|
||||
for (sample, expected) in [
|
||||
("curl | sh", Some('|')),
|
||||
("a && b", Some('&')),
|
||||
("a; rm -rf /", Some(';')),
|
||||
("$(id)", Some('$')),
|
||||
("echo `whoami`", Some('`')),
|
||||
("ls > out", Some('>')),
|
||||
("cat < in", Some('<')),
|
||||
("a\\b", Some('\\')),
|
||||
("echo (x)", Some('(')),
|
||||
("echo )", Some(')')),
|
||||
] {
|
||||
assert_eq!(first_shell_metachar(sample), expected, "样本 {sample:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_plain_argv() {
|
||||
for sample in [
|
||||
"npm",
|
||||
"install",
|
||||
"-g",
|
||||
"@openai/codex",
|
||||
"codex",
|
||||
"--version",
|
||||
"https://example.com/install.ps1",
|
||||
] {
|
||||
assert!(!contains_shell_metachar(sample), "普通参数 {sample:?} 不应被判危险");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,12 @@ use std::fmt;
|
||||
pub enum AdapterError {
|
||||
Io(String),
|
||||
Parse(String),
|
||||
/// schema / 语义校验失败(中文)
|
||||
Validation(String),
|
||||
/// 命令含 shell 元字符等危险输入(中文)
|
||||
DangerousCommand(String),
|
||||
/// 尚未实现的能力(Wave 2 起逐波落地)
|
||||
NotImplemented(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for AdapterError {
|
||||
@@ -13,6 +19,9 @@ impl fmt::Display for AdapterError {
|
||||
match self {
|
||||
AdapterError::Io(m) => write!(f, "IO: {m}"),
|
||||
AdapterError::Parse(m) => write!(f, "Parse: {m}"),
|
||||
AdapterError::Validation(m) => write!(f, "校验失败: {m}"),
|
||||
AdapterError::DangerousCommand(m) => write!(f, "危险命令: {m}"),
|
||||
AdapterError::NotImplemented(m) => write!(f, "未实现: {m}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
//! 统一执行器接口与 dry-run(架构 §3.2)
|
||||
//!
|
||||
//! `AdapterExecutor` 是各适配器的统一操作面;本波只落地骨架与
|
||||
//! `preview_action`(返回 `DryRunPlan`),真实安装/检测/配置/授权/诊断
|
||||
//! 命令的执行在 Wave 2 起由具体实现补全。所有 command 一律走 `agentdock-exec`
|
||||
//! 的 argv 数组执行,禁止 shell 拼接。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::AdapterError;
|
||||
use crate::schema::Adapter;
|
||||
|
||||
/// 动作类型(对应 IPC 契约 previewAction/runAction 的 action)
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AdapterAction {
|
||||
Install,
|
||||
Update,
|
||||
Uninstall,
|
||||
WriteConfig,
|
||||
Authorize,
|
||||
Repair,
|
||||
}
|
||||
|
||||
impl AdapterAction {
|
||||
/// 动作的中文名(用于 dry-run 说明与 UI)
|
||||
pub fn label_zh(&self) -> &'static str {
|
||||
match self {
|
||||
AdapterAction::Install => "安装",
|
||||
AdapterAction::Update => "更新",
|
||||
AdapterAction::Uninstall => "卸载",
|
||||
AdapterAction::WriteConfig => "写配置",
|
||||
AdapterAction::Authorize => "授权",
|
||||
AdapterAction::Repair => "修复",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 干燥运行计划:命令 argv、权限、影响文件、回滚说明(架构 §3.2 dry_run)
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct DryRunPlan {
|
||||
/// 将执行的命令(argv 数组,禁止 shell)
|
||||
pub commands: Vec<Vec<String>>,
|
||||
/// 是否需要权限提升
|
||||
pub elevate: bool,
|
||||
/// 权限提升说明(中文),需要提升时必填
|
||||
pub elevate_reason_zh: Option<String>,
|
||||
/// 将影响/写入的文件路径
|
||||
pub affected_files: Vec<String>,
|
||||
/// 回滚说明(中文)
|
||||
pub rollback_zh: String,
|
||||
}
|
||||
|
||||
/// 统一执行器接口(架构 §3.2)
|
||||
///
|
||||
/// 除 `dry_run` 外,其余方法为骨架:默认返回 `NotImplemented`,由 Wave 2 起
|
||||
/// 各适配器实现补全。任何实现都不得绕过 `agentdock-exec` 拼接 shell。
|
||||
pub trait AdapterExecutor {
|
||||
/// 返回适配器定义
|
||||
fn adapter(&self) -> &Adapter;
|
||||
|
||||
/// 干燥运行:不真正执行,返回将执行的命令与影响面
|
||||
fn dry_run(&self, action: AdapterAction) -> Result<DryRunPlan, AdapterError> {
|
||||
preview_action(self.adapter(), action)
|
||||
}
|
||||
|
||||
fn detect(&self) -> Result<(), AdapterError> {
|
||||
Err(AdapterError::NotImplemented("detect 自 Wave 2 起实现".into()))
|
||||
}
|
||||
|
||||
fn install(&self) -> Result<(), AdapterError> {
|
||||
Err(AdapterError::NotImplemented("install 自 Wave 2 起实现".into()))
|
||||
}
|
||||
|
||||
fn update(&self) -> Result<(), AdapterError> {
|
||||
Err(AdapterError::NotImplemented("update 自 Wave 2 起实现".into()))
|
||||
}
|
||||
|
||||
fn uninstall(&self) -> Result<(), AdapterError> {
|
||||
Err(AdapterError::NotImplemented("uninstall 自 Wave 2 起实现".into()))
|
||||
}
|
||||
|
||||
fn read_config(&self) -> Result<(), AdapterError> {
|
||||
Err(AdapterError::NotImplemented("read_config 自 Wave 2 起实现".into()))
|
||||
}
|
||||
|
||||
fn write_config(&self, _patch: &str) -> Result<(), AdapterError> {
|
||||
Err(AdapterError::NotImplemented("write_config 自 Wave 2 起实现".into()))
|
||||
}
|
||||
|
||||
fn authorization_status(&self) -> Result<(), AdapterError> {
|
||||
Err(AdapterError::NotImplemented("authorization_status 自 Wave 2 起实现".into()))
|
||||
}
|
||||
|
||||
fn authorize(&self, _mode: &str) -> Result<(), AdapterError> {
|
||||
Err(AdapterError::NotImplemented("authorize 自 Wave 2 起实现".into()))
|
||||
}
|
||||
|
||||
fn diagnose(&self) -> Result<(), AdapterError> {
|
||||
Err(AdapterError::NotImplemented("diagnose 自 Wave 2 起实现".into()))
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据适配器声明 + 动作生成干燥运行计划(不执行)。
|
||||
pub fn preview_action(adapter: &Adapter, action: AdapterAction) -> Result<DryRunPlan, AdapterError> {
|
||||
let mut commands: Vec<Vec<String>> = Vec::new();
|
||||
let mut elevate = false;
|
||||
let mut elevate_reason_zh: Option<String> = None;
|
||||
let mut affected_files: Vec<String> = Vec::new();
|
||||
|
||||
// 影响文件:配置文件路径(写配置/授权/修复都会触碰)
|
||||
if let Some(cfg) = &adapter.configuration {
|
||||
for f in &cfg.files {
|
||||
affected_files.push(f.path.clone());
|
||||
}
|
||||
}
|
||||
|
||||
match action {
|
||||
AdapterAction::Install => {
|
||||
if let Some(install) = &adapter.install {
|
||||
if let Some(channel) = pick_channel(install) {
|
||||
if !channel.command.is_empty() {
|
||||
commands.push(channel.command.clone());
|
||||
}
|
||||
elevate = channel_elevates(channel);
|
||||
elevate_reason_zh = channel.elevate_reason_zh.clone();
|
||||
if let Some(script) = &channel.script {
|
||||
if let Some(url) = &script.url {
|
||||
affected_files.push(format!("下载脚本: {url}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AdapterAction::Update => {
|
||||
if let Some(update) = &adapter.update {
|
||||
if !update.command.is_empty() {
|
||||
commands.push(update.command.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
AdapterAction::Uninstall => {
|
||||
if let Some(uninstall) = &adapter.uninstall {
|
||||
if !uninstall.command.is_empty() {
|
||||
commands.push(uninstall.command.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
AdapterAction::Authorize => {
|
||||
if let Some(auth) = &adapter.authorization {
|
||||
if let Some(mode) = auth.modes.first() {
|
||||
if !mode.command.is_empty() {
|
||||
commands.push(mode.command.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AdapterAction::WriteConfig => {
|
||||
// 写配置无外部命令,仅落盘配置文件
|
||||
}
|
||||
AdapterAction::Repair => {
|
||||
// 修复由诊断规则驱动,本波仅占位
|
||||
}
|
||||
}
|
||||
|
||||
let rollback_zh = rollback_note(adapter, action);
|
||||
|
||||
Ok(DryRunPlan {
|
||||
commands,
|
||||
elevate,
|
||||
elevate_reason_zh,
|
||||
affected_files,
|
||||
rollback_zh,
|
||||
})
|
||||
}
|
||||
|
||||
/// 取首选渠道,无 preferred 时回退到第一个声明了命令的渠道。
|
||||
fn pick_channel(install: &crate::schema::Install) -> Option<&crate::schema::Channel> {
|
||||
if let Some(preferred) = &install.preferred {
|
||||
if let Some(ch) = install.channels.iter().find(|c| &c.id == preferred) {
|
||||
return Some(ch);
|
||||
}
|
||||
}
|
||||
install.channels.first()
|
||||
}
|
||||
|
||||
/// 渠道是否需要权限提升(never → false,其余 true)。
|
||||
fn channel_elevates(channel: &crate::schema::Channel) -> bool {
|
||||
matches!(channel.elevate.as_deref(), Some("if_needed") | Some("required"))
|
||||
}
|
||||
|
||||
/// 生成中文回滚说明。
|
||||
fn rollback_note(adapter: &Adapter, action: AdapterAction) -> String {
|
||||
match action {
|
||||
AdapterAction::Install | AdapterAction::Update => {
|
||||
format!("如需回退,可重新运行卸载({});已保留原配置文件不动。", adapter.id)
|
||||
}
|
||||
AdapterAction::Uninstall => {
|
||||
format!("卸载默认保留配置文件({});如需彻底移除请手动删除配置文件。", adapter.id)
|
||||
}
|
||||
AdapterAction::WriteConfig => {
|
||||
format!("写配置前会自动备份原文件为 .bak.<时间戳>,可随时恢复。")
|
||||
}
|
||||
AdapterAction::Authorize => {
|
||||
format!("授权信息仅写入系统密钥库,不落盘;如需撤销可删除对应密钥条目。")
|
||||
}
|
||||
AdapterAction::Repair => {
|
||||
format!("修复动作前会生成干燥运行计划,确认后才执行。")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::schema::parse_adapter;
|
||||
|
||||
#[test]
|
||||
fn preview_install_builds_argv_plan() {
|
||||
let adapter = parse_adapter(
|
||||
r#"
|
||||
id: claude-code
|
||||
name: Claude Code
|
||||
name_zh: Claude Code
|
||||
vendor: Anthropic
|
||||
status: available
|
||||
install:
|
||||
preferred: npm
|
||||
channels:
|
||||
- id: npm
|
||||
platforms: [windows]
|
||||
command: [npm, install, -g, "@anthropic-ai/claude-code"]
|
||||
elevate: never
|
||||
configuration:
|
||||
files:
|
||||
- path: "~/.claude/settings.json"
|
||||
format: json
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let plan = preview_action(&adapter, AdapterAction::Install).unwrap();
|
||||
assert_eq!(plan.commands, vec![vec!["npm", "install", "-g", "@anthropic-ai/claude-code"]]);
|
||||
assert!(!plan.elevate);
|
||||
assert!(plan.affected_files.iter().any(|f| f.contains("settings.json")));
|
||||
assert!(!plan.rollback_zh.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_elevates_when_required() {
|
||||
let adapter = parse_adapter(
|
||||
r#"
|
||||
id: crush
|
||||
name: Crush
|
||||
name_zh: Crush
|
||||
vendor: Charm
|
||||
status: available
|
||||
install:
|
||||
preferred: apt
|
||||
channels:
|
||||
- id: apt
|
||||
command: [apt, install, crush]
|
||||
elevate: required
|
||||
elevate_reason_zh: "需要管理员权限写入系统目录"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let plan = preview_action(&adapter, AdapterAction::Install).unwrap();
|
||||
assert!(plan.elevate);
|
||||
assert_eq!(plan.elevate_reason_zh.as_deref(), Some("需要管理员权限写入系统目录"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn action_label_zh_is_chinese() {
|
||||
assert_eq!(AdapterAction::Install.label_zh(), "安装");
|
||||
assert_eq!(AdapterAction::WriteConfig.label_zh(), "写配置");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wave2_methods_return_not_implemented() {
|
||||
let adapter = parse_adapter("id: x\nname: X\nname_zh: X\nvendor: V\nstatus: available\n").unwrap();
|
||||
struct NoopExecutor(Adapter);
|
||||
impl AdapterExecutor for NoopExecutor {
|
||||
fn adapter(&self) -> &Adapter {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
let ex = NoopExecutor(adapter);
|
||||
assert!(matches!(ex.detect(), Err(AdapterError::NotImplemented(_))));
|
||||
assert!(matches!(ex.install(), Err(AdapterError::NotImplemented(_))));
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,17 @@
|
||||
//! agentdock-adapter —— 适配器层
|
||||
//!
|
||||
//! 负责适配器 schema 加载、版本校验、dry-run 与执行器接口(架构 §3)。
|
||||
//! Wave 0:仅落地目录索引与占位条目加载(`catalog` 模块);
|
||||
//! 完整 schema 校验、执行器接口随 Wave 1 实现。
|
||||
//! Wave 1:完整 schema 定义与校验(含危险命令拒载)、统一执行器骨架、
|
||||
//! dry-run 计划(`preview_action`)。真实安装/检测/配置命令 Wave 2 起落地。
|
||||
|
||||
pub mod catalog;
|
||||
pub mod danger;
|
||||
pub mod error;
|
||||
pub mod executor;
|
||||
pub mod schema;
|
||||
|
||||
pub use catalog::{Catalog, CatalogEntry, CatalogRef, load_catalog};
|
||||
pub use catalog::{Catalog, CatalogEntry, CatalogRef, load_adapters, load_catalog};
|
||||
pub use danger::{contains_shell_metachar, first_shell_metachar};
|
||||
pub use error::AdapterError;
|
||||
pub use executor::{AdapterAction, AdapterExecutor, DryRunPlan, preview_action};
|
||||
pub use schema::{Adapter, parse_adapter};
|
||||
|
||||
@@ -0,0 +1,557 @@
|
||||
//! 适配器完整 schema(对齐架构 §3.1)
|
||||
//!
|
||||
//! 与 `adapters/schema/adapter.schema.json` 同源:JSON Schema 是声明式契约,
|
||||
//! 本文件用 Rust 强类型结构体做运行时校验(deny_unknown_fields + 语义校验),
|
||||
//! 两者字段一一对应。Wave 1 的 14 个占位 YAML 仅填五字段,其余字段均可选。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::danger::first_shell_metachar;
|
||||
use crate::error::AdapterError;
|
||||
|
||||
/// 适配器定义(完整字段,§3.1)
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Adapter {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(rename = "name_zh")]
|
||||
pub name_zh: String,
|
||||
pub vendor: String,
|
||||
/// available | watch
|
||||
pub status: String,
|
||||
/// semver,如 1.2.0
|
||||
#[serde(rename = "adapter_version", default)]
|
||||
pub adapter_version: Option<String>,
|
||||
#[serde(default)]
|
||||
pub license: Option<String>,
|
||||
#[serde(default)]
|
||||
pub platforms: Option<Platforms>,
|
||||
#[serde(default)]
|
||||
pub official: Option<Official>,
|
||||
#[serde(rename = "runtime_deps", default)]
|
||||
pub runtime_deps: Vec<RuntimeDep>,
|
||||
#[serde(default)]
|
||||
pub install: Option<Install>,
|
||||
#[serde(default)]
|
||||
pub detect: Option<Detect>,
|
||||
#[serde(default)]
|
||||
pub update: Option<Update>,
|
||||
#[serde(default)]
|
||||
pub uninstall: Option<Uninstall>,
|
||||
#[serde(default)]
|
||||
pub authorization: Option<Authorization>,
|
||||
#[serde(default)]
|
||||
pub configuration: Option<Configuration>,
|
||||
#[serde(default)]
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
#[serde(default)]
|
||||
pub documentation: Option<Documentation>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Platforms {
|
||||
#[serde(default)]
|
||||
pub windows: Option<PlatformWindows>,
|
||||
#[serde(default)]
|
||||
pub linux: Option<PlatformLinux>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PlatformWindows {
|
||||
#[serde(default)]
|
||||
pub architectures: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PlatformLinux {
|
||||
#[serde(default)]
|
||||
pub distributions: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub architectures: Vec<String>,
|
||||
#[serde(rename = "min_ubuntu", default)]
|
||||
pub min_ubuntu: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Official {
|
||||
#[serde(default)]
|
||||
pub homepage: Option<String>,
|
||||
#[serde(default)]
|
||||
pub docs: Option<String>,
|
||||
#[serde(rename = "allowed_hosts", default)]
|
||||
pub allowed_hosts: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RuntimeDep {
|
||||
pub id: String,
|
||||
#[serde(rename = "semver_range", default)]
|
||||
pub semver_range: Option<String>,
|
||||
#[serde(rename = "required_for", default)]
|
||||
pub required_for: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Install {
|
||||
#[serde(default)]
|
||||
pub preferred: Option<String>,
|
||||
#[serde(default)]
|
||||
pub channels: Vec<Channel>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Channel {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub platforms: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub command: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub script: Option<Script>,
|
||||
#[serde(default)]
|
||||
pub package: Option<String>,
|
||||
#[serde(default)]
|
||||
pub elevate: Option<String>,
|
||||
#[serde(rename = "elevate_reason_zh", default)]
|
||||
pub elevate_reason_zh: Option<String>,
|
||||
#[serde(rename = "post_checks", default)]
|
||||
pub post_checks: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Script {
|
||||
#[serde(default)]
|
||||
pub url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub kind: Option<String>,
|
||||
#[serde(default)]
|
||||
pub integrity: Option<Integrity>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Integrity {
|
||||
#[serde(default)]
|
||||
pub sha256: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Detect {
|
||||
pub executable: String,
|
||||
#[serde(rename = "version_args", default)]
|
||||
pub version_args: Vec<String>,
|
||||
#[serde(rename = "version_regex", default)]
|
||||
pub version_regex: Option<String>,
|
||||
#[serde(rename = "version_unconfirmed", default)]
|
||||
pub version_unconfirmed: Option<bool>,
|
||||
#[serde(rename = "path_hints", default)]
|
||||
pub path_hints: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Update {
|
||||
#[serde(default)]
|
||||
pub method: Option<String>,
|
||||
#[serde(default)]
|
||||
pub command: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Uninstall {
|
||||
#[serde(default)]
|
||||
pub method: Option<String>,
|
||||
#[serde(default)]
|
||||
pub command: Vec<String>,
|
||||
#[serde(rename = "keep_config_default", default = "default_true")]
|
||||
pub keep_config_default: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Authorization {
|
||||
#[serde(default)]
|
||||
pub modes: Vec<AuthMode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AuthMode {
|
||||
pub mode: String,
|
||||
#[serde(default)]
|
||||
pub command: Vec<String>,
|
||||
#[serde(rename = "env_keys", default)]
|
||||
pub env_keys: Vec<String>,
|
||||
#[serde(rename = "status_command", default)]
|
||||
pub status_command: Vec<String>,
|
||||
#[serde(rename = "notes_zh", default)]
|
||||
pub notes_zh: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Configuration {
|
||||
#[serde(default)]
|
||||
pub files: Vec<ConfigFile>,
|
||||
#[serde(default)]
|
||||
pub environment: Vec<EnvMapping>,
|
||||
#[serde(default)]
|
||||
pub fields: Vec<ConfigField>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ConfigFile {
|
||||
pub path: String,
|
||||
pub format: String,
|
||||
#[serde(default)]
|
||||
pub scope: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct EnvMapping {
|
||||
pub key: String,
|
||||
#[serde(default)]
|
||||
pub sensitive: bool,
|
||||
#[serde(rename = "maps_to_field", default)]
|
||||
pub maps_to_field: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ConfigField {
|
||||
pub id: String,
|
||||
#[serde(rename = "label_zh")]
|
||||
pub label_zh: String,
|
||||
#[serde(rename = "help_zh", default)]
|
||||
pub help_zh: Option<String>,
|
||||
#[serde(default)]
|
||||
pub required: bool,
|
||||
#[serde(default)]
|
||||
pub sensitive: bool,
|
||||
/// string | url | enum | bool
|
||||
#[serde(rename = "type")]
|
||||
pub field_type: String,
|
||||
/// file | env | keyring
|
||||
pub storage: String,
|
||||
#[serde(default)]
|
||||
pub platforms: Vec<String>,
|
||||
#[serde(rename = "docs_url", default)]
|
||||
pub docs_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Diagnostic {
|
||||
#[serde(rename = "rule_id")]
|
||||
pub rule_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Documentation {
|
||||
#[serde(rename = "quickstart_zh", default)]
|
||||
pub quickstart_zh: Option<String>,
|
||||
#[serde(default)]
|
||||
pub commands: Vec<DocCommand>,
|
||||
#[serde(rename = "updated_at", default)]
|
||||
pub updated_at: Option<String>,
|
||||
#[serde(rename = "risks_zh", default)]
|
||||
pub risks_zh: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DocCommand {
|
||||
pub cmd: String,
|
||||
#[serde(rename = "desc_zh", default)]
|
||||
pub desc_zh: Option<String>,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// 校验 id 是否符合稳定 ID 约定:`^[a-z0-9][a-z0-9-]*$`
|
||||
pub fn is_valid_id(id: &str) -> bool {
|
||||
let mut chars = id.chars();
|
||||
match chars.next() {
|
||||
Some(c) if c.is_ascii_lowercase() || c.is_ascii_digit() => {}
|
||||
_ => return false,
|
||||
}
|
||||
chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
|
||||
}
|
||||
|
||||
/// 校验 semver 形式(x.y.z,可选 -prerelease / +build),不引入 semver 依赖。
|
||||
pub fn is_valid_semver(s: &str) -> bool {
|
||||
let s = s.trim();
|
||||
if s.is_empty() {
|
||||
return false;
|
||||
}
|
||||
// 拆分 build 元数据(+...)
|
||||
let (no_build, build) = match s.split_once('+') {
|
||||
Some((a, b)) => (a, Some(b)),
|
||||
None => (s, None),
|
||||
};
|
||||
if let Some(b) = build {
|
||||
if !is_semver_ident(b) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// 拆分预发布(-...)
|
||||
let (core, pre) = match no_build.split_once('-') {
|
||||
Some((a, b)) => (a, Some(b)),
|
||||
None => (no_build, None),
|
||||
};
|
||||
let parts: Vec<&str> = core.split('.').collect();
|
||||
if parts.len() != 3 {
|
||||
return false;
|
||||
}
|
||||
for p in &parts {
|
||||
if p.is_empty() || !p.chars().all(|c| c.is_ascii_digit()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(pre) = pre {
|
||||
if !is_semver_ident(pre) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// semver 标识段(预发布/构建元数据):由字母数字与 `-` 组成,点分隔各段非空。
|
||||
fn is_semver_ident(s: &str) -> bool {
|
||||
if s.is_empty() {
|
||||
return false;
|
||||
}
|
||||
s.split('.').all(|seg| !seg.is_empty() && seg.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'))
|
||||
}
|
||||
|
||||
/// 从 YAML 文本解析并校验适配器(解析失败 → Parse,语义/危险命令 → 对应错误)。
|
||||
pub fn parse_adapter(yaml: &str) -> Result<Adapter, AdapterError> {
|
||||
let adapter: Adapter = serde_yaml::from_str(yaml).map_err(|e| AdapterError::Parse(e.to_string()))?;
|
||||
adapter.validate()?;
|
||||
Ok(adapter)
|
||||
}
|
||||
|
||||
impl Adapter {
|
||||
/// 语义校验:id / status / 版本号 / 危险命令。
|
||||
/// 校验通过返回 Ok(()),否则返回带中文说明的错误。
|
||||
pub fn validate(&self) -> Result<(), AdapterError> {
|
||||
if self.id.is_empty() {
|
||||
return Err(AdapterError::Validation("id 不能为空".into()));
|
||||
}
|
||||
if !is_valid_id(&self.id) {
|
||||
return Err(AdapterError::Validation(format!(
|
||||
"id「{}」非法:只能由小写字母、数字、连字符组成,且以字母或数字开头",
|
||||
self.id
|
||||
)));
|
||||
}
|
||||
if self.status != "available" && self.status != "watch" {
|
||||
return Err(AdapterError::Validation(format!(
|
||||
"「{}」的 status 非法: {}(应为 available | watch)",
|
||||
self.id, self.status
|
||||
)));
|
||||
}
|
||||
if let Some(v) = &self.adapter_version {
|
||||
if !is_valid_semver(v) {
|
||||
return Err(AdapterError::Validation(format!(
|
||||
"「{}」的 adapter_version 非法: {}(应为 semver,如 1.2.0)",
|
||||
self.id, v
|
||||
)));
|
||||
}
|
||||
}
|
||||
// 危险命令检查:所有 command argv 必须不含 shell 元字符
|
||||
if let Some(offender) = self.find_dangerous_command() {
|
||||
return Err(AdapterError::DangerousCommand(format!(
|
||||
"适配器「{}」声明了含 shell 元字符的命令参数 {:?},已拒绝加载(禁止管道/重定向/命令替换等)",
|
||||
self.id, offender
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 遍历所有 command 数组,返回首个含 shell 元字符的参数。
|
||||
pub fn find_dangerous_command(&self) -> Option<String> {
|
||||
let mut commands: Vec<&Vec<String>> = Vec::new();
|
||||
if let Some(install) = &self.install {
|
||||
for ch in &install.channels {
|
||||
commands.push(&ch.command);
|
||||
}
|
||||
}
|
||||
if let Some(update) = &self.update {
|
||||
commands.push(&update.command);
|
||||
}
|
||||
if let Some(uninstall) = &self.uninstall {
|
||||
commands.push(&uninstall.command);
|
||||
}
|
||||
if let Some(auth) = &self.authorization {
|
||||
for m in &auth.modes {
|
||||
commands.push(&m.command);
|
||||
commands.push(&m.status_command);
|
||||
}
|
||||
}
|
||||
for argv in commands {
|
||||
for arg in argv {
|
||||
if let Some(c) = first_shell_metachar(arg) {
|
||||
return Some(format!("{arg}(元字符 {c:?})"));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse(yaml: &str) -> Result<Adapter, AdapterError> {
|
||||
let adapter: Adapter = serde_yaml::from_str(yaml).map_err(|e| AdapterError::Parse(e.to_string()))?;
|
||||
adapter.validate()?;
|
||||
Ok(adapter)
|
||||
}
|
||||
|
||||
// ---- 合法 fixture(≥3) ----
|
||||
|
||||
#[test]
|
||||
fn valid_minimal_five_fields() {
|
||||
let yaml = "id: codex\nname: Codex CLI\nname_zh: Codex CLI\nvendor: OpenAI\nstatus: available\n";
|
||||
assert!(parse(yaml).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_full_schema() {
|
||||
let yaml = r#"
|
||||
id: claude-code
|
||||
name: Claude Code
|
||||
name_zh: Claude Code
|
||||
vendor: Anthropic
|
||||
status: available
|
||||
adapter_version: 1.2.0
|
||||
license: 专有(仅官方渠道安装、不重打包)
|
||||
platforms:
|
||||
windows:
|
||||
architectures: [x64]
|
||||
linux:
|
||||
distributions: [ubuntu]
|
||||
architectures: [x64]
|
||||
min_ubuntu: "22.04"
|
||||
official:
|
||||
homepage: https://claude.ai
|
||||
docs: https://docs.anthropic.com
|
||||
allowed_hosts: [claude.ai]
|
||||
runtime_deps:
|
||||
- id: node
|
||||
semver_range: ">=20"
|
||||
required_for: [install]
|
||||
install:
|
||||
preferred: npm
|
||||
channels:
|
||||
- id: npm
|
||||
platforms: [windows, linux]
|
||||
command: [npm, install, -g, "@anthropic-ai/claude-code"]
|
||||
elevate: never
|
||||
detect:
|
||||
executable: claude
|
||||
version_args: ["--version"]
|
||||
version_regex: "^v?(\\d+\\.\\d+\\.\\d+)"
|
||||
authorization:
|
||||
modes:
|
||||
- mode: browser_oauth
|
||||
notes_zh: 浏览器登录
|
||||
configuration:
|
||||
files:
|
||||
- path: "~/.claude/settings.json"
|
||||
format: json
|
||||
scope: user
|
||||
"#;
|
||||
let adapter = parse(yaml).expect("全字段 fixture 应合法");
|
||||
assert_eq!(adapter.adapter_version.as_deref(), Some("1.2.0"));
|
||||
assert_eq!(adapter.install.as_ref().unwrap().channels.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_pre_release_semver() {
|
||||
let yaml = "id: warp\nname: Warp Agent CLI\nname_zh: Warp Agent CLI\nvendor: Warp\nstatus: available\nadapter_version: 2.0.0-rc.1\n";
|
||||
assert!(parse(yaml).is_ok());
|
||||
}
|
||||
|
||||
// ---- 非法 fixture(≥3) ----
|
||||
|
||||
#[test]
|
||||
fn invalid_unknown_field_rejected() {
|
||||
let yaml = "id: x\nname: X\nname_zh: X\nvendor: V\nstatus: available\nbogus_field: 1\n";
|
||||
let adapter: Result<Adapter, _> = serde_yaml::from_str(yaml);
|
||||
assert!(adapter.is_err(), "未知字段应被 deny_unknown_fields 拒绝");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_id_pattern_rejected() {
|
||||
let yaml = "id: Bad_ID!\nname: X\nname_zh: X\nvendor: V\nstatus: available\n";
|
||||
match parse(yaml) {
|
||||
Err(AdapterError::Validation(m)) => assert!(m.contains("id"), "错误应指向 id: {m}"),
|
||||
other => panic!("应返回 Validation 错误,实际 {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_status_rejected() {
|
||||
let yaml = "id: x\nname: X\nname_zh: X\nvendor: V\nstatus: unknown\n";
|
||||
assert!(matches!(parse(yaml), Err(AdapterError::Validation(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_adapter_version_rejected() {
|
||||
let yaml = "id: x\nname: X\nname_zh: X\nvendor: V\nstatus: available\nadapter_version: not-a-version\n";
|
||||
assert!(matches!(parse(yaml), Err(AdapterError::Validation(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dangerous_command_rejected_with_chinese_error() {
|
||||
let yaml = r#"
|
||||
id: x
|
||||
name: X
|
||||
name_zh: X
|
||||
vendor: V
|
||||
status: available
|
||||
install:
|
||||
channels:
|
||||
- id: official_script
|
||||
command: ["curl", "https://x.sh", "|", "sh"]
|
||||
"#;
|
||||
match parse(yaml) {
|
||||
Err(AdapterError::DangerousCommand(m)) => {
|
||||
assert!(m.contains('|'), "错误信息应包含元字符: {m}");
|
||||
assert!(m.contains("已拒绝"), "错误应为中文且明确拒载: {m}");
|
||||
}
|
||||
other => panic!("应返回 DangerousCommand,实际 {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semver_helper() {
|
||||
assert!(is_valid_semver("1.2.0"));
|
||||
assert!(is_valid_semver("0.0.1"));
|
||||
assert!(is_valid_semver("10.20.30-alpha.1+build5"));
|
||||
assert!(!is_valid_semver("1.2"));
|
||||
assert!(!is_valid_semver("1.2.x"));
|
||||
assert!(!is_valid_semver(""));
|
||||
assert!(!is_valid_semver("v1.2.3"));
|
||||
}
|
||||
}
|
||||
@@ -3,5 +3,9 @@ name = "agentdock-config"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "配置读写:多格式编解码(TOML/JSON)+ 原子写入 + 自动备份(架构 §6)"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
toml = "0.8"
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
//! 原子写入 + 自动备份(架构 §6.1)
|
||||
//!
|
||||
//! 对目标文件 F:
|
||||
//! 1. F 存在 → 复制为 `F.bak.<时间戳>` 并返回备份路径;
|
||||
//! 2. 写临时文件 `F.tmp.<pid>`;
|
||||
//! 3. `rename` 覆盖(Windows 用 `MoveFileEx` 替换语义由 std 的 rename 处理,覆盖已存在文件);
|
||||
//! 4. 失败保留原文件与临时文件,错误上抛。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::error::ConfigError;
|
||||
|
||||
/// 生成唯一后缀(时间戳 + 进程 id)。
|
||||
fn suffix() -> String {
|
||||
let millis = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis())
|
||||
.unwrap_or(0);
|
||||
format!("{}.{}", millis, std::process::id())
|
||||
}
|
||||
|
||||
/// 计算备份路径:`F.bak.<时间戳>.<pid>`。
|
||||
pub fn backup_path(path: &Path) -> PathBuf {
|
||||
let mut name = path
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
name.push_str(".bak.");
|
||||
name.push_str(&suffix());
|
||||
path.with_file_name(name)
|
||||
}
|
||||
|
||||
/// 原子写文件:先备份已存在文件(返回备份路径),再 tmp + rename。
|
||||
pub fn atomic_write(path: &Path, content: &str) -> Result<Option<PathBuf>, ConfigError> {
|
||||
if let Some(parent) = path.parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| ConfigError::Io(format!("创建目录 {} 失败: {e}", parent.display())))?;
|
||||
}
|
||||
}
|
||||
|
||||
let backup = if path.exists() {
|
||||
let bak = backup_path(path);
|
||||
std::fs::copy(path, &bak)
|
||||
.map_err(|e| ConfigError::Io(format!("备份 {} 失败: {e}", path.display())))?;
|
||||
Some(bak)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let tmp = path.with_file_name(format!(
|
||||
"{}.tmp.{}",
|
||||
path.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_default(),
|
||||
std::process::id()
|
||||
));
|
||||
|
||||
std::fs::write(&tmp, content)
|
||||
.map_err(|e| ConfigError::Io(format!("写临时文件 {} 失败: {e}", tmp.display())))?;
|
||||
|
||||
// rename 覆盖已存在文件(Windows 上 std::fs::rename 对已存在目标会失败,
|
||||
// 先移除目标再 rename 以保证替换语义)。
|
||||
if path.exists() {
|
||||
std::fs::remove_file(path)
|
||||
.map_err(|e| ConfigError::Io(format!("移除旧文件 {} 失败: {e}", path.display())))?;
|
||||
}
|
||||
std::fs::rename(&tmp, path)
|
||||
.map_err(|e| ConfigError::Io(format!("替换文件 {} 失败: {e}", path.display())))?;
|
||||
|
||||
Ok(backup)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn atomic_write_creates_file_without_backup() {
|
||||
let dir = std::env::temp_dir().join(format!("agentdock-config-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let f = dir.join("new.toml");
|
||||
let backup = atomic_write(&f, "model = \"x\"\n").unwrap();
|
||||
assert!(backup.is_none());
|
||||
assert_eq!(std::fs::read_to_string(&f).unwrap(), "model = \"x\"\n");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_write_backs_up_existing_file() {
|
||||
let dir = std::env::temp_dir().join(format!("agentdock-config-bak-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let f = dir.join("config.toml");
|
||||
std::fs::write(&f, "old").unwrap();
|
||||
let backup = atomic_write(&f, "new").unwrap().expect("已存在文件应产生备份");
|
||||
assert!(backup.exists());
|
||||
assert_eq!(std::fs::read_to_string(&f).unwrap(), "new");
|
||||
assert_eq!(std::fs::read_to_string(&backup).unwrap(), "old");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
//! 配置格式与统一编解码(架构 §6.2 `ConfigCodec`)
|
||||
//!
|
||||
//! 统一以 `serde_json::Value` 作为内部表示(运行时单一真相)。TOML 通过
|
||||
//! `toml::Value` 桥接(双向转换),JSON/JSONC 直接用 serde_json。yaml / crushrc
|
||||
//! 属 Wave 3 工具,本波返回明确的「未实现」错误,不静默吞数据。
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::error::ConfigError;
|
||||
|
||||
/// 支持的配置格式。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ConfigFormat {
|
||||
Toml,
|
||||
Json,
|
||||
Jsonc,
|
||||
Yaml,
|
||||
Env,
|
||||
Crushrc,
|
||||
}
|
||||
|
||||
impl ConfigFormat {
|
||||
pub fn from_str(s: &str) -> ConfigFormat {
|
||||
match s {
|
||||
"toml" => ConfigFormat::Toml,
|
||||
"json" => ConfigFormat::Json,
|
||||
"jsonc" => ConfigFormat::Jsonc,
|
||||
"yaml" => ConfigFormat::Yaml,
|
||||
"env" => ConfigFormat::Env,
|
||||
"crushrc" => ConfigFormat::Crushrc,
|
||||
_ => ConfigFormat::Json,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析配置文本为内部表示。
|
||||
pub fn parse(format: ConfigFormat, text: &str) -> Result<Value, ConfigError> {
|
||||
match format {
|
||||
ConfigFormat::Json | ConfigFormat::Jsonc => {
|
||||
// jsonc:剥离 // 与 /* */ 注释后按 JSON 解析(不追求极致,覆盖常见注释)
|
||||
let text = strip_jsonc_comments(text);
|
||||
serde_json::from_str(&text).map_err(|e| ConfigError::Parse(e.to_string()))
|
||||
}
|
||||
ConfigFormat::Toml => {
|
||||
let tv: toml::Value = toml::from_str(text).map_err(|e| ConfigError::Parse(e.to_string()))?;
|
||||
Ok(toml_to_json(&tv))
|
||||
}
|
||||
ConfigFormat::Env => parse_env(text),
|
||||
ConfigFormat::Yaml | ConfigFormat::Crushrc => Err(ConfigError::Unsupported(format!(
|
||||
"{:?} 格式本波(Wave 2)未实现,将在后续波次接入",
|
||||
format
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// 序列化内部表示为配置文本。
|
||||
pub fn serialize(format: ConfigFormat, value: &Value) -> Result<String, ConfigError> {
|
||||
match format {
|
||||
ConfigFormat::Json | ConfigFormat::Jsonc => serde_json::to_string_pretty(value)
|
||||
.map_err(|e| ConfigError::Parse(e.to_string())),
|
||||
ConfigFormat::Toml => {
|
||||
let tv = json_to_toml(value);
|
||||
toml::to_string(&tv).map_err(|e| ConfigError::Parse(e.to_string()))
|
||||
}
|
||||
ConfigFormat::Env => serialize_env(value),
|
||||
ConfigFormat::Yaml | ConfigFormat::Crushrc => Err(ConfigError::Unsupported(format!(
|
||||
"{:?} 格式本波(Wave 2)未实现,将在后续波次接入",
|
||||
format
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// 按点分路径(如 `model` / `model.name` / `env.ANTHROPIC_API_KEY`)读取值。
|
||||
pub fn get_path<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
|
||||
if path.is_empty() {
|
||||
return Some(value);
|
||||
}
|
||||
let mut cur = value;
|
||||
for seg in path.split('.') {
|
||||
match cur {
|
||||
Value::Object(map) => cur = map.get(seg)?,
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
Some(cur)
|
||||
}
|
||||
|
||||
/// 按点分路径写入值,缺失的中间对象自动创建。
|
||||
pub fn set_path(value: &mut Value, path: &str, new: Value) -> Result<(), ConfigError> {
|
||||
let segs: Vec<&str> = path.split('.').filter(|s| !s.is_empty()).collect();
|
||||
if segs.is_empty() {
|
||||
return Err(ConfigError::InvalidPath("字段路径不能为空".into()));
|
||||
}
|
||||
set_path_impl(value, &segs, new)
|
||||
}
|
||||
|
||||
fn set_path_impl(value: &mut Value, segs: &[&str], new: Value) -> Result<(), ConfigError> {
|
||||
if !value.is_object() {
|
||||
*value = Value::Object(Map::new());
|
||||
}
|
||||
let map = value
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| ConfigError::InvalidPath("路径中间节点不是对象".into()))?;
|
||||
let seg = segs[0];
|
||||
if segs.len() == 1 {
|
||||
map.insert(seg.to_string(), new);
|
||||
return Ok(());
|
||||
}
|
||||
if !map.contains_key(seg) {
|
||||
map.insert(seg.to_string(), Value::Object(Map::new()));
|
||||
}
|
||||
let next = map.get_mut(seg).unwrap();
|
||||
set_path_impl(next, &segs[1..], new)
|
||||
}
|
||||
|
||||
// ---- TOML <-> JSON 双向桥接 ----
|
||||
|
||||
fn toml_to_json(v: &toml::Value) -> Value {
|
||||
match v {
|
||||
toml::Value::String(s) => Value::String(s.clone()),
|
||||
toml::Value::Integer(i) => Value::Number((*i).into()),
|
||||
toml::Value::Float(f) => serde_json::Number::from_f64(*f)
|
||||
.map(Value::Number)
|
||||
.unwrap_or(Value::Null),
|
||||
toml::Value::Boolean(b) => Value::Bool(*b),
|
||||
toml::Value::Datetime(d) => Value::String(d.to_string()),
|
||||
toml::Value::Array(a) => Value::Array(a.iter().map(toml_to_json).collect()),
|
||||
toml::Value::Table(t) => {
|
||||
let mut m = Map::new();
|
||||
for (k, v) in t {
|
||||
m.insert(k.clone(), toml_to_json(v));
|
||||
}
|
||||
Value::Object(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn json_to_toml(v: &Value) -> toml::Value {
|
||||
match v {
|
||||
Value::String(s) => toml::Value::String(s.clone()),
|
||||
Value::Number(n) => {
|
||||
if let Some(i) = n.as_i64() {
|
||||
toml::Value::Integer(i)
|
||||
} else if let Some(f) = n.as_f64() {
|
||||
toml::Value::Float(f)
|
||||
} else {
|
||||
toml::Value::String(n.to_string())
|
||||
}
|
||||
}
|
||||
Value::Bool(b) => toml::Value::Boolean(*b),
|
||||
Value::Array(a) => toml::Value::Array(a.iter().map(json_to_toml).collect()),
|
||||
Value::Object(m) => {
|
||||
let mut t = toml::map::Map::new();
|
||||
for (k, v) in m {
|
||||
t.insert(k.clone(), json_to_toml(v));
|
||||
}
|
||||
toml::Value::Table(t)
|
||||
}
|
||||
Value::Null => toml::Value::String(String::new()),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- env 格式(KEY=VALUE)----
|
||||
|
||||
fn parse_env(text: &str) -> Result<Value, ConfigError> {
|
||||
let mut m = Map::new();
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some((k, v)) = line.split_once('=') {
|
||||
let v = v.trim();
|
||||
let v = v.trim_matches('"').trim_matches('\'');
|
||||
m.insert(k.trim().to_string(), Value::String(v.to_string()));
|
||||
}
|
||||
}
|
||||
Ok(Value::Object(m))
|
||||
}
|
||||
|
||||
fn serialize_env(value: &Value) -> Result<String, ConfigError> {
|
||||
let obj = value.as_object().ok_or_else(|| {
|
||||
ConfigError::Parse("env 格式要求顶层为对象".into())
|
||||
})?;
|
||||
let mut out = String::new();
|
||||
for (k, v) in obj {
|
||||
if let Some(s) = v.as_str() {
|
||||
out.push_str(&format!("{k}={s}\n"));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// 剥离 JSONC 的 // 与 /* */ 注释(保守处理,不处理字符串内的注释序列)。
|
||||
fn strip_jsonc_comments(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let mut i = 0;
|
||||
let n = chars.len();
|
||||
let mut in_string = false;
|
||||
let mut escaped = false;
|
||||
while i < n {
|
||||
let c = chars[i];
|
||||
if in_string {
|
||||
out.push(c);
|
||||
if escaped {
|
||||
escaped = false;
|
||||
} else if c == '\\' {
|
||||
escaped = true;
|
||||
} else if c == '"' {
|
||||
in_string = false;
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if c == '"' {
|
||||
in_string = true;
|
||||
out.push(c);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if c == '/' && i + 1 < n && chars[i + 1] == '/' {
|
||||
while i < n && chars[i] != '\n' {
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if c == '/' && i + 1 < n && chars[i + 1] == '*' {
|
||||
i += 2;
|
||||
while i + 1 < n && !(chars[i] == '*' && chars[i + 1] == '/') {
|
||||
i += 1;
|
||||
}
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
out.push(c);
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn toml_roundtrip() {
|
||||
let text = "model = \"gpt-5\"\n[model_providers.proxy]\nbase_url = \"http://proxy\"\n";
|
||||
let v = parse(ConfigFormat::Toml, text).unwrap();
|
||||
assert_eq!(get_path(&v, "model").and_then(|x| x.as_str()), Some("gpt-5"));
|
||||
assert_eq!(
|
||||
get_path(&v, "model_providers.proxy.base_url").and_then(|x| x.as_str()),
|
||||
Some("http://proxy")
|
||||
);
|
||||
let out = serialize(ConfigFormat::Toml, &v).unwrap();
|
||||
assert!(out.contains("gpt-5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_roundtrip() {
|
||||
let text = r#"{ "model": "claude-sonnet", "env": { "ANTHROPIC_API_KEY": "x" } }"#;
|
||||
let v = parse(ConfigFormat::Json, text).unwrap();
|
||||
assert_eq!(get_path(&v, "model").and_then(|x| x.as_str()), Some("claude-sonnet"));
|
||||
let out = serialize(ConfigFormat::Json, &v).unwrap();
|
||||
assert!(out.contains("claude-sonnet"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsonc_strips_comments() {
|
||||
let text = "{\n // 注释\n \"model\": \"opencode\" /* 行尾 */\n}";
|
||||
let v = parse(ConfigFormat::Jsonc, text).unwrap();
|
||||
assert_eq!(get_path(&v, "model").and_then(|x| x.as_str()), Some("opencode"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_path_creates_nested() {
|
||||
let mut v = json!({});
|
||||
set_path(&mut v, "env.ANTHROPIC_BASE_URL", json!("https://proxy")).unwrap();
|
||||
set_path(&mut v, "model", json!("claude")).unwrap();
|
||||
assert_eq!(
|
||||
get_path(&v, "env.ANTHROPIC_BASE_URL").and_then(|x| x.as_str()),
|
||||
Some("https://proxy")
|
||||
);
|
||||
assert_eq!(get_path(&v, "model").and_then(|x| x.as_str()), Some("claude"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_format_is_explicit() {
|
||||
assert!(matches!(
|
||||
parse(ConfigFormat::Crushrc, "foo=bar"),
|
||||
Err(ConfigError::Unsupported(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_roundtrip() {
|
||||
let v = parse(ConfigFormat::Env, "# c\nGEMINI_API_KEY=\"sk-x\"\n").unwrap();
|
||||
assert_eq!(get_path(&v, "GEMINI_API_KEY").and_then(|x| x.as_str()), Some("sk-x"));
|
||||
let out = serialize(ConfigFormat::Env, &v).unwrap();
|
||||
assert!(out.contains("GEMINI_API_KEY=sk-x"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//! 配置层错误类型(中文,不含密钥明文)
|
||||
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ConfigError {
|
||||
Io(String),
|
||||
Parse(String),
|
||||
/// 不支持的配置格式(本波未实现)
|
||||
Unsupported(String),
|
||||
/// 字段路径非法
|
||||
InvalidPath(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for ConfigError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
ConfigError::Io(m) => write!(f, "IO: {m}"),
|
||||
ConfigError::Parse(m) => write!(f, "解析失败: {m}"),
|
||||
ConfigError::Unsupported(m) => write!(f, "不支持: {m}"),
|
||||
ConfigError::InvalidPath(m) => write!(f, "路径非法: {m}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ConfigError {}
|
||||
@@ -1,18 +1,66 @@
|
||||
//! agentdock-config —— 配置读写层(架构 §6)
|
||||
//!
|
||||
//! 职责:原子写入 + 自动备份、多格式统一编解码(ConfigCodec trait:
|
||||
//! toml / json / jsonc / yaml / env / crushrc)。
|
||||
//! Wave 0:空骨架,随 Wave 1/2 落地。
|
||||
//! 职责:多格式编解码(`codec`)、原子写入 + 自动备份(`atomic`)、
|
||||
//! 配置文件路径解析(`~` 与平台变量展开)。
|
||||
|
||||
/// 配置层能力标记(占位)
|
||||
pub const LAYER: &str = "agentdock-config";
|
||||
pub mod atomic;
|
||||
pub mod codec;
|
||||
pub mod error;
|
||||
|
||||
pub use atomic::{atomic_write, backup_path};
|
||||
pub use codec::{ConfigFormat, get_path, parse, serialize, set_path};
|
||||
pub use error::ConfigError;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// 解析适配器声明的配置文件路径:展开 `~`(用户主目录),
|
||||
/// 并把 Windows 路径分隔符统一处理(YAML 里写的是 `~/.codex/...`)。
|
||||
pub fn resolve_path(raw: &str) -> PathBuf {
|
||||
let expanded = expand_home(raw);
|
||||
PathBuf::from(expanded)
|
||||
}
|
||||
|
||||
/// 展开前导 `~` 为用户主目录(Windows 用 USERPROFILE,Linux 用 HOME)。
|
||||
pub fn expand_home(raw: &str) -> String {
|
||||
if raw == "~" {
|
||||
return home_dir();
|
||||
}
|
||||
if let Some(rest) = raw.strip_prefix("~/") {
|
||||
return format!("{}{}{}", home_dir(), std::path::MAIN_SEPARATOR, rest);
|
||||
}
|
||||
if let Some(rest) = raw.strip_prefix("~\\") {
|
||||
return format!("{}{}{}", home_dir(), std::path::MAIN_SEPARATOR, rest);
|
||||
}
|
||||
raw.to_string()
|
||||
}
|
||||
|
||||
/// 用户主目录(含路径分隔符兜底)。
|
||||
pub fn home_dir() -> String {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
std::env::var("USERPROFILE").unwrap_or_else(|_| ".".to_string())
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
std::env::var("HOME").unwrap_or_else(|_| ".".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn layer_identity() {
|
||||
assert_eq!(LAYER, "agentdock-config");
|
||||
fn expand_home_prefix() {
|
||||
let out = expand_home("~/.codex/config.toml");
|
||||
assert!(!out.starts_with("~/"));
|
||||
assert!(out.ends_with("config.toml"));
|
||||
let bare = expand_home("~");
|
||||
assert!(!bare.starts_with("~/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_path_unchanged() {
|
||||
assert_eq!(expand_home("/etc/codex/config.toml"), "/etc/codex/config.toml");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,5 +3,15 @@ name = "agentdock-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "编排层:发现/安装/配置/授权/诊断流水线(架构 §2 核心编排)"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
regex = "1"
|
||||
agentdock-adapter = { path = "../agentdock-adapter" }
|
||||
agentdock-config = { path = "../agentdock-config" }
|
||||
agentdock-secrets = { path = "../agentdock-secrets" }
|
||||
agentdock-exec = { path = "../agentdock-exec" }
|
||||
agentdock-diag = { path = "../agentdock-diag" }
|
||||
agentdock-platform = { path = "../agentdock-platform" }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
//! 编排层错误类型(中文)
|
||||
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum EngineError {
|
||||
/// 未找到指定 CLI
|
||||
NotFound(String),
|
||||
/// 适配器层错误
|
||||
Adapter(String),
|
||||
/// 配置层错误
|
||||
Config(String),
|
||||
/// 进程执行错误
|
||||
Exec(String),
|
||||
/// 密钥库错误
|
||||
Secrets(String),
|
||||
/// 能力本波未实现
|
||||
NotSupported(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for EngineError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
EngineError::NotFound(m) => write!(f, "未找到: {m}"),
|
||||
EngineError::Adapter(m) => write!(f, "适配器: {m}"),
|
||||
EngineError::Config(m) => write!(f, "配置: {m}"),
|
||||
EngineError::Exec(m) => write!(f, "执行: {m}"),
|
||||
EngineError::Secrets(m) => write!(f, "密钥库: {m}"),
|
||||
EngineError::NotSupported(m) => write!(f, "未支持: {m}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for EngineError {}
|
||||
|
||||
impl From<agentdock_config::ConfigError> for EngineError {
|
||||
fn from(e: agentdock_config::ConfigError) -> Self {
|
||||
EngineError::Config(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<agentdock_exec::ExecError> for EngineError {
|
||||
fn from(e: agentdock_exec::ExecError) -> Self {
|
||||
EngineError::Exec(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for EngineError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
EngineError::Exec(e.to_string())
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
//! agentdock-core —— 编排层(架构 §2 模块关系:UI --invoke--> commands --→ core orchestrator)
|
||||
//!
|
||||
//! 职责:发现 / 安装 / 配置 / 授权 / 诊断 流水线编排。
|
||||
//! Wave 0:空骨架,流水线自 Wave 1 起逐波落地。
|
||||
//! 把 adapter/config/secrets/exec/diag/platform 串成完整流水线:
|
||||
//! detectCli / previewAction / runAction / readConfig / writeConfig /
|
||||
//! authStatus / authorize / diagnose。
|
||||
|
||||
/// 编排层能力标记(占位)
|
||||
pub const LAYER: &str = "agentdock-core";
|
||||
pub mod engine;
|
||||
pub mod error;
|
||||
pub mod process;
|
||||
pub mod types;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn layer_identity() {
|
||||
assert_eq!(LAYER, "agentdock-core");
|
||||
}
|
||||
}
|
||||
pub use engine::{ActionOpts, Engine, auth_status, authorize, detect_one, diagnose, parse_version, read_config, run_action, write_config};
|
||||
pub use error::EngineError;
|
||||
pub use types::{
|
||||
ActionEvent, AuthStatus, ConfigFieldState, ConfigFileState, ConfigFormState, DetectResult,
|
||||
EnvFieldState, WriteResult, now_secs,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
//! 进程探测与流式执行辅助(内部)
|
||||
//!
|
||||
//! 所有命令经 `agentdock-exec::validate_argv` 校验后执行,禁止 shell 拼接。
|
||||
//! 探测类命令(`--version`)隐藏控制台窗口(Windows CREATE_NO_WINDOW)。
|
||||
|
||||
use std::io::BufRead;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
/// 按平台分隔符拆分 PATH。
|
||||
fn path_entries() -> Vec<String> {
|
||||
std::env::var("PATH")
|
||||
.unwrap_or_default()
|
||||
.split(if cfg!(windows) { ';' } else { ':' })
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 在 PATH 中查找可执行文件(返回全部命中,用于版本冲突检测)。
|
||||
/// Windows 优先 .exe/.cmd/.bat,最后回退无扩展名。
|
||||
pub fn which_all(program: &str) -> Vec<PathBuf> {
|
||||
let direct = Path::new(program);
|
||||
if direct.is_absolute() && direct.is_file() {
|
||||
return vec![direct.to_path_buf()];
|
||||
}
|
||||
let exts: &[&str] = if cfg!(windows) { &[".exe", ".cmd", ".bat", ""] } else { &[""] };
|
||||
let mut found = Vec::new();
|
||||
for dir in path_entries() {
|
||||
for ext in exts {
|
||||
let cand = Path::new(&dir).join(format!("{program}{ext}"));
|
||||
if cand.is_file() && !found.contains(&cand) {
|
||||
found.push(cand);
|
||||
}
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// 运行只读探测命令并返回 (exit_ok, stdout+stderr 合并文本)。
|
||||
pub fn run_capture(exe: &Path, args: &[String]) -> std::io::Result<(bool, String)> {
|
||||
let mut cmd = Command::new(exe);
|
||||
cmd.args(args);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
|
||||
}
|
||||
let out = cmd.output()?;
|
||||
let mut text = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
if text.trim().is_empty() {
|
||||
text = String::from_utf8_lossy(&out.stderr).to_string();
|
||||
}
|
||||
Ok((out.status.success(), text))
|
||||
}
|
||||
|
||||
/// 流式执行命令,逐行回调(stdout 与 stderr 均按行输出)。返回是否成功。
|
||||
/// 顺序读:先 stdout 后 stderr;对长任务足够,且实现简单可靠、无闭包 Send 负担。
|
||||
pub fn run_streaming<F>(exe: &str, args: &[String], mut on_line: F) -> std::io::Result<bool>
|
||||
where
|
||||
F: FnMut(bool, &str),
|
||||
{
|
||||
let mut cmd = Command::new(exe);
|
||||
cmd.args(args);
|
||||
cmd.stdout(Stdio::piped());
|
||||
cmd.stderr(Stdio::piped());
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
cmd.creation_flags(0x0800_0000);
|
||||
}
|
||||
let mut child = cmd.spawn()?;
|
||||
|
||||
if let Some(stdout) = child.stdout.take() {
|
||||
let reader = std::io::BufReader::new(stdout);
|
||||
for line in reader.lines().map_while(Result::ok) {
|
||||
on_line(false, &line);
|
||||
}
|
||||
}
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
let reader = std::io::BufReader::new(stderr);
|
||||
for line in reader.lines().map_while(Result::ok) {
|
||||
on_line(true, &line);
|
||||
}
|
||||
}
|
||||
|
||||
let status = child.wait()?;
|
||||
Ok(status.success())
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
//! IPC 契约数据类型(对齐架构 §2「关键 IPC 契约」)
|
||||
//!
|
||||
//! 字段命名与前端 TS 类型一一对应(serde 输出 snake_case)。
|
||||
//! 所有时间戳统一用 Unix 秒(u64),由前端做相对时间展示,后端不做日期数学。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 检测结果(detectCli)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DetectResult {
|
||||
pub cli_id: String,
|
||||
/// installed | not_installed | not_in_path | permission_denied | exec_failed | version_unparseable
|
||||
pub status: String,
|
||||
pub version: Option<String>,
|
||||
/// 解析到的可执行文件绝对路径
|
||||
pub executable: Option<String>,
|
||||
/// 适配器声明「版本检测文档未确认」(UI 显示「实测兜底」)
|
||||
pub version_unconfirmed: bool,
|
||||
/// Unix 秒
|
||||
pub checked_at: u64,
|
||||
}
|
||||
|
||||
/// 授权状态(authStatus)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuthStatus {
|
||||
pub cli_id: String,
|
||||
/// authorized | unauthorized | unknown | possibly_expired
|
||||
pub status: String,
|
||||
/// 结论来源:status_command | keyring | unknown
|
||||
pub via: String,
|
||||
pub detail_zh: String,
|
||||
pub checked_at: u64,
|
||||
}
|
||||
|
||||
/// 单个配置字段的读取状态(readConfig)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConfigFieldState {
|
||||
pub id: String,
|
||||
pub label_zh: String,
|
||||
pub help_zh: Option<String>,
|
||||
pub required: bool,
|
||||
pub sensitive: bool,
|
||||
/// string | url | enum | bool
|
||||
pub field_type: String,
|
||||
/// file | env | keyring
|
||||
pub storage: String,
|
||||
/// 敏感字段永不明文回显;值为 None 时结合 has_value 展示「已保存 / 未保存」
|
||||
pub value: Option<String>,
|
||||
/// 密钥库类字段:是否已有值
|
||||
pub has_value: bool,
|
||||
}
|
||||
|
||||
/// 配置文件解析状态。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConfigFileState {
|
||||
/// 解析后的绝对路径
|
||||
pub path: String,
|
||||
pub format: String,
|
||||
pub scope: Option<String>,
|
||||
pub exists: bool,
|
||||
pub parse_ok: bool,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// 环境变量映射(仅供说明与脱敏,本波不注入执行环境)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EnvFieldState {
|
||||
pub key: String,
|
||||
pub sensitive: bool,
|
||||
pub maps_to_field: Option<String>,
|
||||
}
|
||||
|
||||
/// 配置表单状态(readConfig 返回)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConfigFormState {
|
||||
pub cli_id: String,
|
||||
pub files: Vec<ConfigFileState>,
|
||||
pub fields: Vec<ConfigFieldState>,
|
||||
pub environment: Vec<EnvFieldState>,
|
||||
}
|
||||
|
||||
/// 写配置结果(writeConfig 返回)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WriteResult {
|
||||
/// 自动备份路径(无备份时为 None)
|
||||
pub backup_path: Option<String>,
|
||||
/// 实际写入的配置文件路径
|
||||
pub written_file: Option<String>,
|
||||
/// 成功写入的字段 id
|
||||
pub written_fields: Vec<String>,
|
||||
/// 逐字段错误(key=field id, value=中文错误)
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
/// 动作流事件(runAction 流式输出)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ActionEvent {
|
||||
/// step | stdout | stderr | done | error
|
||||
pub kind: String,
|
||||
/// 已脱敏的消息文本
|
||||
pub message: String,
|
||||
pub data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl ActionEvent {
|
||||
pub fn step(msg: impl Into<String>) -> Self {
|
||||
ActionEvent { kind: "step".into(), message: msg.into(), data: None }
|
||||
}
|
||||
pub fn stdout(line: impl Into<String>) -> Self {
|
||||
ActionEvent { kind: "stdout".into(), message: line.into(), data: None }
|
||||
}
|
||||
pub fn stderr(line: impl Into<String>) -> Self {
|
||||
ActionEvent { kind: "stderr".into(), message: line.into(), data: None }
|
||||
}
|
||||
pub fn done(data: Option<serde_json::Value>) -> Self {
|
||||
ActionEvent { kind: "done".into(), message: String::new(), data }
|
||||
}
|
||||
pub fn error(msg: impl Into<String>) -> Self {
|
||||
ActionEvent { kind: "error".into(), message: msg.into(), data: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前 Unix 秒。
|
||||
pub fn now_secs() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
@@ -3,5 +3,7 @@ name = "agentdock-diag"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "诊断规则引擎:PATH/依赖/版本冲突/配置损坏 四类检查(架构 §10)"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
@@ -1,17 +1,224 @@
|
||||
//! agentdock-diag —— 诊断规则引擎(架构 §10)
|
||||
//!
|
||||
//! 职责:PATH / 依赖缺失 / 版本冲突 / 配置损坏 四类检查的规则引擎。
|
||||
//! Wave 0:空骨架,随 Wave 2 落地。
|
||||
//! 四类必检:PATH / 依赖版本 / 版本冲突 / 配置损坏。每个检查器都是纯函数:
|
||||
//! 输入真实探测结果与声明阈值,输出 `Option<Finding>`。规则本身不依赖适配器
|
||||
//! 或进程执行(由 core 编排层传入数据),便于单元测试与「构造缺失场景」验证。
|
||||
|
||||
/// 诊断层能力标记(占位)
|
||||
pub const LAYER: &str = "agentdock-diag";
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 诊断级别(对齐视觉规范 §3.5 四级 + 六色语义)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Severity {
|
||||
Info,
|
||||
Warn,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl Severity {
|
||||
pub fn label_zh(&self) -> &'static str {
|
||||
match self {
|
||||
Severity::Info => "提示",
|
||||
Severity::Warn => "警告",
|
||||
Severity::Error => "错误",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 单条诊断结论(架构 §10.1:级别 + 证据 + 中文解释)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Finding {
|
||||
/// 规则 id(如 path.not_installed / dependency.node_below_min)
|
||||
pub rule_id: String,
|
||||
pub severity: Severity,
|
||||
pub message_zh: String,
|
||||
/// 原始证据(命令输出 / 路径 / 版本),脱敏后落盘
|
||||
pub evidence: Option<String>,
|
||||
}
|
||||
|
||||
/// 一份诊断报告(面向单个 CLI)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DiagnosticReport {
|
||||
pub cli_id: String,
|
||||
pub findings: Vec<Finding>,
|
||||
}
|
||||
|
||||
impl DiagnosticReport {
|
||||
pub fn new(cli_id: &str) -> Self {
|
||||
DiagnosticReport { cli_id: cli_id.to_string(), findings: Vec::new() }
|
||||
}
|
||||
|
||||
pub fn push(&mut self, f: Finding) {
|
||||
self.findings.push(f);
|
||||
}
|
||||
}
|
||||
|
||||
/// PATH 类:可执行文件状态。
|
||||
/// `status` 取 detect 结果:installed / not_installed / not_in_path / version_unparseable / exec_failed / permission_denied。
|
||||
pub fn check_path(cli_name_zh: &str, executable: &str, status: &str) -> Option<Finding> {
|
||||
match status {
|
||||
"not_installed" => Some(Finding {
|
||||
rule_id: "path.not_installed".into(),
|
||||
severity: Severity::Warn,
|
||||
message_zh: format!("未检测到 {cli_name_zh} 的可执行文件({executable})"),
|
||||
evidence: Some(format!("在 PATH 中未找到 {executable}")),
|
||||
}),
|
||||
"not_in_path" => Some(Finding {
|
||||
rule_id: "path.not_in_path".into(),
|
||||
severity: Severity::Warn,
|
||||
message_zh: format!("{cli_name_zh} 已安装但不在 PATH 中({executable})"),
|
||||
evidence: Some(format!("找到 {executable},但所在目录未加入 PATH")),
|
||||
}),
|
||||
"version_unparseable" => Some(Finding {
|
||||
rule_id: "detect.version_unparseable".into(),
|
||||
severity: Severity::Info,
|
||||
message_zh: format!("{cli_name_zh} 可执行但版本号解析失败"),
|
||||
evidence: Some(format!("{executable} 的版本输出无法解析")),
|
||||
}),
|
||||
"exec_failed" | "permission_denied" => Some(Finding {
|
||||
rule_id: "detect.exec_failed".into(),
|
||||
severity: Severity::Error,
|
||||
message_zh: format!("{cli_name_zh} 执行探测失败({executable})"),
|
||||
evidence: Some(format!("执行 {executable} 时失败(status={status})")),
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 依赖版本类:实际版本是否满足声明的 semver 范围(如 >=20)。
|
||||
pub fn check_dependency_version(dep_name: &str, range: &str, actual: Option<&str>) -> Option<Finding> {
|
||||
let Some(actual) = actual else {
|
||||
return Some(Finding {
|
||||
rule_id: format!("dependency.{dep_name}_missing"),
|
||||
severity: Severity::Error,
|
||||
message_zh: format!("缺少依赖 {dep_name}(要求 {range})"),
|
||||
evidence: Some(format!("未检测到 {dep_name}")),
|
||||
});
|
||||
};
|
||||
if version_satisfies(actual, range) {
|
||||
return None;
|
||||
}
|
||||
Some(Finding {
|
||||
rule_id: format!("dependency.{dep_name}_below_min"),
|
||||
severity: Severity::Error,
|
||||
message_zh: format!("{dep_name} 版本 {actual} 不满足要求 {range}"),
|
||||
evidence: Some(format!("{dep_name} {actual} 需要 {range}")),
|
||||
})
|
||||
}
|
||||
|
||||
/// 版本冲突类:同名可执行文件在 PATH 中出现多份。
|
||||
pub fn check_version_conflict(executable: &str, resolved_paths: &[String]) -> Option<Finding> {
|
||||
if resolved_paths.len() <= 1 {
|
||||
return None;
|
||||
}
|
||||
Some(Finding {
|
||||
rule_id: "version_conflict.multiple_copies".into(),
|
||||
severity: Severity::Warn,
|
||||
message_zh: format!("检测到 {executable} 在 PATH 中存在多份副本"),
|
||||
evidence: Some(format!("共 {} 份:{}", resolved_paths.len(), resolved_paths.join(" ; "))),
|
||||
})
|
||||
}
|
||||
|
||||
/// 配置损坏类:配置文件解析失败。
|
||||
pub fn check_config_parse(_format: &str, path: &str, parse_ok: bool, err: Option<&str>) -> Option<Finding> {
|
||||
if parse_ok {
|
||||
return None;
|
||||
}
|
||||
Some(Finding {
|
||||
rule_id: "config.corrupt".into(),
|
||||
severity: Severity::Error,
|
||||
message_zh: format!("配置文件解析失败({path})"),
|
||||
evidence: Some(err.unwrap_or("未知解析错误").to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
/// 极简 semver 范围判断:仅支持 `>=X` / `>=X.Y`(本波工具依赖均为这种形态)。
|
||||
pub fn version_satisfies(version: &str, range: &str) -> bool {
|
||||
let Some(range) = range.trim().strip_prefix(">=") else {
|
||||
// 无法识别的范围按「无限制」处理,避免误报
|
||||
return true;
|
||||
};
|
||||
let range = range.trim();
|
||||
let req: Vec<u64> = range.split('.').filter_map(|s| s.parse::<u64>().ok()).collect();
|
||||
let got: Vec<u64> = version
|
||||
.split(|c: char| !c.is_ascii_digit())
|
||||
.filter(|s| !s.is_empty())
|
||||
.filter_map(|s| s.parse::<u64>().ok())
|
||||
.collect();
|
||||
if req.is_empty() || got.is_empty() {
|
||||
return true;
|
||||
}
|
||||
for i in 0..req.len() {
|
||||
let g = got.get(i).copied().unwrap_or(0);
|
||||
if g > req[i] {
|
||||
return true;
|
||||
}
|
||||
if g < req[i] {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn layer_identity() {
|
||||
assert_eq!(LAYER, "agentdock-diag");
|
||||
fn path_not_installed_rule() {
|
||||
let f = check_path("Codex CLI", "codex", "not_installed").unwrap();
|
||||
assert_eq!(f.rule_id, "path.not_installed");
|
||||
assert_eq!(f.severity, Severity::Warn);
|
||||
assert!(f.message_zh.contains("codex"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_installed_is_clean() {
|
||||
assert!(check_path("Codex CLI", "codex", "installed").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependency_below_min_hits() {
|
||||
// Node 18 < 20
|
||||
let f = check_dependency_version("node", ">=20", Some("18.20.0")).unwrap();
|
||||
assert_eq!(f.rule_id, "dependency.node_below_min");
|
||||
assert_eq!(f.severity, Severity::Error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependency_ok_passes() {
|
||||
assert!(check_dependency_version("node", ">=20", Some("24.18.0")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependency_missing_hits() {
|
||||
let f = check_dependency_version("node", ">=20", None).unwrap();
|
||||
assert!(f.rule_id.contains("missing"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_conflict_hits_when_multiple() {
|
||||
let paths = vec!["C:\\a\\codex.exe".into(), "C:\\b\\codex.exe".into()];
|
||||
let f = check_version_conflict("codex", &paths).unwrap();
|
||||
assert_eq!(f.rule_id, "version_conflict.multiple_copies");
|
||||
assert!(check_version_conflict("codex", &["C:\\a\\codex.exe".into()]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_corrupt_hits() {
|
||||
let f = check_config_parse("json", "~/.gemini/settings.json", false, Some("expected value")).unwrap();
|
||||
assert_eq!(f.rule_id, "config.corrupt");
|
||||
assert_eq!(f.severity, Severity::Error);
|
||||
assert!(check_config_parse("json", "p", true, None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semver_range_logic() {
|
||||
assert!(version_satisfies("24.18.0", ">=20"));
|
||||
assert!(version_satisfies("20.0.0", ">=20"));
|
||||
assert!(version_satisfies("22.1.0", ">=22"));
|
||||
assert!(!version_satisfies("18.20.0", ">=20"));
|
||||
assert!(!version_satisfies("20.10.0", ">=22"));
|
||||
assert!(version_satisfies("v24.18.0", ">=20"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
//! 执行层错误类型(中文,不含密钥明文)
|
||||
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ExecError {
|
||||
/// 参数含 shell 元字符等危险输入
|
||||
DangerousInput(String),
|
||||
/// 参数槽位白名单校验失败
|
||||
InvalidSlot(String),
|
||||
/// 工作目录越界
|
||||
CwdNotAllowed(String),
|
||||
/// 进程启动失败
|
||||
Spawn(String),
|
||||
/// 非零退出码
|
||||
NonZeroExit(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for ExecError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
ExecError::DangerousInput(m) => write!(f, "危险输入: {m}"),
|
||||
ExecError::InvalidSlot(m) => write!(f, "参数校验失败: {m}"),
|
||||
ExecError::CwdNotAllowed(m) => write!(f, "工作目录越界: {m}"),
|
||||
ExecError::Spawn(m) => write!(f, "进程启动失败: {m}"),
|
||||
ExecError::NonZeroExit(m) => write!(f, "命令非零退出: {m}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ExecError {}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! 仅 argv 数组的安全进程执行(架构 §4.2)
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
|
||||
use crate::error::ExecError;
|
||||
|
||||
/// shell 注入元字符(argv 场景,架构 §4.2)。`\` `/` 属路径分隔符,允许出现在
|
||||
/// 可执行文件路径或路径参数里(argv 不经 shell,无转义语义)。
|
||||
const SHELL_INJECT_CHARS: &[char] = &['|', '&', ';', '$', '>', '<', '(', ')', '`'];
|
||||
|
||||
/// 返回首个 shell 注入元字符。
|
||||
pub fn first_metachar(s: &str) -> Option<char> {
|
||||
s.chars().find(|c| SHELL_INJECT_CHARS.contains(c))
|
||||
}
|
||||
|
||||
/// 校验可执行文件名:非空、不含 shell 注入元字符、不以 `-` 开头。
|
||||
fn validate_executable(prog: &str) -> Result<(), ExecError> {
|
||||
if prog.is_empty() {
|
||||
return Err(ExecError::InvalidSlot("可执行文件名不能为空".into()));
|
||||
}
|
||||
if let Some(c) = first_metachar(prog) {
|
||||
return Err(ExecError::DangerousInput(format!("可执行文件名含 shell 元字符 {c:?}: {prog}")));
|
||||
}
|
||||
if prog.starts_with('-') {
|
||||
return Err(ExecError::InvalidSlot(format!("可执行文件名不能以 - 开头: {prog}")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 校验 argv 数组:可执行文件 + 每个参数均不含 shell 元字符。
|
||||
/// 用户输入只能作为已校验参数槽位(枚举/路径/版本号)传入,禁止拼接。
|
||||
pub fn validate_argv(prog: &str, args: &[String]) -> Result<(), ExecError> {
|
||||
validate_executable(prog)?;
|
||||
for arg in args {
|
||||
if let Some(c) = first_metachar(arg) {
|
||||
return Err(ExecError::DangerousInput(format!("参数含 shell 元字符 {c:?}: {arg}")));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 路径规范化:优先 canonicalize(消 .. 与符号链接),失败则转绝对路径。
|
||||
fn normalize(p: &Path) -> PathBuf {
|
||||
std::fs::canonicalize(p).unwrap_or_else(|_| {
|
||||
if p.is_absolute() {
|
||||
p.to_path_buf()
|
||||
} else {
|
||||
std::env::current_dir()
|
||||
.unwrap_or_default()
|
||||
.join(p)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 工作目录限制(架构 §4.2 第 3 条):cwd 必须落在某个允许的根目录之内。
|
||||
pub fn ensure_cwd_within(cwd: &Path, allowed_roots: &[PathBuf]) -> Result<(), ExecError> {
|
||||
if allowed_roots.is_empty() {
|
||||
return Err(ExecError::CwdNotAllowed("未配置允许的工作目录根".into()));
|
||||
}
|
||||
let cwd_norm = normalize(cwd);
|
||||
for root in allowed_roots {
|
||||
let root_norm = normalize(root);
|
||||
if cwd_norm.starts_with(&root_norm) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(ExecError::CwdNotAllowed(format!(
|
||||
"工作目录「{}」超出允许范围",
|
||||
cwd.display()
|
||||
)))
|
||||
}
|
||||
|
||||
/// 仅 argv 执行:`Command::new(prog).args(args)`,禁止 shell 拼接 / 管道 / 重定向。
|
||||
/// cwd 为 None 时继承当前进程目录。
|
||||
pub fn spawn(prog: &str, args: &[String], cwd: Option<&Path>) -> Result<Output, ExecError> {
|
||||
validate_argv(prog, args)?;
|
||||
let mut cmd = Command::new(prog);
|
||||
cmd.args(args);
|
||||
if let Some(dir) = cwd {
|
||||
cmd.current_dir(dir);
|
||||
}
|
||||
cmd.output()
|
||||
.map_err(|e| ExecError::Spawn(format!("无法执行 {prog}: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rejects_shell_metachar_in_args() {
|
||||
for (prog, args) in [
|
||||
("npm", vec!["a|b".to_string()]),
|
||||
("npm", vec!["a&&b".to_string()]),
|
||||
("sh", vec!["$(id)".to_string()]),
|
||||
("sh", vec!["`whoami`".to_string()]),
|
||||
("sh", vec!["a;b".to_string()]),
|
||||
] {
|
||||
assert!(matches!(validate_argv(prog, &args), Err(ExecError::DangerousInput(_))),
|
||||
"{prog} {args:?} 应被拒绝");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_metachar_in_executable() {
|
||||
assert!(matches!(validate_argv("a|b", &[]), Err(ExecError::DangerousInput(_))));
|
||||
assert!(matches!(validate_argv("", &[]), Err(ExecError::InvalidSlot(_))));
|
||||
assert!(matches!(validate_argv("-rf", &[]), Err(ExecError::InvalidSlot(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_plain_argv() {
|
||||
assert!(validate_argv("npm", &["install".into(), "-g".into(), "@openai/codex".into()]).is_ok());
|
||||
assert!(validate_argv("C:\\Program Files\\nodejs\\node.exe", &["--version".into()]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cwd_within_allowed_root() {
|
||||
let root = std::env::temp_dir();
|
||||
let ok = root.join("agentdock-exec-ok");
|
||||
let out = std::env::temp_dir().join("agentdock-exec-out");
|
||||
let _ = std::fs::create_dir_all(&ok);
|
||||
assert!(ensure_cwd_within(&ok, &[root.clone()]).is_ok());
|
||||
assert!(ensure_cwd_within(&out, &[ok.clone()]).is_err());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn spawn_runs_argv_without_shell() {
|
||||
let out = spawn("where.exe", &["where".to_string()], None).expect("where.exe 应可执行");
|
||||
assert!(out.status.success());
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[test]
|
||||
fn spawn_runs_argv_without_shell() {
|
||||
let out = spawn("/bin/true", &[], None).expect("/bin/true 应可执行");
|
||||
assert!(out.status.success());
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,12 @@
|
||||
//! agentdock-exec —— 安全进程执行层(架构 §3.2 / §4.2)
|
||||
//!
|
||||
//! 职责:仅执行适配器声明的 argv,禁止 shell 拼接、管道、重定向;
|
||||
//! 参数白名单校验。Wave 0:空骨架,随 Wave 1 落地。
|
||||
//! 职责:仅执行适配器声明的 argv 数组,禁止 shell 拼接、管道、重定向;
|
||||
//! 参数槽位白名单校验;工作目录限制。
|
||||
|
||||
/// 执行层能力标记(占位)
|
||||
pub const LAYER: &str = "agentdock-exec";
|
||||
pub mod error;
|
||||
pub mod exec;
|
||||
pub mod slots;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn layer_identity() {
|
||||
assert_eq!(LAYER, "agentdock-exec");
|
||||
}
|
||||
}
|
||||
pub use error::ExecError;
|
||||
pub use exec::{ensure_cwd_within, spawn, validate_argv};
|
||||
pub use slots::{Slot, validate_slot};
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
//! 参数槽位白名单校验(架构 §4.2:用户输入只作为已校验参数槽位)
|
||||
|
||||
use crate::error::ExecError;
|
||||
use crate::exec::first_metachar;
|
||||
|
||||
/// 参数槽位类型。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Slot {
|
||||
/// 枚举:值必须在白名单内
|
||||
Enum(Vec<&'static str>),
|
||||
/// 版本号:纯数字 + 点 + 可选连字符(如 1.2.3 / 24.18.0)
|
||||
Version,
|
||||
/// 路径:禁止 shell 元字符
|
||||
Path,
|
||||
/// 普通参数:禁止 shell 元字符、禁止为空
|
||||
Plain,
|
||||
}
|
||||
|
||||
/// 校验单个参数槽位。
|
||||
pub fn validate_slot(slot: &Slot, value: &str) -> Result<(), ExecError> {
|
||||
if value.is_empty() {
|
||||
return Err(ExecError::InvalidSlot("参数不能为空".into()));
|
||||
}
|
||||
if let Some(c) = first_metachar(value) {
|
||||
return Err(ExecError::DangerousInput(format!("参数含 shell 元字符 {c:?}: {value}")));
|
||||
}
|
||||
match slot {
|
||||
Slot::Enum(allowed) => {
|
||||
if !allowed.iter().any(|a| *a == value) {
|
||||
return Err(ExecError::InvalidSlot(format!(
|
||||
"值「{value}」不在白名单 {allowed:?} 内"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Slot::Version => {
|
||||
if !value.chars().all(|c| c.is_ascii_digit() || c == '.' || c == '-') {
|
||||
return Err(ExecError::InvalidSlot(format!("「{value}」不是合法版本号")));
|
||||
}
|
||||
}
|
||||
Slot::Path | Slot::Plain => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn enum_whitelist_accepts_listed_value() {
|
||||
let slot = Slot::Enum(vec!["npm", "winget", "apt"]);
|
||||
assert!(validate_slot(&slot, "npm").is_ok());
|
||||
assert!(validate_slot(&slot, "winget").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enum_whitelist_rejects_unknown_value() {
|
||||
let slot = Slot::Enum(vec!["npm", "winget", "apt"]);
|
||||
match validate_slot(&slot, "choco") {
|
||||
Err(ExecError::InvalidSlot(m)) => assert!(m.contains("choco"), "错误应包含被拒值: {m}"),
|
||||
other => panic!("应返回 InvalidSlot,实际 {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_slot_accepts_and_rejects() {
|
||||
let slot = Slot::Version;
|
||||
assert!(validate_slot(&slot, "24.18.0").is_ok());
|
||||
assert!(validate_slot(&slot, "3.14.6").is_ok());
|
||||
assert!(matches!(validate_slot(&slot, "abc"), Err(ExecError::InvalidSlot(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_metachar_in_any_slot() {
|
||||
for slot in [
|
||||
Slot::Plain,
|
||||
Slot::Path,
|
||||
Slot::Version,
|
||||
Slot::Enum(vec!["npm"]),
|
||||
] {
|
||||
assert!(matches!(validate_slot(&slot, "a|b"), Err(ExecError::DangerousInput(_))));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty() {
|
||||
assert!(matches!(validate_slot(&Slot::Plain, ""), Err(ExecError::InvalidSlot(_))));
|
||||
}
|
||||
}
|
||||
@@ -15,10 +15,14 @@ pub fn detect_env() -> PlatformEnv {
|
||||
#[cfg(not(windows))]
|
||||
let (os, os_version) = (String::from("linux"), linux_os_version());
|
||||
|
||||
let (distro, distro_version) = distro_info();
|
||||
|
||||
PlatformEnv {
|
||||
os,
|
||||
os_version,
|
||||
arch: std::env::consts::ARCH.to_string(),
|
||||
distro,
|
||||
distro_version,
|
||||
shells: detect_shells(),
|
||||
runtimes: detect_runtimes(),
|
||||
path_entries: path_entries(),
|
||||
@@ -26,6 +30,31 @@ pub fn detect_env() -> PlatformEnv {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn distro_info() -> (Option<String>, Option<String>) {
|
||||
(None, None)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn distro_info() -> (Option<String>, Option<String>) {
|
||||
parse_os_release(&std::fs::read_to_string("/etc/os-release").unwrap_or_default())
|
||||
}
|
||||
|
||||
/// 解析 os-release 内容,返回 (发行版名, 版本号)。
|
||||
/// 独立成纯函数以便跨平台单元测试。
|
||||
pub fn parse_os_release(content: &str) -> (Option<String>, Option<String>) {
|
||||
let mut id = None;
|
||||
let mut ver = None;
|
||||
for line in content.lines() {
|
||||
if let Some(v) = line.strip_prefix("ID=") {
|
||||
id = Some(v.trim().trim_matches('"').to_string());
|
||||
} else if let Some(v) = line.strip_prefix("VERSION_ID=") {
|
||||
ver = Some(v.trim().trim_matches('"').to_string());
|
||||
}
|
||||
}
|
||||
(id, ver)
|
||||
}
|
||||
|
||||
/// 从命令输出中提取首个版本号(如 "v24.18.0" -> "24.18.0")。
|
||||
/// 规则:取第一段以数字开头、由数字与点组成的子串,尾部点剔除。
|
||||
pub fn extract_version(output: &str) -> Option<String> {
|
||||
@@ -95,7 +124,7 @@ fn resolve_program(program: &str) -> Option<PathBuf> {
|
||||
}
|
||||
|
||||
/// 探测一个可执行文件:返回状态 / 版本 / 路径。
|
||||
/// 结果分类:installed / not_in_path / exec_failed / version_unparseable。
|
||||
/// 结果分类:installed / not_in_path / permission_denied / exec_failed / version_unparseable。
|
||||
fn probe(program: &str, args: &[&str]) -> Option<RuntimeInfo> {
|
||||
let path = resolve_program(program);
|
||||
let path_str = path.as_ref().map(|p| p.to_string_lossy().to_string());
|
||||
@@ -119,9 +148,15 @@ fn probe(program: &str, args: &[&str]) -> Option<RuntimeInfo> {
|
||||
|
||||
let output = match cmd.output() {
|
||||
Ok(o) => o,
|
||||
Err(_) => {
|
||||
Err(e) => {
|
||||
// 架构 §5:把「权限不足」与一般执行失败拆分开(总工 Wave 0 🟡)
|
||||
let status = if e.kind() == std::io::ErrorKind::PermissionDenied {
|
||||
"permission_denied"
|
||||
} else {
|
||||
"exec_failed"
|
||||
};
|
||||
return Some(RuntimeInfo {
|
||||
status: "exec_failed".into(),
|
||||
status: status.into(),
|
||||
version: None,
|
||||
path: path_str,
|
||||
});
|
||||
@@ -296,6 +331,29 @@ mod tests {
|
||||
assert_eq!(extract_version("v1.2.3."), Some("1.2.3".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_os_release_distro_and_version() {
|
||||
let content = "NAME=\"Ubuntu\"\nVERSION=\"24.04.1 LTS (Noble Numbat)\"\nID=ubuntu\nID_LIKE=debian\nVERSION_ID=\"24.04\"\n";
|
||||
let (distro, ver) = parse_os_release(content);
|
||||
assert_eq!(distro.as_deref(), Some("ubuntu"));
|
||||
assert_eq!(ver.as_deref(), Some("24.04"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_os_release_missing_fields() {
|
||||
let (distro, ver) = parse_os_release("NAME=\"Other\"\n");
|
||||
assert_eq!(distro, None);
|
||||
assert_eq!(ver, None);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_distro_is_none() {
|
||||
let env = detect_env();
|
||||
assert_eq!(env.distro, None);
|
||||
assert_eq!(env.distro_version, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_has_path_on_windows() {
|
||||
// PATH 变量在任何真实 Windows/Linux 上都存在
|
||||
|
||||
@@ -11,6 +11,12 @@ pub struct PlatformEnv {
|
||||
pub os_version: String,
|
||||
/// 架构(std::env::consts::ARCH,如 x86_64)
|
||||
pub arch: String,
|
||||
/// Linux 发行版名称(如 "ubuntu");Windows 为 None。
|
||||
/// 架构 §5 原 `distro?: ubuntu + version` 合并字段,本波拆分为
|
||||
/// `distro` + `distro_version` 两个字段(总工 Wave 0 🟡)。
|
||||
pub distro: Option<String>,
|
||||
/// Linux 发行版版本(如 "22.04");Windows 为 None。
|
||||
pub distro_version: Option<String>,
|
||||
pub shells: Shells,
|
||||
pub runtimes: Runtimes,
|
||||
/// PATH 条目(Windows 用 ';' 分割,Linux 用 ':')
|
||||
@@ -41,7 +47,7 @@ pub struct Runtimes {
|
||||
/// 单个运行时探测结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||
pub struct RuntimeInfo {
|
||||
/// installed | not_installed | not_in_path | exec_failed | version_unparseable
|
||||
/// installed | not_in_path | permission_denied | exec_failed | version_unparseable
|
||||
pub status: String,
|
||||
pub version: Option<String>,
|
||||
/// 解析到的可执行文件绝对路径(PATH 搜索)
|
||||
|
||||
@@ -3,5 +3,15 @@ name = "agentdock-secrets"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "系统密钥库封装(keyring)+ 日志脱敏(架构 §4.1 / §4.3)"
|
||||
|
||||
[dependencies]
|
||||
regex = "1"
|
||||
|
||||
# keyring 为架构 §1/§4.1 指定的密钥库后端:Windows Credential Manager /
|
||||
# Linux Secret Service。按平台仅编译对应后端,避免把 dbus 栈拖进 Windows 构建。
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
keyring = { version = "3", features = ["windows-native"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
keyring = { version = "3", features = ["sync-secret-service"] }
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
//! 密钥库错误类型。
|
||||
//!
|
||||
//! 铁律:错误信息(Display / Debug)绝不携带密钥明文,也不携带后端原始错误
|
||||
//! 文本(原始错误可能夹带敏感信息),只保留中文说明。
|
||||
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum SecretStoreError {
|
||||
/// 无此条目
|
||||
NotFound,
|
||||
/// 后端不可用(中文说明,不含明文)
|
||||
Backend(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for SecretStoreError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
SecretStoreError::NotFound => write!(f, "未找到对应密钥条目"),
|
||||
SecretStoreError::Backend(m) => write!(f, "密钥库错误: {m}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SecretStoreError {}
|
||||
@@ -0,0 +1,93 @@
|
||||
//! 生产密钥库后端:keyring → Windows Credential Manager(优先)/ Linux Secret Service
|
||||
//! (架构 §4.1)
|
||||
//!
|
||||
//! 错误映射时只保留中文说明,绝不把后端原始错误或密钥明文带入错误信息。
|
||||
|
||||
use crate::error::SecretStoreError;
|
||||
use crate::store::SecretStore;
|
||||
|
||||
/// keyring 后端封装。
|
||||
#[derive(Default)]
|
||||
pub struct KeyringSecretStore;
|
||||
|
||||
impl KeyringSecretStore {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod platform {
|
||||
use keyring::Entry;
|
||||
|
||||
use crate::error::SecretStoreError;
|
||||
|
||||
pub fn set(service: &str, account: &str, secret: &str) -> Result<(), SecretStoreError> {
|
||||
let entry = Entry::new(service, account).map_err(map_err)?;
|
||||
entry.set_password(secret).map_err(map_err)
|
||||
}
|
||||
|
||||
pub fn get(service: &str, account: &str) -> Result<String, SecretStoreError> {
|
||||
let entry = Entry::new(service, account).map_err(map_err)?;
|
||||
entry.get_password().map_err(map_err)
|
||||
}
|
||||
|
||||
pub fn has(service: &str, account: &str) -> bool {
|
||||
Entry::new(service, account)
|
||||
.and_then(|e| e.get_password())
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
pub fn delete(service: &str, account: &str) -> Result<(), SecretStoreError> {
|
||||
let entry = Entry::new(service, account).map_err(map_err)?;
|
||||
entry.delete_credential().map_err(map_err)
|
||||
}
|
||||
|
||||
fn map_err(e: keyring::Error) -> SecretStoreError {
|
||||
// NoEntry 表示无此条目;其余错误统一映射为后端失败,绝不回显原始错误文本。
|
||||
if matches!(e, keyring::Error::NoEntry) {
|
||||
SecretStoreError::NotFound
|
||||
} else {
|
||||
SecretStoreError::Backend("系统密钥库操作失败(Windows Credential Manager)".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
mod platform {
|
||||
use crate::error::SecretStoreError;
|
||||
|
||||
pub fn set(_service: &str, _account: &str, _secret: &str) -> Result<(), SecretStoreError> {
|
||||
Err(SecretStoreError::Backend("Linux 密钥库后端自 Wave 2 起接入(Secret Service)".into()))
|
||||
}
|
||||
|
||||
pub fn get(_service: &str, _account: &str) -> Result<String, SecretStoreError> {
|
||||
Err(SecretStoreError::Backend("Linux 密钥库后端自 Wave 2 起接入(Secret Service)".into()))
|
||||
}
|
||||
|
||||
pub fn has(_service: &str, _account: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn delete(_service: &str, _account: &str) -> Result<(), SecretStoreError> {
|
||||
Err(SecretStoreError::Backend("Linux 密钥库后端自 Wave 2 起接入(Secret Service)".into()))
|
||||
}
|
||||
}
|
||||
|
||||
impl SecretStore for KeyringSecretStore {
|
||||
fn set(&self, service: &str, account: &str, secret: &str) -> Result<(), SecretStoreError> {
|
||||
platform::set(service, account, secret)
|
||||
}
|
||||
|
||||
fn get(&self, service: &str, account: &str) -> Result<String, SecretStoreError> {
|
||||
platform::get(service, account)
|
||||
}
|
||||
|
||||
fn has(&self, service: &str, account: &str) -> bool {
|
||||
platform::has(service, account)
|
||||
}
|
||||
|
||||
fn delete(&self, service: &str, account: &str) -> Result<(), SecretStoreError> {
|
||||
platform::delete(service, account)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,18 @@
|
||||
//! agentdock-secrets —— 系统密钥库封装与日志脱敏(架构 §4.1 / §4.3)
|
||||
//!
|
||||
//! 职责:keyring 封装(Windows Credential Manager / Linux Secret Service)、
|
||||
//! 敏感值脱敏。Wave 0:空骨架,随 Wave 1 落地。
|
||||
//! - `store` / `mock` / `keyring`:密钥库封装(set/get/has/delete),
|
||||
//! 生产后端走 keyring(Windows Credential Manager / Linux Secret Service),
|
||||
//! 测试用 mock 内存后端。
|
||||
//! - `redact`:日志脱敏器(sk-/xai-/ghp_/gho_/JWT 与敏感键值)。
|
||||
|
||||
/// 密钥层能力标记(占位)
|
||||
pub const LAYER: &str = "agentdock-secrets";
|
||||
pub mod error;
|
||||
pub mod keyring;
|
||||
pub mod mock;
|
||||
pub mod redact;
|
||||
pub mod store;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn layer_identity() {
|
||||
assert_eq!(LAYER, "agentdock-secrets");
|
||||
}
|
||||
}
|
||||
pub use error::SecretStoreError;
|
||||
pub use keyring::KeyringSecretStore;
|
||||
pub use mock::MockSecretStore;
|
||||
pub use redact::{REDACTED, redact, redact_value};
|
||||
pub use store::{SecretStore, service_name};
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
//! 内存 mock 密钥库后端(测试与无系统钥匙串环境用)
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::error::SecretStoreError;
|
||||
use crate::store::SecretStore;
|
||||
|
||||
/// 线程安全的内存后端。仅用于测试与开发联调,不落盘。
|
||||
#[derive(Default)]
|
||||
pub struct MockSecretStore {
|
||||
inner: Mutex<HashMap<(String, String), String>>,
|
||||
}
|
||||
|
||||
impl MockSecretStore {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 返回当前条目数量(测试断言用)。
|
||||
pub fn len(&self) -> usize {
|
||||
self.inner.lock().map(|m| m.len()).unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
impl SecretStore for MockSecretStore {
|
||||
fn set(&self, service: &str, account: &str, secret: &str) -> Result<(), SecretStoreError> {
|
||||
let mut map = self.inner.lock().map_err(|_| SecretStoreError::Backend("内存锁中毒".into()))?;
|
||||
map.insert((service.to_string(), account.to_string()), secret.to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get(&self, service: &str, account: &str) -> Result<String, SecretStoreError> {
|
||||
let map = self.inner.lock().map_err(|_| SecretStoreError::Backend("内存锁中毒".into()))?;
|
||||
map.get(&(service.to_string(), account.to_string()))
|
||||
.cloned()
|
||||
.ok_or(SecretStoreError::NotFound)
|
||||
}
|
||||
|
||||
fn has(&self, service: &str, account: &str) -> bool {
|
||||
self.get(service, account).is_ok()
|
||||
}
|
||||
|
||||
fn delete(&self, service: &str, account: &str) -> Result<(), SecretStoreError> {
|
||||
let mut map = self.inner.lock().map_err(|_| SecretStoreError::Backend("内存锁中毒".into()))?;
|
||||
map.remove(&(service.to_string(), account.to_string()))
|
||||
.map(|_| ())
|
||||
.ok_or(SecretStoreError::NotFound)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roundtrip_set_get() {
|
||||
let store = MockSecretStore::new();
|
||||
store.set("agentdock.codex", "api_key", "sk-verysecret").unwrap();
|
||||
assert_eq!(store.get("agentdock.codex", "api_key").unwrap(), "sk-verysecret");
|
||||
assert!(store.has("agentdock.codex", "api_key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_missing_returns_not_found_without_secret() {
|
||||
let store = MockSecretStore::new();
|
||||
let err = store.get("agentdock.codex", "nope").unwrap_err();
|
||||
assert!(matches!(err, SecretStoreError::NotFound));
|
||||
// 错误信息不得含任何密钥明文
|
||||
let secret = "sk-do-not-leak";
|
||||
assert!(!format!("{err}").contains(secret));
|
||||
assert!(!format!("{err:?}").contains(secret));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_removes_entry() {
|
||||
let store = MockSecretStore::new();
|
||||
store.set("agentdock.claude", "api_key", "xai-123").unwrap();
|
||||
assert!(store.has("agentdock.claude", "api_key"));
|
||||
store.delete("agentdock.claude", "api_key").unwrap();
|
||||
assert!(!store.has("agentdock.claude", "api_key"));
|
||||
assert!(matches!(store.delete("agentdock.claude", "api_key"), Err(SecretStoreError::NotFound)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_never_contains_plaintext() {
|
||||
let store = MockSecretStore::new();
|
||||
let secret = "sk-super-secret-value";
|
||||
store.set("agentdock.x", "k", secret).unwrap();
|
||||
// 触发一个错误路径:删除后读取
|
||||
store.delete("agentdock.x", "k").unwrap();
|
||||
let err = store.get("agentdock.x", "k").unwrap_err();
|
||||
let rendered = format!("{err} / {err:?}");
|
||||
assert!(!rendered.contains(secret), "错误渲染不应含密钥明文: {rendered}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//! 日志脱敏器(架构 §4.3)
|
||||
//!
|
||||
//! 模式表:
|
||||
//! - `sk-` / `xai-` / `ghp_`(及 GitHub 其它前缀 `gho_/ghs_/ghu_/ghr_`)/ JWT 形态 → `***REDACTED***`
|
||||
//! - `key=value` / `key: value` 形式的敏感键(api_key/token/secret/password/authorization/bearer)→ 值脱敏
|
||||
//! - 任何标记 `sensitive: true` 的字段值,调用方用 `redact_value` 强制脱敏
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
/// 统一的脱敏占位符。
|
||||
pub const REDACTED: &str = "***REDACTED***";
|
||||
|
||||
fn jwt_re() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| Regex::new(r"eyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+").unwrap())
|
||||
}
|
||||
|
||||
fn sk_xai_re() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| Regex::new(r"(?i)\b(?:sk|xai)-[A-Za-z0-9_-]+").unwrap())
|
||||
}
|
||||
|
||||
fn github_token_re() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| Regex::new(r"\bgh[pousr]_[A-Za-z0-9]+").unwrap())
|
||||
}
|
||||
|
||||
fn sensitive_kv_re() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| {
|
||||
Regex::new(
|
||||
r#"(?i)\b(api[_-]?key|apikey|token|secret|password|authorization|bearer)\s*[:=]\s*("[^"]*"|'[^']*'|[^\s,;]+)"#,
|
||||
)
|
||||
.unwrap()
|
||||
})
|
||||
}
|
||||
|
||||
/// 对任意文本做脱敏:替换前缀密钥、JWT、敏感键值。
|
||||
pub fn redact(input: &str) -> String {
|
||||
let mut out = input.to_string();
|
||||
out = jwt_re().replace_all(&out, REDACTED).to_string();
|
||||
out = sk_xai_re().replace_all(&out, REDACTED).to_string();
|
||||
out = github_token_re().replace_all(&out, REDACTED).to_string();
|
||||
out = sensitive_kv_re()
|
||||
.replace_all(&out, "$1=***REDACTED***")
|
||||
.to_string();
|
||||
out
|
||||
}
|
||||
|
||||
/// 对单个已知敏感值强制脱敏(适配器 `sensitive: true` 字段落日志前调用)。
|
||||
pub fn redact_value(_value: &str) -> String {
|
||||
REDACTED.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn redacts_sk_prefix() {
|
||||
let out = redact("密钥是 sk-abc123def");
|
||||
assert!(out.contains(REDACTED));
|
||||
assert!(!out.contains("sk-abc123def"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacts_xai_prefix() {
|
||||
let out = redact("xai-verysecret");
|
||||
assert!(out.contains(REDACTED));
|
||||
assert!(!out.contains("xai-verysecret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacts_github_tokens() {
|
||||
for token in ["ghp_abcdefghijklmnop", "gho_1234", "ghs_xyz", "ghu_9", "ghr_ab"] {
|
||||
let out = redact(token);
|
||||
assert!(out.contains(REDACTED), "{token}");
|
||||
assert!(!out.contains(token), "{token}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacts_jwt() {
|
||||
let jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
|
||||
let out = redact(&format!("Authorization: Bearer {jwt}"));
|
||||
assert!(!out.contains(jwt));
|
||||
assert!(out.contains(REDACTED));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacts_sensitive_key_value() {
|
||||
let out = redact("API_KEY=sk-abc123");
|
||||
assert!(out.contains("API_KEY=***REDACTED***"), "{out}");
|
||||
assert!(!out.contains("sk-abc123"));
|
||||
|
||||
let out2 = redact("token: abcdef123456");
|
||||
assert!(out2.contains("***REDACTED***"), "{out2}");
|
||||
assert!(!out2.contains("abcdef123456"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacts_quoted_value() {
|
||||
let out = redact("password=\"hunter2secret\"");
|
||||
assert!(out.contains("***REDACTED***"), "{out}");
|
||||
assert!(!out.contains("hunter2secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_innocent_text_untouched() {
|
||||
let s = "node --version 返回 24.18.0";
|
||||
assert_eq!(redact(s), s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redact_value_always_masks() {
|
||||
assert_eq!(redact_value("anything"), REDACTED);
|
||||
assert_eq!(redact_value("sk-abc"), REDACTED);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//! 密钥库抽象接口(架构 §4.1)
|
||||
|
||||
use crate::error::SecretStoreError;
|
||||
|
||||
/// 系统密钥库封装接口。`service` 固定前缀 `agentdock.<cli_id>`,
|
||||
/// `account` 为字段 id(如 `api_key`)。
|
||||
pub trait SecretStore: Send + Sync {
|
||||
/// 写入密钥。
|
||||
fn set(&self, service: &str, account: &str, secret: &str) -> Result<(), SecretStoreError>;
|
||||
/// 读取密钥。
|
||||
fn get(&self, service: &str, account: &str) -> Result<String, SecretStoreError>;
|
||||
/// 是否存在该密钥条目。
|
||||
fn has(&self, service: &str, account: &str) -> bool;
|
||||
/// 删除密钥条目。
|
||||
fn delete(&self, service: &str, account: &str) -> Result<(), SecretStoreError>;
|
||||
}
|
||||
|
||||
/// 构造 service 名:`agentdock.<cli_id>`。
|
||||
pub fn service_name(cli_id: &str) -> String {
|
||||
format!("agentdock.{cli_id}")
|
||||
}
|
||||
@@ -313,7 +313,8 @@ AdapterExecutor:
|
||||
os: windows | linux
|
||||
os_version: ...
|
||||
arch: x64 | other(reject)
|
||||
distro?: ubuntu + version
|
||||
distro?: string # 发行版名,如 ubuntu(Windows 为 None)
|
||||
distro_version?: string # 发行版版本,如 22.04(Wave 1 拆分:原 distro?: ubuntu + version)
|
||||
shells: powershell_version?, bash_available?
|
||||
runtimes: { node?, npm?, python?, uv?, git?, winget?, apt? }
|
||||
path_entries: [...]
|
||||
@@ -331,6 +332,7 @@ capabilities: { keyring: ok|missing, can_elevate: bool }
|
||||
| 密钥库 | Credential Manager 可用性 | `secret-tool` / DBus Secret Service |
|
||||
|
||||
检测失败分类(对齐 FR-02):`not_installed` | `not_in_path` | `permission_denied` | `exec_failed` | `version_unparseable`。
|
||||
Wave 1 已把 `permission_denied` 从一般执行失败中拆分为独立状态(`std::io::ErrorKind::PermissionDenied` 单独归类),`RuntimeInfo.status` 相应支持该值。
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user