1 Commits
30 changed files with 1594 additions and 124 deletions
+48 -1
View File
@@ -82,6 +82,7 @@
"properties": { "properties": {
"homepage": { "type": "string", "format": "uri" }, "homepage": { "type": "string", "format": "uri" },
"docs": { "type": "string", "format": "uri" }, "docs": { "type": "string", "format": "uri" },
"icon": { "type": "string", "format": "uri", "description": "官方图标地址(识别/许可说明与本地打包来源,不热链)" },
"allowed_hosts": { "allowed_hosts": {
"type": "array", "type": "array",
"items": { "type": "string" }, "items": { "type": "string" },
@@ -136,7 +137,17 @@
"type": "string", "type": "string",
"enum": ["npm_update", "self_update_cmd", "channel_reinstall", "winget_upgrade", "pypi_upgrade", "manual"] "enum": ["npm_update", "self_update_cmd", "channel_reinstall", "winget_upgrade", "pypi_upgrade", "manual"]
}, },
"command": { "type": "array", "items": { "type": "string" } } "command": { "type": "array", "items": { "type": "string" } },
"source": {
"type": "object",
"additionalProperties": false,
"required": ["kind"],
"properties": {
"kind": { "type": "string", "enum": ["npm", "pypi", "github"] },
"package": { "type": "string", "description": "npm / pypi 包名" },
"repo": { "type": "string", "description": "GitHub 仓库 owner/repo" }
}
}
} }
}, },
"uninstall": { "uninstall": {
@@ -166,6 +177,22 @@
"notes_zh": { "type": "string" } "notes_zh": { "type": "string" }
} }
} }
},
"credential_files": {
"type": "array",
"items": { "type": "string" },
"description": "登录态/OAuth 凭据文件或目录(存在任一即视为已授权,如 kimi-code ~/.kimi-code/credentials"
},
"test": {
"type": "object",
"additionalProperties": false,
"required": ["url"],
"properties": {
"url": { "type": "string", "format": "uri", "description": "测试连通端点 URL" },
"key_header": { "type": "string", "description": "API Key 注入头名(如 x-api-key / Authorization" },
"bearer": { "type": "boolean", "default": false, "description": "Authorization 头是否带 Bearer 前缀" },
"extra_headers": { "type": "array", "items": { "type": "string" }, "description": "额外请求头" }
}
} }
} }
}, },
@@ -286,6 +313,26 @@
"updated_at": { "type": "string", "format": "date" }, "updated_at": { "type": "string", "format": "date" },
"risks_zh": { "type": "array", "items": { "type": "string" } } "risks_zh": { "type": "array", "items": { "type": "string" } }
} }
},
"models": {
"type": "object",
"additionalProperties": false,
"description": "该 CLI 可加载的模型(模型列表来源:CLI 自带列举命令或适配器按官方文档维护的清单)",
"properties": {
"command": { "type": "array", "items": { "type": "string" }, "description": "CLI 自带的模型列举命令(如 agent --list-models" },
"list": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id"],
"properties": {
"id": { "type": "string" },
"label_zh": { "type": "string" }
}
}
}
}
} }
}, },
"definitions": { "definitions": {
+3
View File
@@ -63,6 +63,9 @@ detect:
update: update:
method: pypi_upgrade method: pypi_upgrade
command: [uv, tool, upgrade, aider-chat] command: [uv, tool, upgrade, aider-chat]
source:
kind: pypi
package: aider-chat
uninstall: uninstall:
method: package_manager method: package_manager
+18 -1
View File
@@ -22,7 +22,7 @@ platforms:
official: official:
homepage: https://claude.ai homepage: https://claude.ai
docs: https://code.claude.com/docs/en/setup docs: https://code.claude.com/docs/en/setup
allowed_hosts: [claude.ai, registry.npmjs.org, github.com] allowed_hosts: [claude.ai, registry.npmjs.org, github.com, api.anthropic.com]
runtime_deps: [] runtime_deps: []
@@ -57,6 +57,9 @@ detect:
update: update:
method: winget_upgrade method: winget_upgrade
command: [winget, upgrade, Anthropic.ClaudeCode] command: [winget, upgrade, Anthropic.ClaudeCode]
source:
kind: npm
package: "@anthropic-ai/claude-code"
uninstall: uninstall:
method: package_manager method: package_manager
@@ -71,6 +74,11 @@ authorization:
- mode: api_key - mode: api_key
env_keys: [ANTHROPIC_API_KEY] env_keys: [ANTHROPIC_API_KEY]
notes_zh: 设置 ANTHROPIC_API_KEY 后 CLI 优先使用 API Key notes_zh: 设置 ANTHROPIC_API_KEY 后 CLI 优先使用 API Key
test:
url: https://api.anthropic.com/v1/models
key_header: x-api-key
extra_headers:
- "anthropic-version: 2023-06-01"
# 配置机制(调研底稿 §2「配置机制」):用户级 ~/.claude/settings.jsonJSON # 配置机制(调研底稿 §2「配置机制」):用户级 ~/.claude/settings.jsonJSON
# 官方确认:settings.json 内 env 块可注入环境变量;ANTHROPIC_API_KEY / ANTHROPIC_BASE_URL / # 官方确认:settings.json 内 env 块可注入环境变量;ANTHROPIC_API_KEY / ANTHROPIC_BASE_URL /
@@ -116,6 +124,15 @@ configuration:
storage: keyring storage: keyring
docs_url: https://code.claude.com/docs/en/env-vars docs_url: https://code.claude.com/docs/en/env-vars
models:
list:
- id: claude-sonnet-4
label_zh: Claude Sonnet 4
- id: claude-opus-4
label_zh: Claude Opus 4
- id: claude-haiku-4
label_zh: Claude Haiku 4
diagnostics: diagnostics:
- rule_id: path.not_installed - rule_id: path.not_installed
- rule_id: path.not_in_path - rule_id: path.not_in_path
+3
View File
@@ -51,6 +51,9 @@ detect:
update: update:
method: npm_update method: npm_update
command: [npm, update, -g, "cline"] command: [npm, update, -g, "cline"]
source:
kind: npm
package: cline
uninstall: uninstall:
method: npm_uninstall method: npm_uninstall
+10
View File
@@ -54,6 +54,9 @@ detect:
update: update:
method: npm_update method: npm_update
command: [npm, update, -g, "@tencent-ai/codebuddy-code"] command: [npm, update, -g, "@tencent-ai/codebuddy-code"]
source:
kind: npm
package: "@tencent-ai/codebuddy-code"
uninstall: uninstall:
method: npm_uninstall method: npm_uninstall
@@ -112,6 +115,13 @@ configuration:
storage: file storage: file
docs_url: https://codebuddy.ai/docs docs_url: https://codebuddy.ai/docs
models:
list:
- id: deepseek-v4-pro
label_zh: DeepSeek V4 Pro
- id: glm-5.3
label_zh: GLM 5.3
diagnostics: diagnostics:
- rule_id: path.not_installed - rule_id: path.not_installed
- rule_id: path.not_in_path - rule_id: path.not_in_path
+15 -1
View File
@@ -21,7 +21,7 @@ platforms:
official: official:
homepage: https://github.com/openai/codex homepage: https://github.com/openai/codex
docs: https://learn.chatgpt.com/docs/codex/cli docs: https://learn.chatgpt.com/docs/codex/cli
allowed_hosts: [chatgpt.com, github.com, releases.openai.com, registry.npmjs.org] allowed_hosts: [chatgpt.com, github.com, api.github.com, releases.openai.com, registry.npmjs.org, api.openai.com]
runtime_deps: [] runtime_deps: []
@@ -56,6 +56,9 @@ detect:
update: update:
method: npm_update method: npm_update
command: [npm, update, -g, "@openai/codex"] command: [npm, update, -g, "@openai/codex"]
source:
kind: npm
package: "@openai/codex"
uninstall: uninstall:
method: npm_uninstall method: npm_uninstall
@@ -75,6 +78,10 @@ authorization:
command: [codex, login, --with-api-key] command: [codex, login, --with-api-key]
env_keys: [OPENAI_API_KEY] env_keys: [OPENAI_API_KEY]
notes_zh: API Key 从系统密钥库读取,经 stdin 注入,不进 argv notes_zh: API Key 从系统密钥库读取,经 stdin 注入,不进 argv
test:
url: https://api.openai.com/v1/models
key_header: Authorization
bearer: true
# 配置机制(调研底稿 §1「配置机制」):用户级 ~/.codex/config.tomlTOML # 配置机制(调研底稿 §1「配置机制」):用户级 ~/.codex/config.tomlTOML
# 官方确认可配:model / approval_policy / sandbox_mode / model_reasoning_effort / # 官方确认可配:model / approval_policy / sandbox_mode / model_reasoning_effort /
@@ -136,6 +143,13 @@ configuration:
storage: keyring storage: keyring
docs_url: https://learn.chatgpt.com/docs/config-file/config-advanced docs_url: https://learn.chatgpt.com/docs/config-file/config-advanced
models:
list:
- id: gpt-5.6-terra
label_zh: GPT-5.6 Terra(默认)
- id: gpt-5
label_zh: GPT-5
diagnostics: diagnostics:
- rule_id: path.not_installed - rule_id: path.not_installed
- rule_id: path.not_in_path - rule_id: path.not_in_path
+3
View File
@@ -50,6 +50,9 @@ detect:
update: update:
method: winget_upgrade method: winget_upgrade
command: [winget, upgrade, GitHub.Copilot] command: [winget, upgrade, GitHub.Copilot]
source:
kind: npm
package: "@github/copilot"
uninstall: uninstall:
method: package_manager method: package_manager
+4 -1
View File
@@ -22,7 +22,7 @@ platforms:
official: official:
homepage: https://github.com/charmbracelet/crush homepage: https://github.com/charmbracelet/crush
docs: https://github.com/charmbracelet/crush docs: https://github.com/charmbracelet/crush
allowed_hosts: [github.com, charm.sh] allowed_hosts: [github.com, api.github.com, charm.sh]
runtime_deps: [] runtime_deps: []
@@ -62,6 +62,9 @@ detect:
update: update:
method: winget_upgrade method: winget_upgrade
command: [winget, upgrade, charmbracelet.crush] command: [winget, upgrade, charmbracelet.crush]
source:
kind: github
repo: charmbracelet/crush
uninstall: uninstall:
method: package_manager method: package_manager
+3
View File
@@ -111,6 +111,9 @@ configuration:
storage: keyring storage: keyring
docs_url: https://cursor.com/docs/cli docs_url: https://cursor.com/docs/cli
models:
command: [agent, --list-models]
diagnostics: diagnostics:
- rule_id: path.not_installed - rule_id: path.not_installed
- rule_id: path.not_in_path - rule_id: path.not_in_path
+12
View File
@@ -46,6 +46,9 @@ detect:
update: update:
method: npm_update method: npm_update
command: [npm, update, -g, "@google/gemini-cli"] command: [npm, update, -g, "@google/gemini-cli"]
source:
kind: npm
package: "@google/gemini-cli"
uninstall: uninstall:
method: npm_uninstall method: npm_uninstall
@@ -102,6 +105,15 @@ configuration:
storage: keyring storage: keyring
docs_url: https://geminicli.com/docs/get-started/authentication/ docs_url: https://geminicli.com/docs/get-started/authentication/
models:
list:
- id: gemini-3-pro
label_zh: Gemini 3 Pro
- id: gemini-2.5-flash
label_zh: Gemini 2.5 Flash
- id: gemini-2.5-pro
label_zh: Gemini 2.5 Pro
diagnostics: diagnostics:
- rule_id: path.not_installed - rule_id: path.not_installed
- rule_id: path.not_in_path - rule_id: path.not_in_path
+4 -1
View File
@@ -22,7 +22,7 @@ platforms:
official: official:
homepage: https://block.github.io/goose/ homepage: https://block.github.io/goose/
docs: https://block.github.io/goose/docs/ docs: https://block.github.io/goose/docs/
allowed_hosts: [github.com, raw.githubusercontent.com, block.github.io] allowed_hosts: [github.com, api.github.com, raw.githubusercontent.com, block.github.io]
runtime_deps: [] runtime_deps: []
@@ -56,6 +56,9 @@ detect:
update: update:
method: self_update_cmd method: self_update_cmd
command: [goose, update] command: [goose, update]
source:
kind: github
repo: aaif-goose/goose
uninstall: uninstall:
method: manual_delete method: manual_delete
+88 -77
View File
@@ -1,57 +1,52 @@
# ============================================================ # ============================================================
# AgentDock 适配器 · Kimi CLI(月之暗面 Moonshot)(Wave 2.1 # AgentDock 适配器 · Kimi Code CLI(月之暗面 Moonshot)(Wave 3.1 换代
# 数据依据:调研底稿 agent-cli-survey-2026-08-24.mdKimi 章节)+ 架构 §3.3 # 换代依据:老板实测官方现行安装已切换至 kimi-code 线,本适配器弃用旧 kimi-cli(PyPI)线。
# 铁律:只走官方 code.kimi.com 脚本 / PyPI kimi-cli,严禁 npm(仿名包)。 # 核对来源(当日官方页面为准,2026-08-25):
# - 仓库/READMEhttps://github.com/MoonshotAI/kimi-code
# - 官方文档:https://moonshotai.github.io/kimi-code/en/
# - 配置:https://moonshotai.github.io/kimi-code/en/configuration/config-files.html
# - 数据位置:https://moonshotai.github.io/kimi-code/en/configuration/data-locations.html
# ============================================================ # ============================================================
id: kimi id: kimi
name: Kimi CLI name: Kimi Code CLI
name_zh: Kimi CLI name_zh: Kimi Code CLI
vendor: 月之暗面 vendor: 月之暗面
status: available status: available
adapter_version: 1.1.0 adapter_version: 1.2.0
license: Apache-2.0 license: MIT
platforms: platforms:
windows: windows:
architectures: [x64] architectures: [x64]
notes: 原生支持(PowerShell 脚本安装,非 WSL notes: 原生支持(官方 PowerShell 脚本安装,需 Git for Windows 作 shell 环境
linux: linux:
distributions: [ubuntu] distributions: [ubuntu]
architectures: [x64] architectures: [x64]
official: official:
homepage: https://github.com/MoonshotAI/kimi-cli homepage: https://github.com/MoonshotAI/kimi-code
docs: https://moonshotai.github.io/kimi-cli/en/guides/getting-started.html docs: https://moonshotai.github.io/kimi-code/en/
allowed_hosts: [code.kimi.com, pypi.org, github.com] allowed_hosts: [code.kimi.com, moonshotai.github.io, github.com, api.github.com, api.kimi.com, api.moonshot.cn]
runtime_deps: runtime_deps:
- id: uv - id: git
semver_range: ">=0" semver_range: ">=0"
required_for: [install]
- id: python
semver_range: ">=3.12"
required_for: [run] required_for: [run]
install: install:
preferred: pypi_uv preferred: official_script
channels: channels:
- id: pypi_uv
platforms: [windows, linux]
command: [uv, tool, install, kimi-cli]
package: kimi-cli
elevate: never
post_checks: [detect]
- id: official_script - id: official_script
platforms: [windows] platforms: [windows]
script: script:
url: https://code.kimi.com/install.ps1 url: https://code.kimi.com/kimi-code/install.ps1
kind: powershell_irm kind: powershell_irm
elevate: never elevate: never
post_checks: [detect] post_checks: [detect]
- id: official_script - id: official_script
platforms: [linux] platforms: [linux]
script: script:
url: https://code.kimi.com/install.sh url: https://code.kimi.com/kimi-code/install.sh
kind: bash_pipe kind: bash_pipe
elevate: never elevate: never
post_checks: [detect] post_checks: [detect]
@@ -61,110 +56,126 @@ detect:
version_args: ["--version"] version_args: ["--version"]
update: update:
method: pypi_upgrade method: manual
command: [uv, tool, upgrade, kimi-cli] command: []
source:
kind: github
repo: MoonshotAI/kimi-code
uninstall: uninstall:
method: package_manager method: manual_delete
command: [uv, tool, uninstall, kimi-cli] command: []
keep_config_default: true keep_config_default: true
authorization: authorization:
modes: modes:
- mode: browser_oauth - mode: browser_oauth
command: [kimi, login] command: [kimi]
notes_zh: Kimi Code 浏览器 OAuth(推荐) notes_zh: 运行 kimi 后在界面输入 /login,选 Kimi Code OAuth(推荐)
- mode: api_key - mode: api_key
env_keys: [KIMI_API_KEY] notes_zh: Moonshot 开放平台 API Key(官方写入 config.toml providersAgentDock 密钥存系统密钥库)
notes_zh: Moonshot 开放平台 API Key credential_files:
- "~/.kimi-code/credentials"
test:
url: https://api.moonshot.cn/v1/models
key_header: Authorization
bearer: true
# 配置机制(调研底稿 Kimi 章节):~/.kimi/config.tomlTOML兼容 JSON # 配置机制(官方 config-files 文档):~/.kimi-code/config.tomlTOMLsnake_case
# 可配 default_model、providerstype/base_url/api_key)、models 等 # 常用字段:default_model / default_permission_modemanual|yolo|auto/ default_plan_mode
# 环境变量:KIMI_API_KEY / KIMI_BASE_URL / KIMI_MODEL_NAME / KIMI_MODEL_TEMPERATURE # providers 表(type/base_url/api_key)、models 表(模型别名)。默认模型别名必须定义在 models 表
# 环境变量:KIMI_CODE_HOME(数据目录)、KIMI_MODEL_*(临时模型),密钥不自动从 shell 环境变量读取。
configuration: configuration:
files: files:
- path: "~/.kimi/config.toml" - path: "~/.kimi-code/config.toml"
format: toml format: toml
scope: user scope: user
environment: environment: []
- key: KIMI_API_KEY
sensitive: true
maps_to_field: api_key
fields: fields:
- id: default_model - id: default_model
label_zh: 默认模型 label_zh: 默认模型
group: common group: common
help_zh: 官方键 default_modelKimi CLI 使用的默认模型 help_zh: 官方键 default_model(模型别名,须已在 models 表定义),如 kimi-code/k3
required: false required: false
sensitive: false sensitive: false
type: string type: string
storage: file storage: file
docs_url: https://moonshotai.github.io/kimi-cli/en/configuration/config-files.html docs_url: https://moonshotai.github.io/kimi-code/en/configuration/config-files.html
- id: env.KIMI_BASE_URL - id: default_permission_mode
label_zh: Base URL label_zh: 权限模式
group: advanced group: advanced
help_zh: 官方环境变量 KIMI_BASE_URL(自定义 OpenAI 兼容端点);写入 config.toml 的 env 表,若需进程级生效请以官方 env-vars 文档为准 help_zh: 官方键 default_permission_mode,新会话默认权限模式
required: false required: false
sensitive: false sensitive: false
type: url type: enum
storage: file storage: file
docs_url: https://moonshotai.github.io/kimi-cli/en/configuration/env-vars.html docs_url: https://moonshotai.github.io/kimi-code/en/configuration/config-files.html
options:
- value: manual
label_zh: 手动确认(每次询问)
- value: yolo
label_zh: 自动放行(可能仍会提问)
- value: auto
label_zh: 全自动(自主决定)
- id: api_key - id: api_key
label_zh: API Key label_zh: API Key
group: auth group: auth
help_zh: 存入系统密钥库(对应环境变量 KIMI_API_KEYMoonshot 开放平台获取 help_zh: 存入系统密钥库(Moonshot 开放平台 API Key;官方也支持写入 config.toml providers.api_keyAgentDock 密钥不落明文
required: false required: false
sensitive: true sensitive: true
type: string type: string
storage: keyring storage: keyring
docs_url: https://moonshotai.github.io/kimi-cli/en/configuration/env-vars.html docs_url: https://moonshotai.github.io/kimi-code/en/configuration/config-files.html
# 模型清单(官方 config-files 示例中 /login 预置的 managed:kimi-code 别名)
models:
list:
- id: kimi-code/k3
label_zh: K3(旗舰推理)
- id: kimi-code/kimi-for-coding
label_zh: Kimi for Coding(均衡)
- id: kimi-code/kimi-for-coding-highspeed
label_zh: Kimi for Coding 高速版
diagnostics: diagnostics:
- rule_id: path.not_installed - rule_id: path.not_installed
- rule_id: path.not_in_path - rule_id: path.not_in_path
- rule_id: dependency.uv - rule_id: dependency.git
- rule_id: dependency.python_below_min
- rule_id: version_conflict.multiple_copies - rule_id: version_conflict.multiple_copies
- rule_id: config.corrupt - rule_id: config.corrupt
documentation: documentation:
quickstart_zh: 安装后运行 `kimi login` 登录,或用 `kimi -p "提示词"` 无头执行。 quickstart_zh: 安装后运行 `kimi` 进入交互界面,首次启动输入 `/login` 选 Kimi Code OAuth 或 Moonshot API Key;也可 `kimi -p "提示词"` 无头执行。
install_zh: 只走官方渠道:`uv tool install kimi-cli`(本适配器默认)或官方脚本 code.kimi.com/install.shinstall.ps1)。脚本会自动装 uv 并从 PyPI kimi-cli 安装。严禁 npmnpm 上的 kimi-code 是第三方仿名包)。 install_zh: 只走官方脚本:Windows `irm https://code.kimi.com/kimi-code/install.ps1 | iex`macOS/Linux `curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash`(单二进制发行,无需 Node.js)。Windows 首次启动前需装 Git for Windows(作 shell 环境)。
auth_zh: ① Kimi Code 浏览器 OAuth(推荐,`kimi login`);② Moonshot 开放平台 API Key(platform 侧获取后粘贴,经 KIMI_API_KEY 注入)。`kimi logout` 清除 OAuth 凭据 auth_zh: ① Kimi Code OAuth(推荐,TUI 内 /login);② Moonshot 开放平台 API Key。登录凭据存于 `~/.kimi-code/credentials/`/logout 清除。
commands: commands:
- cmd: kimi - cmd: kimi
desc_zh: 启动交互式会话 desc_zh: 启动交互式会话TUI
- cmd: kimi login
desc_zh: 登录(浏览器 OAuth 或 API Key
- cmd: kimi logout
desc_zh: 清除 OAuth 凭据
- cmd: kimi -p "提示词" - cmd: kimi -p "提示词"
desc_zh: 无头单次执行 desc_zh: 无头单次执行print 模式)
- cmd: kimi --output-format stream-json -p "提示词"
desc_zh: print 模式 JSON 流输出
- cmd: kimi --quiet
desc_zh: 静默模式
- cmd: kimi --version - cmd: kimi --version
desc_zh: 显示版本号 desc_zh: 显示版本号
- cmd: kimi info
desc_zh: 显示版本与协议信息
- cmd: kimi acp - cmd: kimi acp
desc_zh: IDE 集成 stdioACP 协议) desc_zh: IDE 集成 stdioACP 协议)
- cmd: /login
desc_zh: 登录(Kimi Code OAuth 或 Moonshot API Key
- cmd: /model
desc_zh: 切换模型
- cmd: /mcp-config
desc_zh: 配置 MCP 服务器
params: params:
- param: -p / --prompt - param: -p / --print
desc_zh: 处理后退出(无头模式) desc_zh: 无头模式,执行后退出
- param: --print
desc_zh: 打印模式
- param: --output-format - param: --output-format
desc_zh: 输出格式(如 stream-json desc_zh: 输出格式(如 stream-json
- param: -m / --model
desc_zh: 指定模型别名
- param: --quiet - param: --quiet
desc_zh: 静默模式 desc_zh: 静默模式
- param: --afk - param: --session
desc_zh: 离键模式 desc_zh: 恢复指定会话
- param: --config-file
desc_zh: 指定配置文件路径
updated_at: "2026-08-25" updated_at: "2026-08-25"
risks_zh: risks_zh:
- 严禁 npmnpm 上的 kimi-code 是第三方仿名包,与月之暗面无关 - 已换代:旧 PyPI `kimi-cli` 线废止,官方现行安装为 kimi-code 脚本(本适配器 1.2.0 已改)
- 官方脚本会自动安装 uv 并从 PyPI kimi-cli 安装(Python 3.123.14 - 官方密钥不自动从 shell 环境变量读取,需写入 config.toml providers 表;AgentDock 密钥存系统密钥库(测试连通直连官方接口),OAuth 登录为推荐路径
- providers 表(type/base_url/api_key 多 provider)等高级配置本表单不覆盖,以官方 config-files 文档为准 - 模型别名与 providers/models 高级配置以官方 config-files 文档为准,本表单不覆盖
+3
View File
@@ -62,6 +62,9 @@ detect:
update: update:
method: self_update_cmd method: self_update_cmd
command: [opencode, upgrade] command: [opencode, upgrade]
source:
kind: npm
package: opencode-ai
uninstall: uninstall:
method: npm_uninstall method: npm_uninstall
+3
View File
@@ -47,6 +47,9 @@ detect:
update: update:
method: npm_update method: npm_update
command: [npm, update, -g, "@qwen-code/qwen-code"] command: [npm, update, -g, "@qwen-code/qwen-code"]
source:
kind: npm
package: "@qwen-code/qwen-code"
uninstall: uninstall:
method: npm_uninstall method: npm_uninstall
+61 -1
View File
@@ -7,7 +7,7 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use agentdock_adapter::{AdapterAction, DryRunPlan}; use agentdock_adapter::{AdapterAction, DryRunPlan};
use agentdock_core::{ActionEvent, ActionOpts, AuthFlowEvent, AuthStatus, ConfigFormState, ConfigVerifyResult, DetectResult, Engine, WriteResult}; use agentdock_core::{ActionEvent, ActionOpts, AuthFlowEvent, AuthStatus, ConfigFormState, ConfigVerifyResult, ConnectionTestResult, DetectResult, Engine, ModelListResult, SystemEnvReport, UpdateCheckResult, WriteResult};
use agentdock_diag::DiagnosticReport; use agentdock_diag::DiagnosticReport;
use serde_json::json; use serde_json::json;
use tauri::Emitter; use tauri::Emitter;
@@ -166,3 +166,63 @@ pub fn diagnose_all(state: tauri::State<'_, Engine>) -> Result<Vec<DiagnosticRep
let env = agentdock_platform::detect::detect_env(); let env = agentdock_platform::detect::detect_env();
state.diagnose_all(&env).map_err(|e| e.to_string()) state.diagnose_all(&env).map_err(|e| e.to_string())
} }
/// 系统环境诊断(Wave 3.1 Req 7):检查缺失/过低的系统依赖,标注影响哪些 CLI。
#[tauri::command(rename = "diagnoseSystem")]
pub fn diagnose_system(state: tauri::State<'_, Engine>) -> Result<SystemEnvReport, String> {
let env = agentdock_platform::detect::detect_env();
state.diagnose_system(&env).map_err(|e| e.to_string())
}
/// 「可更新」判定(Wave 3.1 Req 4):官方源最新版本 vs 本地已装版本对比。
#[tauri::command(rename = "checkUpdate")]
pub fn check_update(id: String, state: tauri::State<'_, Engine>) -> Result<UpdateCheckResult, String> {
state.check_update(&id).map_err(|e| e.to_string())
}
/// 模型列表(Wave 3.1 Req 5)。
#[tauri::command(rename = "listModels")]
pub fn list_models(id: String, state: tauri::State<'_, Engine>) -> Result<ModelListResult, String> {
state.list_models(&id).map_err(|e| e.to_string())
}
/// 测试连通(Wave 3.1 Req 5):密钥从密钥库读取直连官方接口测一次。
#[tauri::command(rename = "testConnection")]
pub fn test_connection(id: String, state: tauri::State<'_, Engine>) -> Result<ConnectionTestResult, String> {
state.test_connection(&id).map_err(|e| e.to_string())
}
/// 用系统默认浏览器打开外链(Wave 3.1 Req 8),目标主机须在白名单内。
#[tauri::command(rename = "openExternal")]
pub fn open_external(url: String, state: tauri::State<'_, Engine>) -> Result<(), String> {
let allowed: Vec<String> = state
.adapters()
.map(|ads| {
let mut hosts: Vec<String> = ads
.iter()
.flat_map(|a| a.official.as_ref().map(|o| o.allowed_hosts.clone()).unwrap_or_default())
.collect();
hosts.extend(well_known_docs_hosts().iter().cloned());
hosts
})
.unwrap_or_else(|_| well_known_docs_hosts());
if !agentdock_core::is_url_host_allowed(&url, &allowed) {
return Err("目标链接不在白名单内".to_string());
}
agentdock_core::open_with_shell(&url).map_err(|e| e.to_string())
}
/// 官方文档/主页常见主机(与适配器 allowed_hosts 合并,覆盖 docs.* 站点)。
fn well_known_docs_hosts() -> Vec<String> {
[
"opencode.ai", "moonshotai.github.io", "code.kimi.com", "github.com",
"code.claude.com", "geminicli.com", "qwen.ai", "codebuddy.ai", "docs.cline.bot",
"aider.chat", "cursor.com", "docs.warp.dev", "warp.dev", "block.github.io",
"charm.sh", "docs.github.com", "learn.chatgpt.com", "claude.ai",
"nodejs.org", "python.org", "git-scm.com", "docs.astral.sh", "learn.microsoft.com",
"aka.ms", "pypi.org", "registry.npmjs.org", "api.github.com", "chatgpt.com",
]
.iter()
.map(|s| s.to_string())
.collect()
}
+5
View File
@@ -60,6 +60,11 @@ pub fn run() {
commands::cli::cancel_authorize, commands::cli::cancel_authorize,
commands::cli::diagnose, commands::cli::diagnose,
commands::cli::diagnose_all, commands::cli::diagnose_all,
commands::cli::diagnose_system,
commands::cli::check_update,
commands::cli::list_models,
commands::cli::test_connection,
commands::cli::open_external,
commands::runtime::preview_runtime_install, commands::runtime::preview_runtime_install,
commands::runtime::open_runtime_page, commands::runtime::open_runtime_page,
commands::runtime::install_runtime, commands::runtime::install_runtime,
+30 -2
View File
@@ -30,7 +30,29 @@ function fallbackAbbr(name: string): string {
return name.replace(/\s+/g, "").slice(0, 2).toUpperCase(); return name.replace(/\s+/g, "").slice(0, 2).toUpperCase();
} }
/** CLI 单色字母头像(视觉规范 §7:双字母单色,--ad-bg-2 底 + 1px --ad-border */ /**
* 各工具官方图标主色(Wave 3.1 Req 10:按官方主色微调头像角标,不改动六色语义)。
* 官方图标二进制资源待打包波由美术师统一下载本地化;此处以主色作为识别配色过渡。
*/
const CLI_BRAND_COLORS: Record<string, string> = {
codex: "#10A37F",
"claude-code": "#D97757",
gemini: "#4285F4",
copilot: "#8957E5",
kimi: "#5B5BEE",
qwen: "#615CED",
codebuddy: "#0052D9",
opencode: "#7C5CFF",
crush: "#F4B860",
goose: "#E84393",
aider: "#FF6B6B",
cursor: "#4A4A4A",
cline: "#6B6BFF",
warp: "#00E0FF",
};
/** CLI 单色字母头像(视觉规范 §7:双字母单色,--ad-bg-2 底 + 1px --ad-border
* Wave 3.1:官方图标主色作为字母/角标识别配色(官方图标二进制待打包波本地化) */
export function CliMonogram({ export function CliMonogram({
id, id,
name, name,
@@ -41,10 +63,16 @@ export function CliMonogram({
size?: number; size?: number;
}) { }) {
const chars = CLI_MONOGRAMS[id] ?? fallbackAbbr(name); const chars = CLI_MONOGRAMS[id] ?? fallbackAbbr(name);
const accent = CLI_BRAND_COLORS[id];
return ( return (
<span <span
className="monogram" className="monogram"
style={{ width: size, height: size, fontSize: Math.round(size * 0.42) }} style={{
width: size,
height: size,
fontSize: Math.round(size * 0.42),
...(accent ? { color: accent, borderColor: `${accent}66` } : {}),
}}
aria-hidden="true" aria-hidden="true"
> >
{chars} {chars}
+95 -5
View File
@@ -1,10 +1,12 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Check, ChevronDown, CircleAlert, ExternalLink, Eye, EyeOff, Lock, ShieldCheck } from "lucide-react"; import { Check, ChevronDown, CircleAlert, ExternalLink, Eye, EyeOff, Lock, PlugZap, ShieldCheck } from "lucide-react";
import { readConfig, verifyConfig, writeConfig } from "../ipc"; import { listModels, readConfig, testConnection, verifyConfig, writeConfig, openExternal } from "../ipc";
import type { import type {
ConfigFieldState, ConfigFieldState,
ConfigFormState, ConfigFormState,
ConfigVerifyResult, ConfigVerifyResult,
ConnectionTestResult,
ModelListResult,
WriteResult, WriteResult,
} from "../ipc/types"; } from "../ipc/types";
import { AuthPanel } from "./AuthPanel"; import { AuthPanel } from "./AuthPanel";
@@ -12,8 +14,17 @@ import { MonoChip } from "./MonoChip";
/** 配置表单(视觉规范 §3.4:单列 ≤640px、敏感字段密码框 + 密钥库说明、吸底保存条) /** 配置表单(视觉规范 §3.4:单列 ≤640px、敏感字段密码框 + 密钥库说明、吸底保存条)
* Wave 2.1:字段按官方配置方法渲染 + 保存后「生效检查」 * Wave 2.1:字段按官方配置方法渲染 + 保存后「生效检查」
* Wave 2.2:授权/登录层改为可操作授权流程(AuthPanel) */ * Wave 2.2:授权/登录层改为可操作授权流程(AuthPanel)
export function ConfigForm({ id, onAuthChanged }: { id: string; onAuthChanged?: () => void }) { * Wave 3.1:默认模型 → 模型列表 + 测试连通 */
export function ConfigForm({
id,
onAuthChanged,
canTestConnection = false,
}: {
id: string;
onAuthChanged?: () => void;
canTestConnection?: boolean;
}) {
const [state, setState] = useState<ConfigFormState | null>(null); const [state, setState] = useState<ConfigFormState | null>(null);
const [values, setValues] = useState<Record<string, string>>({}); const [values, setValues] = useState<Record<string, string>>({});
const [revealed, setRevealed] = useState<Record<string, boolean>>({}); const [revealed, setRevealed] = useState<Record<string, boolean>>({});
@@ -24,6 +35,9 @@ export function ConfigForm({ id, onAuthChanged }: { id: string; onAuthChanged?:
const [verify, setVerify] = useState<ConfigVerifyResult | null>(null); const [verify, setVerify] = useState<ConfigVerifyResult | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [advancedOpen, setAdvancedOpen] = useState(false); const [advancedOpen, setAdvancedOpen] = useState(false);
const [models, setModels] = useState<ModelListResult | null>(null);
const [connTest, setConnTest] = useState<ConnectionTestResult | null>(null);
const [connBusy, setConnBusy] = useState(false);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -44,11 +58,29 @@ export function ConfigForm({ id, onAuthChanged }: { id: string; onAuthChanged?:
setLoading(false); setLoading(false);
} }
}); });
listModels(id)
.then((m) => {
if (!cancelled) setModels(m);
})
.catch(() => {});
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [id]); }, [id]);
async function runTestConnection() {
setConnBusy(true);
setConnTest(null);
try {
const r = await testConnection(id);
setConnTest(r);
} catch (e) {
setConnTest({ cli_id: id, ok: false, message_zh: String(e), http_status: null, detail: null });
} finally {
setConnBusy(false);
}
}
const sensitiveSaved = useMemo(() => { const sensitiveSaved = useMemo(() => {
const m: Record<string, boolean> = {}; const m: Record<string, boolean> = {};
state?.fields.forEach((f) => { state?.fields.forEach((f) => {
@@ -67,6 +99,8 @@ export function ConfigForm({ id, onAuthChanged }: { id: string; onAuthChanged?:
const advancedFields = state.fields.filter((f) => f.group === "advanced"); const advancedFields = state.fields.filter((f) => f.group === "advanced");
function renderField(field: ConfigFieldState) { function renderField(field: ConfigFieldState) {
const isModelField = field.field_type === "string" && field.id.toLowerCase().includes("model");
const suggestions = isModelField ? (models?.models.map((m) => m.id) ?? []) : [];
return ( return (
<Field <Field
key={field.id} key={field.id}
@@ -74,6 +108,7 @@ export function ConfigForm({ id, onAuthChanged }: { id: string; onAuthChanged?:
value={values[field.id] ?? ""} value={values[field.id] ?? ""}
saved={sensitiveSaved[field.id] ?? false} saved={sensitiveSaved[field.id] ?? false}
revealed={!!revealed[field.id]} revealed={!!revealed[field.id]}
suggestions={suggestions}
onToggleReveal={() => setRevealed((r) => ({ ...r, [field.id]: !r[field.id] }))} onToggleReveal={() => setRevealed((r) => ({ ...r, [field.id]: !r[field.id] }))}
onChange={(v) => setValues((x) => ({ ...x, [field.id]: v }))} onChange={(v) => setValues((x) => ({ ...x, [field.id]: v }))}
/> />
@@ -135,6 +170,46 @@ export function ConfigForm({ id, onAuthChanged }: { id: string; onAuthChanged?:
</section> </section>
)} )}
{/* 第二层:模型列表 + 测试连通(Wave 3.1 Req 5 */}
{(models && models.models.length > 0) || canTestConnection ? (
<section className="config-section">
<h4 className="config-section-title"></h4>
{models && models.models.length > 0 ? (
<div className="model-list">
<div className="model-list-rows">
{models.models.map((m) => (
<div key={m.id} className="model-row">
<MonoChip>{m.id}</MonoChip>
{m.label_zh && <span className="model-row-label">{m.label_zh}</span>}
</div>
))}
</div>
<span className="model-list-hint">{models.detail_zh}</span>
</div>
) : (
<p className="model-list-hint"></p>
)}
{canTestConnection && (
<div className="model-connect">
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={runTestConnection}
disabled={connBusy}
>
<PlugZap size={13} strokeWidth={1.5} aria-hidden="true" />
{connBusy ? "测试中…" : "测试连通"}
</button>
{connTest && (
<span className={`connect-test-result ${connTest.ok ? "ok" : "fail"}`}>
{connTest.message_zh}
</span>
)}
</div>
)}
</section>
) : null}
{/* 第二层:常用配置 */} {/* 第二层:常用配置 */}
{commonFields.length > 0 && ( {commonFields.length > 0 && (
<section className="config-section"> <section className="config-section">
@@ -222,6 +297,7 @@ function Field({
value, value,
saved, saved,
revealed, revealed,
suggestions,
onChange, onChange,
onToggleReveal, onToggleReveal,
}: { }: {
@@ -229,12 +305,14 @@ function Field({
value: string; value: string;
saved: boolean; saved: boolean;
revealed: boolean; revealed: boolean;
suggestions?: string[];
onChange: (v: string) => void; onChange: (v: string) => void;
onToggleReveal: () => void; onToggleReveal: () => void;
}) { }) {
const isPassword = field.sensitive; const isPassword = field.sensitive;
const isEnum = field.field_type === "enum"; const isEnum = field.field_type === "enum";
const inputType = isPassword ? (revealed ? "text" : "password") : "text"; const inputType = isPassword ? (revealed ? "text" : "password") : "text";
const datalistId = suggestions && suggestions.length > 0 ? `dl-${field.id}` : undefined;
return ( return (
<div className="field"> <div className="field">
<label className="field-label" htmlFor={`field-${field.id}`}> <label className="field-label" htmlFor={`field-${field.id}`}>
@@ -266,12 +344,20 @@ function Field({
id={`field-${field.id}`} id={`field-${field.id}`}
type={inputType} type={inputType}
value={value} value={value}
list={datalistId}
placeholder={isPassword ? (saved ? "已保存 · 留空则不变" : "输入 API Key") : ""} placeholder={isPassword ? (saved ? "已保存 · 留空则不变" : "输入 API Key") : ""}
autoComplete="off" autoComplete="off"
spellCheck={false} spellCheck={false}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
/> />
)} )}
{datalistId && (
<datalist id={datalistId}>
{suggestions!.map((m) => (
<option key={m} value={m} />
))}
</datalist>
)}
{isPassword && ( {isPassword && (
<button <button
type="button" type="button"
@@ -294,7 +380,11 @@ function Field({
</span> </span>
)} )}
{field.docs_url && ( {field.docs_url && (
<a className="field-docs-link" href={field.docs_url} target="_blank" rel="noreferrer"> <a
className="field-docs-link"
href={field.docs_url}
onClick={(e) => { e.preventDefault(); void openExternal(field.docs_url!); }}
>
<ExternalLink size={11} strokeWidth={1.5} aria-hidden="true" /> <ExternalLink size={11} strokeWidth={1.5} aria-hidden="true" />
</a> </a>
)} )}
+37
View File
@@ -10,12 +10,16 @@ import type {
CliActionEvent, CliActionEvent,
ConfigFormState, ConfigFormState,
ConfigVerifyResult, ConfigVerifyResult,
ConnectionTestResult,
DetectResult, DetectResult,
DiagnosticReport, DiagnosticReport,
DryRunPlan, DryRunPlan,
ModelListResult,
PlatformEnv, PlatformEnv,
RuntimeInstallEvent, RuntimeInstallEvent,
RuntimeSource, RuntimeSource,
SystemEnvReport,
UpdateCheckResult,
WriteResult, WriteResult,
} from "./types"; } from "./types";
@@ -106,6 +110,39 @@ export async function diagnoseAll(): Promise<DiagnosticReport[]> {
return invoke<DiagnosticReport[]>("diagnoseAll"); return invoke<DiagnosticReport[]>("diagnoseAll");
} }
/** 系统环境诊断(Wave 3.1 Req 7):检查缺失/过低的系统依赖,标注影响哪些 CLI。 */
export async function diagnoseSystem(): Promise<SystemEnvReport> {
if (!isTauri()) return { findings: [] };
return invoke<SystemEnvReport>("diagnoseSystem");
}
/** 「可更新」判定(Wave 3.1 Req 4):官方源最新版本 vs 本地已装版本。 */
export async function checkUpdate(id: string): Promise<UpdateCheckResult> {
if (!isTauri()) return { cli_id: id, current: null, latest: null, update_available: false, source_zh: "官方源", source_url: "", error: null };
return invoke<UpdateCheckResult>("checkUpdate", { id });
}
/** 模型列表(Wave 3.1 Req 5)。 */
export async function listModels(id: string): Promise<ModelListResult> {
if (!isTauri()) return { cli_id: id, models: [], source: "empty", detail_zh: "" };
return invoke<ModelListResult>("listModels", { id });
}
/** 测试连通(Wave 3.1 Req 5):密钥从密钥库读取直连官方接口。 */
export async function testConnection(id: string): Promise<ConnectionTestResult> {
if (!isTauri()) return { cli_id: id, ok: false, message_zh: "浏览器预览模式不支持测试连通", http_status: null, detail: null };
return invoke<ConnectionTestResult>("testConnection", { id });
}
/** 用系统默认浏览器打开外链(Wave 3.1 Req 8)。 */
export async function openExternal(url: string): Promise<void> {
if (!isTauri()) {
window.open(url, "_blank", "noopener,noreferrer");
return;
}
return invoke<void>("openExternal", { url });
}
/** 软件内授权:启动指定模式的授权流程(事件经 cli-auth-event 流式回传)。 */ /** 软件内授权:启动指定模式的授权流程(事件经 cli-auth-event 流式回传)。 */
export async function authorize(id: string, mode: string): Promise<void> { export async function authorize(id: string, mode: string): Promise<void> {
if (!isTauri()) return; if (!isTauri()) return;
+53 -2
View File
@@ -234,6 +234,51 @@ export interface RuntimeInstallEvent {
fallback?: boolean; fallback?: boolean;
} }
// ---- Wave 3.1:可更新判定 / 模型列表 / 测试连通 / 系统环境诊断 ----
export interface UpdateCheckResult {
cli_id: string;
current: string | null;
latest: string | null;
update_available: boolean;
source_zh: string;
source_url: string;
error: string | null;
}
export interface ModelInfo {
id: string;
label_zh: string | null;
}
export interface ModelListResult {
cli_id: string;
models: ModelInfo[];
source: string;
detail_zh: string;
}
export interface ConnectionTestResult {
cli_id: string;
ok: boolean;
message_zh: string;
http_status: number | null;
detail: string | null;
}
export interface SystemEnvFinding {
runtime_id: string;
label_zh: string;
status: string;
version: string | null;
affected_cli: string[];
message_zh: string;
}
export interface SystemEnvReport {
findings: SystemEnvFinding[];
}
// ---- 适配器完整定义(getAdapter 返回,detail 页展示用) ---- // ---- 适配器完整定义(getAdapter 返回,detail 页展示用) ----
export interface AdapterChannel { export interface AdapterChannel {
@@ -259,7 +304,7 @@ export interface Adapter {
windows?: { architectures?: string[]; notes?: string | null } | null; windows?: { architectures?: string[]; notes?: string | null } | null;
linux?: { distributions?: string[]; architectures?: string[]; min_ubuntu?: string | null } | null; linux?: { distributions?: string[]; architectures?: string[]; min_ubuntu?: string | null } | null;
} | null; } | null;
official?: { homepage?: string | null; docs?: string | null; allowed_hosts?: string[] } | null; official?: { homepage?: string | null; docs?: string | null; icon?: string | null; allowed_hosts?: string[] } | null;
runtime_deps?: { id: string; semver_range?: string | null; required_for?: string[] }[]; runtime_deps?: { id: string; semver_range?: string | null; required_for?: string[] }[];
install?: { preferred?: string | null; channels: AdapterChannel[] } | null; install?: { preferred?: string | null; channels: AdapterChannel[] } | null;
detect?: { detect?: {
@@ -269,7 +314,7 @@ export interface Adapter {
version_unconfirmed?: boolean | null; version_unconfirmed?: boolean | null;
path_hints?: string[]; path_hints?: string[];
} | null; } | null;
update?: { method?: string | null; command?: string[] } | null; update?: { method?: string | null; command?: string[]; source?: { kind: string; package?: string | null; repo?: string | null } | null } | null;
uninstall?: { method?: string | null; command?: string[]; keep_config_default?: boolean } | null; uninstall?: { method?: string | null; command?: string[]; keep_config_default?: boolean } | null;
authorization?: { authorization?: {
modes: { modes: {
@@ -279,6 +324,12 @@ export interface Adapter {
status_command?: string[]; status_command?: string[];
notes_zh?: string | null; notes_zh?: string | null;
}[]; }[];
credential_files?: string[];
test?: { url: string; key_header?: string | null; bearer?: boolean; extra_headers?: string[] } | null;
} | null;
models?: {
command?: string[];
list?: { id: string; label_zh?: string | null }[];
} | null; } | null;
configuration?: { configuration?: {
files: { path: string; format: string; scope?: string | null }[]; files: { path: string; format: string; scope?: string | null }[];
+48 -6
View File
@@ -1,7 +1,9 @@
import { useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Search } from "lucide-react"; import { Search } from "lucide-react";
import { useCatalog } from "../hooks/useCatalog"; import { useCatalog } from "../hooks/useCatalog";
import { useDetectAll } from "../hooks/useDetectAll"; import { useDetectAll } from "../hooks/useDetectAll";
import { checkUpdate } from "../ipc";
import type { UpdateCheckResult } from "../ipc/types";
import { StatusBadge } from "../components/StatusBadge"; import { StatusBadge } from "../components/StatusBadge";
import { CliMonogram } from "../components/CliMonogram"; import { CliMonogram } from "../components/CliMonogram";
import { MonoChip } from "../components/MonoChip"; import { MonoChip } from "../components/MonoChip";
@@ -22,13 +24,46 @@ const PLATFORM_FILTERS: { key: PlatformFilter; label: string }[] = [
{ key: "linux", label: "Linux" }, { key: "linux", label: "Linux" },
]; ];
/** CLI 目录页(视觉规范 §3.2 + PRD §7):搜索 + 筛选 + 14 张卡片(数据来自 catalog.yaml + 真机 detect */ /** CLI 目录页(视觉规范 §3.2 + PRD §7):搜索 + 筛选 + 14 张卡片(数据来自 catalog.yaml + 真机 detect + 官方源可更新判定 */
export function CatalogPage({ onOpenDetail }: { onOpenDetail: (id: string) => void }) { export function CatalogPage({ onOpenDetail }: { onOpenDetail: (id: string) => void }) {
const { entries, loading, error } = useCatalog(); const { entries, loading, error } = useCatalog();
const { detectMap, loading: detecting } = useDetectAll(); const { detectMap, loading: detecting } = useDetectAll();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [status, setStatus] = useState<StatusFilter>("all"); const [status, setStatus] = useState<StatusFilter>("all");
const [platform, setPlatform] = useState<PlatformFilter>("all"); const [platform, setPlatform] = useState<PlatformFilter>("all");
const [updates, setUpdates] = useState<Record<string, UpdateCheckResult>>({});
const [checkingUpdates, setCheckingUpdates] = useState(false);
// 「可更新」筛选:仅当用户点选时才对已装工具查询官方源最新版本(Wave 3.1 Req 4
useEffect(() => {
if (status !== "update" || checkingUpdates) return;
let cancelled = false;
setCheckingUpdates(true);
const installedIds = entries.filter((e) => detectMap[e.id]?.status === "installed").map((e) => e.id);
Promise.all(
installedIds.map(async (id) => {
try {
return await checkUpdate(id);
} catch {
return null;
}
}),
)
.then((results) => {
if (cancelled) return;
const m: Record<string, UpdateCheckResult> = {};
results.forEach((r, i) => {
if (r) m[installedIds[i]] = r;
});
setUpdates(m);
})
.finally(() => {
if (!cancelled) setCheckingUpdates(false);
});
return () => {
cancelled = true;
};
}, [status, entries, detectMap, checkingUpdates]);
const filtered = useMemo(() => { const filtered = useMemo(() => {
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
@@ -41,13 +76,13 @@ export function CatalogPage({ onOpenDetail }: { onOpenDetail: (id: string) => vo
// 状态筛选:接真机 detect 结果 // 状态筛选:接真机 detect 结果
if (status === "installed" && !installed) return false; if (status === "installed" && !installed) return false;
if (status === "uninstalled" && installed) return false; if (status === "uninstalled" && installed) return false;
// 可更新:Wave 2.1 不检测可更新,恒为空(诚实 // 可更新:官方源最新版本 vs 本地已装版本(真实数据,Wave 3.1 Req 4
if (status === "update") return false; if (status === "update") return updates[e.id]?.update_available === true;
// 平台:全部 14 个均支持 Windows + Ubuntu(调研底稿结论),筛选不改变集合 // 平台:全部 14 个均支持 Windows + Ubuntu(调研底稿结论),筛选不改变集合
if (platform === "windows" || platform === "linux") return true; if (platform === "windows" || platform === "linux") return true;
return true; return true;
}); });
}, [entries, query, status, platform, detectMap]); }, [entries, query, status, platform, detectMap, updates]);
return ( return (
<div className="catalog"> <div className="catalog">
@@ -128,11 +163,18 @@ export function CatalogPage({ onOpenDetail }: { onOpenDetail: (id: string) => vo
)} )}
<span className="catalog-platforms">Windows · Linux</span> <span className="catalog-platforms">Windows · Linux</span>
</div> </div>
{updates[entry.id]?.update_available && (
<div className="catalog-update">
{updates[entry.id].latest} {updates[entry.id].source_zh}
</div>
)}
</article> </article>
); );
})} })}
{!loading && filtered.length === 0 && ( {!loading && filtered.length === 0 && (
<p className="panel-empty catalog-empty"> CLI</p> <p className="panel-empty catalog-empty">
{status === "update" && checkingUpdates ? "正在查询官方源最新版本…" : "没有符合条件的 CLI"}
</p>
)} )}
</div> </div>
</div> </div>
+51 -7
View File
@@ -6,13 +6,14 @@ import {
ChevronDown, ChevronDown,
CircleHelp, CircleHelp,
ClipboardList, ClipboardList,
Copy,
Loader2, Loader2,
ShieldAlert, ShieldAlert,
Terminal, Terminal,
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import { useCliDetail } from "../hooks/useCliDetail"; import { useCliDetail } from "../hooks/useCliDetail";
import { diagnose, onCliAction, previewAction, runAction } from "../ipc"; import { diagnose, onCliAction, openExternal, previewAction, runAction } from "../ipc";
import type { import type {
ActionEvent, ActionEvent,
Adapter, Adapter,
@@ -249,7 +250,7 @@ export function CliDetailPage({
<section className="cli-detail-body"> <section className="cli-detail-body">
{tab === "overview" && <OverviewTab adapter={adapter} detect={detect} auth={auth} />} {tab === "overview" && <OverviewTab adapter={adapter} detect={detect} auth={auth} />}
{tab === "config" && <ConfigForm id={id} onAuthChanged={refresh} />} {tab === "config" && <ConfigForm id={id} onAuthChanged={refresh} canTestConnection={adapter.authorization?.test != null} />}
{tab === "docs" && <DocsTab adapter={adapter} />} {tab === "docs" && <DocsTab adapter={adapter} />}
{tab === "diag" && <DiagTab id={id} />} {tab === "diag" && <DiagTab id={id} />}
</section> </section>
@@ -552,12 +553,20 @@ function OverviewTab({
<h3 className="detail-block-title"></h3> <h3 className="detail-block-title"></h3>
<div className="detail-links"> <div className="detail-links">
{adapter.official?.homepage && ( {adapter.official?.homepage && (
<a className="detail-link" href={adapter.official.homepage} target="_blank" rel="noreferrer"> <a
className="detail-link"
href={adapter.official.homepage}
onClick={(e) => { e.preventDefault(); void openExternal(adapter.official!.homepage!); }}
>
</a> </a>
)} )}
{adapter.official?.docs && ( {adapter.official?.docs && (
<a className="detail-link" href={adapter.official.docs} target="_blank" rel="noreferrer"> <a
className="detail-link"
href={adapter.official.docs}
onClick={(e) => { e.preventDefault(); void openExternal(adapter.official!.docs!); }}
>
</a> </a>
)} )}
@@ -589,6 +598,37 @@ function authModeLabel(mode: string): string {
} }
} }
/** 可复制命令块(Wave 3.1 Req 9):hover 高亮 + 点击复制 + 复制成功轻提示 */
function CopyableCommand({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
async function copy() {
try {
await navigator.clipboard.writeText(text);
} catch {
const ta = document.createElement("textarea");
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
}
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
}
return (
<button type="button" className="command-copy" onClick={copy} title="点击复制命令" aria-label={`复制命令 ${text}`}>
<MonoChip>{text}</MonoChip>
{copied ? (
<span className="command-copied">
<Check size={12} strokeWidth={1.5} aria-hidden="true" />
</span>
) : (
<Copy size={12} strokeWidth={1.5} aria-hidden="true" />
)}
</button>
);
}
function DocsTab({ adapter }: { adapter: Adapter }) { function DocsTab({ adapter }: { adapter: Adapter }) {
const doc = adapter.documentation; const doc = adapter.documentation;
const docUrl = adapter.official?.docs; const docUrl = adapter.official?.docs;
@@ -622,7 +662,7 @@ function DocsTab({ adapter }: { adapter: Adapter }) {
<ul className="detail-commands"> <ul className="detail-commands">
{doc!.commands!.map((c) => ( {doc!.commands!.map((c) => (
<li key={c.cmd} className="detail-command"> <li key={c.cmd} className="detail-command">
<MonoChip>{c.cmd}</MonoChip> <CopyableCommand text={c.cmd} />
{c.desc_zh && <span className="detail-command-desc">{c.desc_zh}</span>} {c.desc_zh && <span className="detail-command-desc">{c.desc_zh}</span>}
</li> </li>
))} ))}
@@ -635,7 +675,7 @@ function DocsTab({ adapter }: { adapter: Adapter }) {
<ul className="detail-commands"> <ul className="detail-commands">
{doc!.params!.map((p) => ( {doc!.params!.map((p) => (
<li key={p.param} className="detail-command"> <li key={p.param} className="detail-command">
<MonoChip>{p.param}</MonoChip> <CopyableCommand text={p.param} />
{p.desc_zh && <span className="detail-command-desc">{p.desc_zh}</span>} {p.desc_zh && <span className="detail-command-desc">{p.desc_zh}</span>}
</li> </li>
))} ))}
@@ -654,7 +694,11 @@ function DocsTab({ adapter }: { adapter: Adapter }) {
)} )}
<div className="detail-block detail-doc-footer"> <div className="detail-block detail-doc-footer">
{docUrl && ( {docUrl && (
<a className="detail-link" href={docUrl} target="_blank" rel="noreferrer"> <a
className="detail-link"
href={docUrl}
onClick={(e) => { e.preventDefault(); void openExternal(docUrl); }}
>
</a> </a>
)} )}
+105 -5
View File
@@ -1,9 +1,9 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Activity, Archive, Download, Loader2, Lock, Plus, ScanSearch } from "lucide-react"; import { Activity, Archive, Cpu, Download, Loader2, Lock, Plus, ScanSearch } from "lucide-react";
import { useEnv } from "../hooks/useEnv"; import { useEnv } from "../hooks/useEnv";
import { useCatalog } from "../hooks/useCatalog"; import { useCatalog } from "../hooks/useCatalog";
import { useDetectAll } from "../hooks/useDetectAll"; import { useDetectAll } from "../hooks/useDetectAll";
import { diagnoseAll } from "../ipc"; import { diagnoseAll, diagnoseSystem } from "../ipc";
import type { import type {
CatalogEntry, CatalogEntry,
DetectResult, DetectResult,
@@ -11,6 +11,7 @@ import type {
PlatformEnv, PlatformEnv,
RuntimeInfo, RuntimeInfo,
RuntimeStatus, RuntimeStatus,
SystemEnvReport,
} from "../ipc/types"; } from "../ipc/types";
import { KpiCard } from "../components/KpiCard"; import { KpiCard } from "../components/KpiCard";
import { MonoChip } from "../components/MonoChip"; import { MonoChip } from "../components/MonoChip";
@@ -82,8 +83,8 @@ function RuntimeItem({
<span className="runtime-missing">{meta.missingText}</span> <span className="runtime-missing">{meta.missingText}</span>
)} )}
{meta.needsInstall && ( {meta.needsInstall && (
<button type="button" className="link-btn runtime-action" onClick={() => onInstall(name)}> <button type="button" className="btn btn-secondary btn-sm runtime-action" onClick={() => onInstall(name)}>
<Download size={12} strokeWidth={1.5} aria-hidden="true" /> <Download size={13} strokeWidth={1.5} aria-hidden="true" />
</button> </button>
)} )}
</div> </div>
@@ -92,8 +93,10 @@ function RuntimeItem({
function EnvPanel({ function EnvPanel({
onInstallRuntime, onInstallRuntime,
onSystemDiagnose,
}: { }: {
onInstallRuntime: (runtime: string) => void; onInstallRuntime: (runtime: string) => void;
onSystemDiagnose: () => void;
}) { }) {
const { env, loading } = useEnv(); const { env, loading } = useEnv();
const [showAllPath, setShowAllPath] = useState(false); const [showAllPath, setShowAllPath] = useState(false);
@@ -132,6 +135,9 @@ function EnvPanel({
<div className="panel-head"> <div className="panel-head">
<h2 className="panel-title"></h2> <h2 className="panel-title"></h2>
{loading && <span className="panel-hint"></span>} {loading && <span className="panel-hint"></span>}
<button type="button" className="btn btn-secondary btn-sm env-diag-btn" onClick={onSystemDiagnose}>
<Cpu size={13} strokeWidth={1.5} aria-hidden="true" />
</button>
</div> </div>
<div className="env-grid"> <div className="env-grid">
<div className="env-basic"> <div className="env-basic">
@@ -408,6 +414,9 @@ export function OverviewPage({ onNavigate, onOpenDetail, pendingRuntime, onRunti
const [diagOpen, setDiagOpen] = useState(false); const [diagOpen, setDiagOpen] = useState(false);
const [diagReports, setDiagReports] = useState<DiagnosticReport[] | null>(null); const [diagReports, setDiagReports] = useState<DiagnosticReport[] | null>(null);
const [diagBusy, setDiagBusy] = useState(false); const [diagBusy, setDiagBusy] = useState(false);
const [sysDiagOpen, setSysDiagOpen] = useState(false);
const [sysDiag, setSysDiag] = useState<SystemEnvReport | null>(null);
const [sysDiagBusy, setSysDiagBusy] = useState(false);
// 外部跳转(详情页错误内联按钮「去安装 Node.js」) // 外部跳转(详情页错误内联按钮「去安装 Node.js」)
useEffect(() => { useEffect(() => {
@@ -431,6 +440,21 @@ export function OverviewPage({ onNavigate, onOpenDetail, pendingRuntime, onRunti
} }
} }
async function runSystemDiagnose() {
setSysDiagOpen(true);
setSysDiagBusy(true);
setSysDiag(null);
try {
const report = await diagnoseSystem();
setSysDiag(report);
} catch (e) {
setSysDiag({ findings: [] });
void e;
} finally {
setSysDiagBusy(false);
}
}
const installedCount = entries.filter((e) => detectMap[e.id]?.status === "installed").length; const installedCount = entries.filter((e) => detectMap[e.id]?.status === "installed").length;
// 检测未完成(首次、无缓存)时不得判空态 // 检测未完成(首次、无缓存)时不得判空态
const ready = !detecting && !catalogLoading; const ready = !detecting && !catalogLoading;
@@ -495,7 +519,7 @@ export function OverviewPage({ onNavigate, onOpenDetail, pendingRuntime, onRunti
)} )}
{/* ④ 本机环境(真实检测结果,架构 §5;超宽断点上提为第三栏) */} {/* ④ 本机环境(真实检测结果,架构 §5;超宽断点上提为第三栏) */}
<EnvPanel onInstallRuntime={setRuntimeInstall} /> <EnvPanel onInstallRuntime={setRuntimeInstall} onSystemDiagnose={runSystemDiagnose} />
{/* 运行时一键安装弹窗 */} {/* 运行时一键安装弹窗 */}
{runtimeInstall && ( {runtimeInstall && (
@@ -508,6 +532,13 @@ export function OverviewPage({ onNavigate, onOpenDetail, pendingRuntime, onRunti
<DiagSummary busy={diagBusy} reports={diagReports} /> <DiagSummary busy={diagBusy} reports={diagReports} />
</Modal> </Modal>
)} )}
{/* 系统环境诊断弹窗(Wave 3.1 Req 7 */}
{sysDiagOpen && (
<Modal title="系统环境诊断" onClose={() => setSysDiagOpen(false)}>
<SystemDiagSummary busy={sysDiagBusy} report={sysDiag} onInstall={setRuntimeInstall} />
</Modal>
)}
</div> </div>
); );
} }
@@ -559,3 +590,72 @@ function DiagSummary({ busy, reports }: { busy: boolean; reports: DiagnosticRepo
</div> </div>
); );
} }
/** 系统环境诊断(Wave 3.1 Req 7):每项标注影响哪些 CLI,缺失项带一键安装 */
function SystemDiagSummary({
busy,
report,
onInstall,
}: {
busy: boolean;
report: SystemEnvReport | null;
onInstall: (runtime: string) => void;
}) {
if (busy) {
return (
<p className="panel-empty">
<Loader2 size={14} strokeWidth={1.5} className="spin" aria-hidden="true" />
</p>
);
}
if (!report) return null;
const problems = report.findings.filter((f) => f.status !== "installed");
const allOk = problems.length === 0;
return (
<div className="sys-diag">
<div className="diag-summary">
{allOk ? (
<>
<span className="status-dot ok" aria-hidden="true" />
<span></span>
</>
) : (
<>
<span className="status-dot warn" aria-hidden="true" />
<span> {problems.length} </span>
</>
)}
</div>
{report.findings.map((f) => {
const missing = f.status !== "installed";
return (
<div key={f.runtime_id} className="sys-diag-item">
<span className={`status-dot ${missing ? "warn" : "ok"}`} aria-hidden="true" />
<div className="sys-diag-main">
<div className="sys-diag-head">
<span className="sys-diag-name">{f.label_zh}</span>
{f.version && <MonoChip>{f.version}</MonoChip>}
<span className={`sys-diag-status ${missing ? "warn" : ""}`}>
{missing ? "缺失 / 需更新" : "正常"}
</span>
</div>
<p className="sys-diag-msg">{f.message_zh}</p>
{f.affected_cli.length > 0 && (
<p className="sys-diag-affected">{f.affected_cli.join(" · ")}</p>
)}
</div>
{missing && (
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={() => onInstall(f.runtime_id)}
>
<Download size={13} strokeWidth={1.5} aria-hidden="true" />
</button>
)}
</div>
);
})}
</div>
);
}
+171 -8
View File
@@ -288,6 +288,14 @@ button {
cursor: not-allowed; cursor: not-allowed;
} }
/* 紧凑尺寸(本机环境内联「安装」等) */
.btn-sm {
height: 26px;
min-width: 0;
padding: 0 var(--ad-space-3);
font-size: var(--ad-text-s-size);
}
/* 主按钮(§2.6 v1.2:内渐变 + 内高光 + 常驻弱辉光,hover 双层扩散辉光) */ /* 主按钮(§2.6 v1.2:内渐变 + 内高光 + 常驻弱辉光,hover 双层扩散辉光) */
.btn-primary { .btn-primary {
background: var(--ad-btn-primary-bg); background: var(--ad-btn-primary-bg);
@@ -1412,6 +1420,7 @@ button {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--ad-space-3); gap: var(--ad-space-3);
flex-wrap: wrap;
} }
.detail-command-desc { .detail-command-desc {
@@ -1419,6 +1428,38 @@ button {
font-size: var(--ad-text-s-size); font-size: var(--ad-text-s-size);
} }
/* 可复制命令块(Wave 3.1 Req 9):hover 高亮 + 点击复制 */
.command-copy {
display: inline-flex;
align-items: center;
gap: var(--ad-space-2);
background: transparent;
border: none;
padding: 0;
cursor: pointer;
color: var(--ad-text-2);
border-radius: var(--ad-radius-s);
transition: color 120ms ease, background 120ms ease;
}
.command-copy:hover {
color: var(--ad-primary);
background: var(--ad-bg-1);
}
.command-copy:hover .mono-chip {
border-color: var(--ad-primary);
color: var(--ad-primary);
}
.command-copied {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: var(--ad-text-xs-size);
color: var(--ad-success);
}
/* ---------- 诊断结果(§3.5 ---------- */ /* ---------- 诊断结果(§3.5 ---------- */
.diag-summary { .diag-summary {
display: flex; display: flex;
@@ -2208,11 +2249,11 @@ button {
/* ============================================================ /* ============================================================
* Wave 2.2£ºÊµÊ±Êä³ö / Èí¼þÄÚÊÚȨ / ÒÀÀµÒ»¼ü×° / ¼ÓÔØ¹Ç¼Ü * Wave 2.2ʵʱ / Ȩ / һװ / عǼ
* È«²¿ÑÕÉ«À´×Ô tokens/ * ȫɫ tokens/
* ============================================================ */ * ============================================================ */
/* ---------- ʧ°Ü̬ÈË»°»¯£¨°²×°/Ð¶ÔØµ¯´°£© ---------- */ /* ---------- ʧ̬˻װ/жص ---------- */
.run-failure { .run-failure {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
@@ -2245,7 +2286,7 @@ button {
word-break: break-all; word-break: break-all;
} }
/* ---------- Èí¼þÄÚÊÚȨ£¨AuthPanel£© ---------- */ /* ---------- ȨAuthPanel ---------- */
.auth-mode-action { .auth-mode-action {
height: 28px; height: 28px;
min-width: 0; min-width: 0;
@@ -2355,7 +2396,7 @@ button {
color: var(--ad-attention); color: var(--ad-attention);
} }
/* ---------- ±¾»ú»·¾³Ò»¼ü°²×°£¨RuntimeInstallModal£© ---------- */ /* ---------- һװRuntimeInstallModal ---------- */
.runtime-install { .runtime-install {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -2403,7 +2444,7 @@ button {
font-size: var(--ad-text-xs-size); font-size: var(--ad-text-xs-size);
} }
/* ---------- ×ÜÀÀ¼ÓÔØ¹Ç¼ÜÆÁ£¨Wave 2.2 Req 4£© ---------- */ /* ---------- عǼWave 2.2 Req 4 ---------- */
.overview-skeleton { .overview-skeleton {
padding: var(--ad-space-5); padding: var(--ad-space-5);
background: var(--ad-bg-1); background: var(--ad-bg-1);
@@ -2468,7 +2509,7 @@ button {
} }
} }
/* ---------- È«Á¿Õï¶Ï»ã×Ü ---------- */ /* ---------- ȫϻ ---------- */
.diag-summary-all { .diag-summary-all {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -2504,7 +2545,7 @@ button {
line-height: 1.5; line-height: 1.5;
} }
/* È«Á¿Õï¶Ï»ã×ÜÀïµÄ¼¶±ðÎÄ×Ö±êÇ©£¨½öÎÄ×ÖÉ«£¬²»Óà diag-sev-tag µÄÌî³äÉ«£¬±ÜÃâÎÄ×Ö±»±³¾°Í̵ô£© */ /* ȫϻļֱǩɫ diag-sev-tag ɫ̵ֱ */
.diag-sev-label { .diag-sev-label {
flex-shrink: 0; flex-shrink: 0;
font-size: var(--ad-text-xs-size); font-size: var(--ad-text-xs-size);
@@ -2521,3 +2562,125 @@ button {
.diag-sev-label.info { .diag-sev-label.info {
color: var(--ad-attention); color: var(--ad-attention);
} }
/* ============================================================
* Wave 3.1:外链 / 命令复制 / 依赖内联安装 / 系统环境诊断 / 模型列表
* 全部取色取字走 tokens/
* ============================================================ */
/* ---------- 本机环境头部:系统环境诊断入口 ---------- */
.env-diag-btn {
margin-left: auto;
}
/* ---------- 系统环境诊断(Req 7 ---------- */
.sys-diag {
display: flex;
flex-direction: column;
gap: var(--ad-space-3);
}
.sys-diag-item {
display: flex;
align-items: center;
gap: var(--ad-space-3);
padding: var(--ad-space-4);
border: 1px solid var(--ad-border);
border-radius: var(--ad-radius-m);
background: var(--ad-bg-1);
}
.sys-diag-main {
flex: 1;
min-width: 0;
}
.sys-diag-head {
display: flex;
align-items: center;
gap: var(--ad-space-2);
}
.sys-diag-name {
color: var(--ad-text-1);
font-size: var(--ad-text-m-size);
font-weight: 500;
}
.sys-diag-status {
color: var(--ad-success);
font-size: var(--ad-text-xs-size);
}
.sys-diag-status.warn {
color: var(--ad-warning);
}
.sys-diag-msg {
color: var(--ad-text-2);
font-size: var(--ad-text-s-size);
line-height: 1.5;
margin-top: 2px;
}
.sys-diag-affected {
color: var(--ad-text-3);
font-size: var(--ad-text-xs-size);
margin-top: 2px;
}
/* ---------- 模型列表 + 测试连通(Req 5 ---------- */
.model-list {
display: flex;
flex-direction: column;
gap: var(--ad-space-2);
}
.model-list-hint {
color: var(--ad-text-3);
font-size: var(--ad-text-xs-size);
}
.model-list-rows {
display: flex;
flex-direction: column;
gap: var(--ad-space-2);
}
.model-row {
display: flex;
align-items: center;
gap: var(--ad-space-2);
}
.model-row-label {
color: var(--ad-text-2);
font-size: var(--ad-text-xs-size);
}
.connect-test-result {
display: flex;
align-items: flex-start;
gap: var(--ad-space-2);
padding: var(--ad-space-3);
border: 1px solid var(--ad-border);
border-radius: var(--ad-radius-m);
font-size: var(--ad-text-s-size);
}
.connect-test-result.ok {
color: var(--ad-success);
}
.connect-test-result.fail {
color: var(--ad-warning);
}
/* ---------- 目录卡片「可更新」角标(Req 4 ---------- */
.catalog-update {
margin-top: var(--ad-space-2);
padding-top: var(--ad-space-2);
border-top: 1px solid var(--ad-border);
color: var(--ad-accent);
font-size: var(--ad-text-xs-size);
}
+69
View File
@@ -47,6 +47,9 @@ pub struct Adapter {
pub diagnostics: Vec<Diagnostic>, pub diagnostics: Vec<Diagnostic>,
#[serde(default)] #[serde(default)]
pub documentation: Option<Documentation>, pub documentation: Option<Documentation>,
/// 该 CLI 可加载的模型(模型列表来源:CLI 自带列举命令或适配器按官方文档维护的清单)
#[serde(default)]
pub models: Option<Models>,
} }
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
@@ -87,6 +90,9 @@ pub struct Official {
pub docs: Option<String>, pub docs: Option<String>,
#[serde(rename = "allowed_hosts", default)] #[serde(rename = "allowed_hosts", default)]
pub allowed_hosts: Vec<String>, pub allowed_hosts: Vec<String>,
/// 官方图标地址(仅作识别/许可说明与本地打包来源,不热链)
#[serde(default)]
pub icon: Option<String>,
} }
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
@@ -167,6 +173,23 @@ pub struct Update {
pub method: Option<String>, pub method: Option<String>,
#[serde(default)] #[serde(default)]
pub command: Vec<String>, pub command: Vec<String>,
/// 「可更新」判定依据来源(官方源最新版本,与本地版本 semver 对比)
#[serde(default)]
pub source: Option<UpdateSource>,
}
/// 官方源最新版本查询来源(npm registry / PyPI / GitHub Releases)。
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct UpdateSource {
/// npm | pypi | github
pub kind: String,
/// npm / pypi 包名(kind = npm|pypi 时)
#[serde(default)]
pub package: Option<String>,
/// GitHub 仓库(owner/repokind = github 时)
#[serde(default)]
pub repo: Option<String>,
} }
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
@@ -185,6 +208,30 @@ pub struct Uninstall {
pub struct Authorization { pub struct Authorization {
#[serde(default)] #[serde(default)]
pub modes: Vec<AuthMode>, pub modes: Vec<AuthMode>,
/// 授权判定信号:登录态/OAuth 凭据文件或目录(存在任一即视为已授权)。
/// 无 status_command 的 CLI(如 kimi-code 的 ~/.kimi-code/credentials)用此判授权。
#[serde(rename = "credential_files", default)]
pub credential_files: Vec<String>,
/// 「测试连通」端点(已授权时直连官方接口测一次)
#[serde(default)]
pub test: Option<ConnectionTest>,
}
/// 「测试连通」端点声明(密钥从密钥库读取,直连官方接口,成功/失败如实显示)。
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct ConnectionTest {
/// 探测端点 URL(通常为官方模型列表/账号接口)
pub url: String,
/// API Key 注入头名(如 x-api-key / Authorization
#[serde(rename = "key_header", default)]
pub key_header: Option<String>,
/// Authorization 头是否带 Bearer 前缀(key_header = Authorization 时)
#[serde(default)]
pub bearer: bool,
/// 额外请求头(如 anthropic-version: 2023-06-01
#[serde(rename = "extra_headers", default)]
pub extra_headers: Vec<String>,
} }
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
@@ -314,6 +361,28 @@ pub struct DocParam {
pub desc_zh: Option<String>, pub desc_zh: Option<String>,
} }
/// 该 CLI 可加载的模型(Wave 3.1 Req 5:默认模型 → 模型列表)。
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Models {
/// CLI 自带的模型列举命令(如 `agent --list-models`),存在则优先用它实时调取
#[serde(default)]
pub command: Vec<String>,
/// 适配器按官方文档维护的模型清单(无 CLI 列举命令时使用)
#[serde(default)]
pub list: Vec<ModelEntry>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct ModelEntry {
/// 模型 ID(如 kimi-code/k3
pub id: String,
/// 中文名/说明(可选)
#[serde(rename = "label_zh", default)]
pub label_zh: Option<String>,
}
fn default_true() -> bool { fn default_true() -> bool {
true true
} }
+547 -3
View File
@@ -15,11 +15,12 @@ use agentdock_secrets::{SecretStore, redact, service_name};
use serde_json::json; use serde_json::json;
use crate::error::EngineError; use crate::error::EngineError;
use crate::errors_zh::map_exec_error; use crate::errors_zh::{map_exec_error, runtime_label_zh};
use crate::process::{ use crate::process::{
resolve_exe, run_capture, run_streaming, run_streaming_cancellable, run_terminal, run_with_stdin, resolve_exe, run_capture, run_streaming, run_streaming_cancellable, run_terminal, run_with_stdin,
which_all, RunningProcess, which_all, RunningProcess,
}; };
use crate::runtime_install::{fetch_http_text, is_url_host_allowed};
use crate::types::*; use crate::types::*;
/// 授权会话 key`<cli_id>:<mode>`。 /// 授权会话 key`<cli_id>:<mode>`。
@@ -345,6 +346,30 @@ impl Engine {
.collect()) .collect())
} }
/// 系统环境诊断(Wave 3.1 Req 7):检查缺失/过低的系统依赖,标注影响哪些 CLI。
pub fn diagnose_system(&self, env: &agentdock_platform::model::PlatformEnv) -> Result<SystemEnvReport, EngineError> {
let adapters = self.adapters()?;
Ok(diagnose_system(env, &adapters))
}
/// 「可更新」判定(Wave 3.1 Req 4):官方源最新版本 vs 本地已装版本 semver 对比。
pub fn check_update(&self, id: &str) -> Result<UpdateCheckResult, EngineError> {
let adapter = self.adapter(id)?;
Ok(check_update(&adapter))
}
/// 模型列表(Wave 3.1 Req 5):优先 CLI 自带列举命令,否则适配器维护清单。
pub fn list_models(&self, id: &str) -> Result<ModelListResult, EngineError> {
let adapter = self.adapter(id)?;
Ok(list_models(&adapter))
}
/// 测试连通(Wave 3.1 Req 5):密钥从密钥库读取,直连官方接口测一次。
pub fn test_connection(&self, id: &str) -> Result<ConnectionTestResult, EngineError> {
let adapter = self.adapter(id)?;
Ok(test_connection(&adapter, self.secrets.as_ref()))
}
// ---- 执行 ---- // ---- 执行 ----
pub fn run<F>(&self, id: &str, action: AdapterAction, opts: &ActionOpts, mut emit: F) -> Result<(), EngineError> pub fn run<F>(&self, id: &str, action: AdapterAction, opts: &ActionOpts, mut emit: F) -> Result<(), EngineError>
@@ -840,7 +865,23 @@ pub fn auth_status(adapter: &Adapter, secrets: &dyn SecretStore) -> AuthStatus {
} }
} }
// 2. keyring // 2. credential_filesWave 3.1 Req 3):无 status_command 的 CLI 用登录态/OAuth 凭据文件判定
if let Some(auth) = adapter.authorization.as_ref() {
for cred in &auth.credential_files {
let p = cfg::resolve_path(cred);
if p.exists() {
return AuthStatus {
cli_id: adapter.id.clone(),
status: "authorized".into(),
via: "credential_file".into(),
detail_zh: format!("检测到登录凭据({}", cred),
checked_at,
};
}
}
}
// 3. keyring
if let Some(cfg) = adapter.configuration.as_ref() { if let Some(cfg) = adapter.configuration.as_ref() {
for field in &cfg.fields { for field in &cfg.fields {
if field.sensitive && secrets.has(&service, &field.id) { if field.sensitive && secrets.has(&service, &field.id) {
@@ -855,7 +896,7 @@ pub fn auth_status(adapter: &Adapter, secrets: &dyn SecretStore) -> AuthStatus {
} }
} }
// 3. unknown // 4. unknown
AuthStatus { AuthStatus {
cli_id: adapter.id.clone(), cli_id: adapter.id.clone(),
status: "unknown".into(), status: "unknown".into(),
@@ -1175,6 +1216,435 @@ where
Ok(()) Ok(())
} }
// =====================================================================
// Wave 3.1:可更新判定 / 模型列表 / 测试连通 / 系统环境诊断
// =====================================================================
/// 版本号 → 数值元组(忽略 v 前缀与非数字段,供版本比较)。
fn version_parts(s: &str) -> Vec<u64> {
s.split(|c: char| !c.is_ascii_digit())
.filter(|seg| !seg.is_empty())
.filter_map(|seg| seg.parse::<u64>().ok())
.collect()
}
/// 比较两个版本号,返回 Ordering(缺失段按 0 处理)。
fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
let pa = version_parts(a);
let pb = version_parts(b);
let n = pa.len().max(pb.len());
for i in 0..n {
let x = pa.get(i).copied().unwrap_or(0);
let y = pb.get(i).copied().unwrap_or(0);
match x.cmp(&y) {
std::cmp::Ordering::Equal => continue,
other => return other,
}
}
std::cmp::Ordering::Equal
}
/// 官方源最新版本查询结果。
enum LatestVersion {
Found(String),
Unsupported(String),
Failed(String),
}
/// 从官方源查询最新版本(npm registry / PyPI / GitHub Releases),均经 allowed_hosts 白名单。
fn fetch_latest_version(adapter: &Adapter) -> LatestVersion {
let allowed = adapter
.official
.as_ref()
.map(|o| o.allowed_hosts.clone())
.unwrap_or_default();
let src = match adapter.update.as_ref().and_then(|u| u.source.as_ref()) {
Some(s) => s,
None => return LatestVersion::Unsupported("适配器未声明 update.source(可更新依据来源)".into()),
};
let url = match src.kind.as_str() {
"npm" => format!("https://registry.npmjs.org/{}", src.package.as_deref().unwrap_or("")),
"pypi" => format!("https://pypi.org/pypi/{}/json", src.package.as_deref().unwrap_or("")),
"github" => format!("https://api.github.com/repos/{}/releases/latest", src.repo.as_deref().unwrap_or("")),
other => return LatestVersion::Unsupported(format!("不支持的更新来源 {other}")),
};
if !is_url_host_allowed(&url, &allowed) {
return LatestVersion::Failed(format!("来源主机不在白名单内:{url}"));
}
match fetch_http_text(&url, &[]) {
Ok(text) => {
let v: serde_json::Value = match serde_json::from_str(&text) {
Ok(v) => v,
Err(_) => return LatestVersion::Failed("官方源返回非 JSON".into()),
};
let latest = match src.kind.as_str() {
"npm" => v.pointer("/dist-tags/latest").and_then(|x| x.as_str()),
"pypi" => v.pointer("/info/version").and_then(|x| x.as_str()),
"github" => v.pointer("/tag_name").and_then(|x| x.as_str()),
_ => None,
};
match latest {
Some(l) => LatestVersion::Found(l.trim_start_matches('v').to_string()),
None => LatestVersion::Failed("官方源未返回版本号".into()),
}
}
Err(e) => LatestVersion::Failed(format!("查询官方源失败:{e}")),
}
}
/// 「可更新」判定(Wave 3.1 Req 4):官方源最新版本 vs 本地已装版本 semver 对比。
pub fn check_update(adapter: &Adapter) -> UpdateCheckResult {
let cli_id = adapter.id.clone();
let detect = detect_one(adapter);
let current = if detect.status == "installed" { detect.version.clone() } else { None };
let (source_zh, source_url) = match adapter.update.as_ref().and_then(|u| u.source.as_ref()) {
Some(s) => match s.kind.as_str() {
"npm" => (
"npm registry".to_string(),
format!("https://registry.npmjs.org/{}", s.package.as_deref().unwrap_or("")),
),
"pypi" => (
"PyPI".to_string(),
format!("https://pypi.org/pypi/{}", s.package.as_deref().unwrap_or("")),
),
"github" => (
"GitHub Releases".to_string(),
format!("https://github.com/{}/releases", s.repo.as_deref().unwrap_or("")),
),
_ => ("官方源".to_string(), String::new()),
},
None => ("官方源".to_string(), String::new()),
};
match fetch_latest_version(adapter) {
LatestVersion::Found(latest) => {
let update_available = match &current {
Some(c) => compare_versions(c, &latest) == std::cmp::Ordering::Less,
None => false,
};
UpdateCheckResult {
cli_id,
current,
latest: Some(latest),
update_available,
source_zh,
source_url,
error: None,
}
}
LatestVersion::Unsupported(m) => UpdateCheckResult {
cli_id, current, latest: None, update_available: false, source_zh, source_url, error: Some(m),
},
LatestVersion::Failed(m) => UpdateCheckResult {
cli_id, current, latest: None, update_available: false, source_zh, source_url, error: Some(m),
},
}
}
/// 模型列表(Wave 3.1 Req 5):优先 CLI 自带列举命令,否则适配器维护清单。
pub fn list_models(adapter: &Adapter) -> ModelListResult {
let cli_id = adapter.id.clone();
let models = match adapter.models.as_ref() {
None => {
return ModelListResult {
cli_id,
models: vec![],
source: "empty".into(),
detail_zh: "该 CLI 未声明模型清单".into(),
}
}
Some(m) => m,
};
// 优先:CLI 自带列举命令(已安装时实时调取)
if !models.command.is_empty() {
let prog = &models.command[0];
let args: Vec<String> = models.command[1..].to_vec();
if agentdock_exec::validate_argv(prog, &args).is_ok() {
if let Some(exe) = which_all(prog).first() {
let exe = exe.to_string_lossy().to_string();
if let Ok((true, text)) = run_capture(std::path::Path::new(&exe), &args) {
let parsed = parse_model_lines(&text);
if !parsed.is_empty() {
return ModelListResult {
cli_id,
models: parsed,
source: "cli_command".into(),
detail_zh: format!("已通过 {} 实时列举", models.command.join(" ")),
};
}
}
}
}
}
// 回退:适配器按官方文档维护的模型清单
let list: Vec<ModelInfo> = models
.list
.iter()
.map(|e| ModelInfo { id: e.id.clone(), label_zh: e.label_zh.clone() })
.collect();
let source = if list.is_empty() { "empty" } else { "adapter" };
ModelListResult {
cli_id,
models: list,
source: source.to_string(),
detail_zh: "按官方文档维护的模型清单".into(),
}
}
/// 从模型列举命令输出解析模型 ID(逐行清理,剔除明显表头)。
fn parse_model_lines(text: &str) -> Vec<ModelInfo> {
text.lines()
.map(|l| {
l.trim()
.trim_start_matches('-')
.trim_start_matches('*')
.trim()
.to_string()
})
.filter(|l| !l.is_empty() && l.len() <= 200)
.filter(|l| !l.contains("模型") && !l.to_lowercase().starts_with("available") && !l.to_lowercase().starts_with("model"))
.take(200)
.map(|l| ModelInfo { id: l, label_zh: None })
.collect()
}
/// curl 探测(不 --fail,返回 HTTP 状态码 + 响应体,用于测试连通时区分 401/403/网络失败)。
fn curl_probe(url: &str, headers: &[String]) -> (Option<u16>, String) {
let mut cmd = std::process::Command::new("curl.exe");
cmd.args(["-L", "--silent", "--show-error", "--max-time", "20", "-w", "\n__HTTP_STATUS__%{http_code}"]);
for h in headers {
cmd.arg("-H").arg(h);
}
cmd.arg(url);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
}
match cmd.output() {
Ok(out) => {
let text = String::from_utf8_lossy(&out.stdout).to_string();
let status = text
.rsplit("__HTTP_STATUS__")
.next()
.and_then(|s| s.trim().parse::<u16>().ok());
let body = text.split("__HTTP_STATUS__").next().unwrap_or("").trim().to_string();
(status, body)
}
Err(_) => (None, String::new()),
}
}
/// 测试连通(Wave 3.1 Req 5):密钥从密钥库读取直连官方接口测一次,全程不落盘不进日志。
pub fn test_connection(adapter: &Adapter, secrets: &dyn SecretStore) -> ConnectionTestResult {
let cli_id = adapter.id.clone();
let test = match adapter.authorization.as_ref().and_then(|a| a.test.as_ref()) {
Some(t) => t,
None => {
return ConnectionTestResult {
cli_id,
ok: false,
message_zh: "该 CLI 未声明测试连通端点".into(),
http_status: None,
detail: None,
}
}
};
let allowed = adapter
.official
.as_ref()
.map(|o| o.allowed_hosts.clone())
.unwrap_or_default();
if !is_url_host_allowed(&test.url, &allowed) {
return ConnectionTestResult {
cli_id,
ok: false,
message_zh: "测试端点不在白名单内".into(),
http_status: None,
detail: None,
};
}
let service = service_name(&adapter.id);
let key = match secrets.get(&service, "api_key") {
Ok(k) if !k.is_empty() => k,
_ => {
return ConnectionTestResult {
cli_id,
ok: false,
message_zh: "未找到已保存的 API Key,请先在「授权 / 登录」里保存".into(),
http_status: None,
detail: None,
}
}
};
let header_name = test
.key_header
.clone()
.unwrap_or_else(|| "Authorization".to_string());
let header_value = if header_name.eq_ignore_ascii_case("authorization") || test.bearer {
format!("Bearer {key}")
} else {
key.clone()
};
let header = format!("{header_name}: {header_value}");
let mut headers = vec![header];
headers.extend(test.extra_headers.clone());
let (status, body) = curl_probe(&test.url, &headers);
match status {
Some(s) if (200..300).contains(&s) => ConnectionTestResult {
cli_id,
ok: true,
message_zh: "连通正常,官方接口响应成功".into(),
http_status: Some(s),
detail: Some(redact(&body).chars().take(200).collect()),
},
Some(401) | Some(403) => ConnectionTestResult {
cli_id,
ok: false,
message_zh: "密钥无效或权限不足(官方接口返回 401/403)".into(),
http_status: status,
detail: Some(redact(&body).chars().take(200).collect()),
},
Some(s) => ConnectionTestResult {
cli_id,
ok: false,
message_zh: format!("官方接口返回 HTTP {s}").into(),
http_status: Some(s),
detail: Some(redact(&body).chars().take(200).collect()),
},
None => ConnectionTestResult {
cli_id,
ok: false,
message_zh: "网络请求失败,无法连通官方接口".into(),
http_status: None,
detail: None,
},
}
}
/// 程序名 → 运行时 id(安装命令用到 npm 视为依赖 Node.js,等等)。
fn prog_to_runtime(prog: &str) -> Option<&'static str> {
match prog.to_ascii_lowercase().as_str() {
"npm" | "npx" | "node" => Some("node"),
"uv" | "uvx" => Some("uv"),
"python" | "python3" | "pip" => Some("python"),
"git" => Some("git"),
"winget" => Some("winget"),
"apt" | "apt-get" | "sudo" => Some("apt"),
_ => None,
}
}
/// 判断适配器是否依赖某运行时(runtime_deps 声明,或其安装/更新/卸载命令用到对应程序)。
fn adapter_depends_on(a: &Adapter, runtime_id: &str) -> bool {
if a.runtime_deps.iter().any(|d| d.id == runtime_id) {
return true;
}
let mut progs: Vec<String> = Vec::new();
if let Some(i) = a.install.as_ref() {
for c in &i.channels {
if let Some(p) = c.command.first() {
progs.push(p.to_lowercase());
}
}
}
if let Some(u) = a.update.as_ref() {
if let Some(p) = u.command.first() {
progs.push(p.to_lowercase());
}
}
if let Some(u) = a.uninstall.as_ref() {
if let Some(p) = u.command.first() {
progs.push(p.to_lowercase());
}
}
progs.iter().any(|p| prog_to_runtime(p) == Some(runtime_id))
}
/// 系统环境诊断(Wave 3.1 Req 7):检查缺失/过低的系统依赖,每项标注影响哪些 CLI。
pub fn diagnose_system(env: &agentdock_platform::model::PlatformEnv, adapters: &[Adapter]) -> SystemEnvReport {
let rt = &env.runtimes;
let ids: &[&str] = if env.os == "windows" {
&["node", "python", "uv", "git", "winget"]
} else {
&["node", "python", "uv", "git", "apt"]
};
let mut findings = Vec::new();
for id in ids {
let info: Option<&agentdock_platform::model::RuntimeInfo> = match *id {
"node" => rt.node.as_ref(),
"npm" => rt.npm.as_ref(),
"python" => rt.python.as_ref(),
"uv" => rt.uv.as_ref(),
"git" => rt.git.as_ref(),
"winget" => rt.winget.as_ref(),
"apt" => rt.apt.as_ref(),
_ => None,
};
let affected: Vec<String> = adapters
.iter()
.filter(|a| adapter_depends_on(a, id))
.map(|a| a.id.clone())
.collect();
if affected.is_empty() {
continue;
}
let (status, version, message_zh) = match info {
Some(i) if i.status == "installed" => {
let mut below_min = false;
let mut range_used = String::new();
for a in adapters {
for d in &a.runtime_deps {
if d.id == *id {
if let Some(range) = &d.semver_range {
if let Some(v) = i.version.as_deref() {
if !diag::version_satisfies(v, range) {
below_min = true;
range_used = range.clone();
}
}
}
}
}
}
if below_min {
(
"below_min".to_string(),
i.version.clone(),
format!(
"版本 {} 不满足受影响 CLI 要求的 {}",
i.version.as_deref().unwrap_or("?"),
range_used
),
)
} else {
("installed".to_string(), i.version.clone(), "已安装且版本满足要求".to_string())
}
}
Some(i) => (i.status.clone(), i.version.clone(), format!("状态:{}", i.status)),
None => ("not_installed".to_string(), None, "未检测到该运行时".to_string()),
};
findings.push(SystemEnvFinding {
runtime_id: id.to_string(),
label_zh: runtime_label_zh(id).to_string(),
status,
version,
affected_cli: affected,
message_zh,
});
}
SystemEnvReport { findings }
}
// ===================================================================== // =====================================================================
// 测试 // 测试
// ===================================================================== // =====================================================================
@@ -1833,4 +2303,78 @@ configuration:
assert!(url.contains("openai.com") || url.contains("auth.openai"), "验证链接应指向官方域名: {url}"); assert!(url.contains("openai.com") || url.contains("auth.openai"), "验证链接应指向官方域名: {url}");
assert!(!code.is_empty(), "设备码不应为空"); assert!(!code.is_empty(), "设备码不应为空");
} }
#[test]
fn compare_versions_ordering() {
use std::cmp::Ordering;
assert_eq!(compare_versions("1.2.3", "1.2.4"), Ordering::Less);
assert_eq!(compare_versions("2.0.0", "1.9.9"), Ordering::Greater);
assert_eq!(compare_versions("v1.2.3", "1.2.3"), Ordering::Equal);
assert_eq!(compare_versions("2026.08.11", "2026.08.10"), Ordering::Greater);
assert_eq!(compare_versions("1.2.3", "1.2.3.0"), Ordering::Equal);
}
#[test]
fn prog_to_runtime_maps_commands() {
assert_eq!(prog_to_runtime("npm"), Some("node"));
assert_eq!(prog_to_runtime("uv"), Some("uv"));
assert_eq!(prog_to_runtime("winget"), Some("winget"));
assert_eq!(prog_to_runtime("apt-get"), Some("apt"));
assert_eq!(prog_to_runtime("opencode"), None);
}
#[test]
fn adapter_depends_on_via_install_command() {
let adapter = codex_adapter(); // codex 走 npm install → 依赖 node
assert!(adapter_depends_on(&adapter, "node"));
assert!(!adapter_depends_on(&adapter, "uv"));
}
#[test]
fn diagnose_system_reports_missing_runtime_with_affected_cli() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../adapters");
let adapters = load_adapters(&dir).expect("适配器目录应可加载");
let mut env = agentdock_platform::model::PlatformEnv {
os: "windows".into(),
..Default::default()
};
env.runtimes.node = Some(agentdock_platform::model::RuntimeInfo {
status: "installed".into(),
version: Some("24.18.0".into()),
path: None,
});
env.runtimes.uv = None; // uv 未安装
let report = diagnose_system(&env, &adapters);
let uv = report.findings.iter().find(|f| f.runtime_id == "uv").expect("应包含 uv 依赖项");
assert_eq!(uv.status, "not_installed");
assert!(uv.affected_cli.iter().any(|c| c == "kimi" || c == "aider"), "uv 应标注影响 kimi/aider");
assert!(!uv.message_zh.is_empty());
let node = report.findings.iter().find(|f| f.runtime_id == "node").expect("应包含 node 依赖项");
assert_eq!(node.status, "installed");
assert!(node.affected_cli.iter().any(|c| c == "gemini"), "node 应标注影响 gemini");
}
#[test]
fn list_models_falls_back_to_adapter_list() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../adapters");
let adapters = load_adapters(&dir).expect("适配器目录应可加载");
let kimi = adapters.iter().find(|a| a.id == "kimi").unwrap();
let r = list_models(kimi);
assert!(!r.models.is_empty(), "kimi 应维护模型清单");
assert!(r.models.iter().any(|m| m.id == "kimi-code/k3"));
}
#[test]
fn check_update_returns_error_when_no_source() {
// warp 未声明 update.source,应如实返回「未声明来源」而非误报可更新
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../adapters");
let adapters = load_adapters(&dir).expect("适配器目录应可加载");
let warp = adapters.iter().find(|a| a.id == "warp").unwrap();
let r = check_update(warp);
assert!(!r.update_available);
assert!(r.latest.is_none());
assert!(r.error.is_some());
}
} }
+2
View File
@@ -27,10 +27,12 @@ pub fn runtime_for_prog(prog: &str) -> Option<&'static str> {
pub fn runtime_label_zh(id: &str) -> &'static str { pub fn runtime_label_zh(id: &str) -> &'static str {
match id { match id {
"node" => "Node.js", "node" => "Node.js",
"npm" => "npm",
"python" => "Python", "python" => "Python",
"git" => "Git", "git" => "Git",
"winget" => "winget", "winget" => "winget",
"uv" => "uv", "uv" => "uv",
"apt" => "apt",
"choco" => "choco", "choco" => "choco",
"scoop" => "scoop", "scoop" => "scoop",
_ => "运行时", _ => "运行时",
+5 -3
View File
@@ -16,12 +16,14 @@ pub use engine::{
parse_version, read_config, run_action, strip_ansi, verify_config, write_config, parse_version, read_config, run_action, strip_ansi, verify_config, write_config,
}; };
pub use error::EngineError; pub use error::EngineError;
pub use errors_zh::{map_exec_error, runtime_for_prog}; pub use errors_zh::{map_exec_error, runtime_for_prog, runtime_label_zh};
pub use process::{open_with_shell, resolve_exe, run_capture, run_streaming, run_terminal, run_with_stdin, which_all, RunningProcess}; pub use process::{open_with_shell, resolve_exe, run_capture, run_streaming, run_terminal, run_with_stdin, which_all, RunningProcess};
pub use runtime_install::{ pub use runtime_install::{
download_with_curl, is_url_host_allowed, source_for, supported_runtimes, RuntimeSource, download_with_curl, fetch_http_text, is_url_host_allowed, source_for, supported_runtimes,
RuntimeSource,
}; };
pub use types::{ pub use types::{
ActionEvent, AuthFlowEvent, AuthStatus, AuthModeInfo, ConfigFieldState, ConfigFileState, ConfigFormState, ActionEvent, AuthFlowEvent, AuthStatus, AuthModeInfo, ConfigFieldState, ConfigFileState, ConfigFormState,
DetectResult, EnvFieldState, ErrorHint, WriteResult, ConfigVerifyResult, now_secs, ConnectionTestResult, DetectResult, EnvFieldState, ErrorHint, ModelInfo, ModelListResult,
SystemEnvFinding, SystemEnvReport, UpdateCheckResult, WriteResult, ConfigVerifyResult, now_secs,
}; };
@@ -134,6 +134,31 @@ pub fn download_with_curl(url: &str, dest: &std::path::Path) -> std::io::Result<
} }
} }
/// 用系统 `curl.exe` 拉取 HTTPS 文本到 stdout(用于官方源版本查询 / 测试连通)。
/// 仅应配合 `is_url_host_allowed` 白名单校验后使用。`headers` 为额外请求头(如鉴权)。
pub fn fetch_http_text(url: &str, headers: &[String]) -> std::io::Result<String> {
let mut cmd = std::process::Command::new("curl.exe");
cmd.args(["-L", "--fail", "--silent", "--show-error", "--max-time", "20"]);
for h in headers {
cmd.arg("-H").arg(h);
}
cmd.arg(url);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
}
let out = cmd.output()?;
if out.status.success() {
Ok(String::from_utf8_lossy(&out.stdout).to_string())
} else {
Err(std::io::Error::new(
std::io::ErrorKind::Other,
"curl 请求失败(网络错误或来源不可用)",
))
}
}
/// 极简 URL 主机提取(仅用于白名单比对,不解析完整 URL)。 /// 极简 URL 主机提取(仅用于白名单比对,不解析完整 URL)。
fn extract_host(url: &str) -> Option<String> { fn extract_host(url: &str) -> Option<String> {
let s = url.trim(); let s = url.trim();
+73
View File
@@ -291,3 +291,76 @@ pub fn now_secs() -> u64 {
.map(|d| d.as_secs()) .map(|d| d.as_secs())
.unwrap_or(0) .unwrap_or(0)
} }
// =====================================================================
// Wave 3.1:可更新判定 / 模型列表 / 测试连通 / 系统环境诊断
// =====================================================================
/// 「可更新」判定结果(官方源最新版本 vs 本地已装版本 semver 对比)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateCheckResult {
pub cli_id: String,
/// 本地已装版本(未安装为 None)
pub current: Option<String>,
/// 官方源最新版本(查询失败为 None)
pub latest: Option<String>,
/// 是否可更新(本地版本 < 官方最新版本)
pub update_available: bool,
/// 依据来源(中文,如「npm registry」)
pub source_zh: String,
/// 依据来源链接
pub source_url: String,
/// 查询失败原因(成功为 None)
pub error: Option<String>,
}
/// 模型信息(id + 中文名)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelInfo {
pub id: String,
pub label_zh: Option<String>,
}
/// 模型列表结果(来源:CLI 自带列举命令或适配器维护清单)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelListResult {
pub cli_id: String,
pub models: Vec<ModelInfo>,
/// cli_command | adapter | empty
pub source: String,
pub detail_zh: String,
}
/// 「测试连通」结果(已授权工具直连官方接口测一次)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConnectionTestResult {
pub cli_id: String,
pub ok: bool,
pub message_zh: String,
pub http_status: Option<u16>,
/// 脱敏后的补充信息(不含密钥明文)
pub detail: Option<String>,
}
/// 系统环境诊断:单个环境依赖项的状态及其影响的 CLI。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SystemEnvFinding {
/// 依赖 idnode / npm / python / uv / git / winget / apt
pub runtime_id: String,
/// 中文名(Node.js / Python / Git / uv / winget / apt
pub label_zh: String,
/// installed | not_installed | not_in_path | below_min
pub status: String,
/// 当前版本(可为 None
pub version: Option<String>,
/// 该依赖缺失/版本过低时受影响的 CLI id 列表
pub affected_cli: Vec<String>,
/// 中文结论
pub message_zh: String,
}
/// 系统环境诊断报告。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SystemEnvReport {
pub findings: Vec<SystemEnvFinding>,
}