Skip to main content

wanning_init/
install.rs

1//! W-51a:`wanning init --install` 直写安装——消掉「init 只打印、用户手动贴配置」断点。
2//!
3//! 职责边界(与生成器面的关系):条目内容**永远**来自 [`crate::generate_with`]
4//! 的产物(单一事实来源:install 只负责「放进宿主的正确位置」,不另写一份
5//! 字段面)——四个 mcp.json 平台解析产物里的 `mcpServers.wanning`,dsh 取产物
6//! 里的 `- insert:` 块,openclaw/hermes 的宿主命令行原样来自产物。
7//!
8//! 纪律(扩展 W-36「绝不覆盖」):
9//! - 写前必读现有文件;merge 只动 `mcpServers.wanning` / `wanning-gate` 块,
10//!   他人条目语义不动(mcp.json)或逐字节不动(cordis.patch.yml 追加在尾);
11//! - 写前备份 `<file>.wanning.bak`(先备份、后写入);
12//! - 已有 wanning 条目且内容一致 = 已是最新:逐字节不动、不产生备份、无 diff;
13//! - 升级场景打字段级 diff;`--dry-run` 打印将做的全部动作、零落盘(连目录都不建);
14//! - codex 主配置是 TOML 文本面,文本合并的风险大于收益 → fail-closed 不支持
15//!   (报错给 `--out` 人工指引,绝不乱写主配置);
16//! - openclaw/hermes 产出宿主 CLI 命令行,仅 `--yes` 显式时才执行;执行前解析
17//!   宿主真实路径(显式 `--host-bin` 优先,否则 PATH),解析不到或宿主退出码
18//!   非 0 一律 fail-closed。
19//!
20//! 库层不读进程环境:一切宿主路径由 CLI 层显式传入([`InstallEnv`]),测试确定性;
21//! `read_installed_entry` 是 doctor(W-51b)与安装面共用的读取口。
22
23use std::collections::BTreeSet;
24use std::ffi::OsStr;
25use std::fs;
26use std::io::Write as _;
27use std::path::{Path, PathBuf};
28use std::process::{Command, Stdio};
29
30use serde_json::Value;
31
32use crate::{budget_arg, generate_with, slash, Platform, Resolved};
33
34/// 安装环境(全部显式传入;CLI 层负责读进程环境,库层不读 → 测试确定性)。
35#[derive(Debug, Clone, Copy)]
36pub struct InstallEnv<'a> {
37    /// 项目根:`.mcp.json` / `.trae/mcp.json` / `.kimi-code/mcp.json` /
38    /// `.workbuddy/mcp.json` 都落在这里(项目级挂法)。
39    pub cwd: &'a Path,
40    /// 用户主目录(doctor 用户级扫描备用;install 面只动项目级/显式路径)。
41    pub home: Option<&'a Path>,
42    /// `$DSH_HOME`(dsh 配置根);deepseek-harness 落 `$DSH_HOME/cordis.patch.yml`。
43    pub dsh_home: Option<&'a Path>,
44    /// `$OPENCLAW_STATE_DIR`(openclaw.json 所在目录)。
45    pub openclaw_state_dir: Option<&'a Path>,
46    /// `$HERMES_HOME`(hermes config.yaml 所在目录)。
47    pub hermes_home: Option<&'a Path>,
48    /// `$KIMI_CODE_HOME`(kimi 用户级配置根;install 只写项目级 `.kimi-code/`)。
49    pub kimi_code_home: Option<&'a Path>,
50    /// `$CODEX_HOME`(codex config.toml 所在目录;doctor 读,codex 不支持 install)。
51    pub codex_home: Option<&'a Path>,
52    /// PATH(宿主 CLI 解析用;`None` = 无 PATH)。
53    pub path_env: Option<&'a OsStr>,
54}
55
56/// 安装入参。
57#[derive(Debug, Clone, Copy)]
58pub struct InstallOptions<'a> {
59    pub platform: Platform,
60    pub resolved: &'a Resolved,
61    pub env: &'a InstallEnv<'a>,
62    /// 只打印将做的全部动作,零落盘。
63    pub dry_run: bool,
64    /// openclaw/hermes 执行宿主 CLI 的显式确认(缺省只打印命令行)。
65    pub yes: bool,
66    /// 宿主 CLI 可执行文件显式路径(测试/特殊安装布局);缺省按 PATH 解析。
67    pub host_bin: Option<&'a Path>,
68}
69
70/// 安装结果状态(人可读文案见 [`InstallState::label`])。
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum InstallState {
73    /// 全新创建(此前没有配置文件/条目)。
74    Fresh,
75    /// 升级更新(原有 wanning 条目被替换,旧文件已备份)。
76    Updated,
77    /// 已是最新:逐字节未动,无备份无 diff。
78    AlreadyCurrent,
79    /// 已执行宿主 CLI(`--yes`)。
80    HostExecuted,
81    /// 只打印宿主命令行(未执行;加 `--yes` 执行)。
82    HostPrinted,
83    /// dry-run:打印了将做的动作,零落盘。
84    DryRun,
85}
86
87impl InstallState {
88    pub fn label(self) -> &'static str {
89        match self {
90            InstallState::Fresh => "已写入(全新创建)",
91            InstallState::Updated => "已更新(原文件已备份)",
92            InstallState::AlreadyCurrent => "已是最新,逐字节未动",
93            InstallState::HostExecuted => "已执行宿主 CLI",
94            InstallState::HostPrinted => "已打印宿主命令行(未执行;加 --yes 执行)",
95            InstallState::DryRun => "dry-run(不落盘)",
96        }
97    }
98}
99
100/// 安装报告(CLI 打印成「安装报告」块)。
101#[derive(Debug)]
102pub struct InstallReport {
103    pub state: InstallState,
104    /// 实际落点(宿主 CLI 平台与 dry-run 为 `None`)。
105    pub target: Option<PathBuf>,
106    /// 写前备份路径(全新创建/未改动/dry-run/宿主 CLI 为 `None`)。
107    pub backup: Option<PathBuf>,
108    /// 升级场景的字段级 diff(`- ` 旧行 / `+ ` 新行)。
109    pub diff: Vec<String>,
110    /// 将做/已做的动作列表(dry-run 也非空)。
111    pub actions: Vec<String>,
112    /// openclaw/hermes 的宿主命令行(打印与执行同源)。
113    pub printed: Option<String>,
114}
115
116/// 已安装条目(doctor 复用的读取面)。
117#[derive(Debug)]
118pub struct InstalledEntry {
119    /// 配置文件路径。
120    pub path: PathBuf,
121    pub command: String,
122    pub args: Vec<String>,
123}
124
125/// 安装失败。全部 fail-closed:宁可拒装,绝不产出一份坏的/半吊子的配置。
126#[derive(Debug)]
127pub enum InstallError {
128    /// 平台不支持 `--install`(codex:TOML 主配置文本合并风险大,给人工指引)。
129    Unsupported(String),
130    /// 落点解析不出(如 DSH_HOME 未设;不猜落点)。
131    TargetUnresolved(String),
132    /// 现有配置形状不对(损坏 JSON/顶层不是对象/不是 insert 列表),拒绝动它。
133    BadExisting(String),
134    /// 文件系统错误。
135    Io(String),
136    /// 宿主 CLI 解析不到/无法启动。
137    HostNotFound(String),
138    /// 宿主 CLI 执行失败(退出码非 0)。
139    HostFailed(String),
140    /// 生成产物异常(install 依赖生成器,生成失败即拒装)。
141    Generate(String),
142}
143
144impl InstallError {
145    pub fn message(&self) -> String {
146        match self {
147            InstallError::Unsupported(message)
148            | InstallError::TargetUnresolved(message)
149            | InstallError::BadExisting(message)
150            | InstallError::Io(message)
151            | InstallError::HostNotFound(message)
152            | InstallError::HostFailed(message)
153            | InstallError::Generate(message) => message.clone(),
154        }
155    }
156}
157
158/// 执行安装(按平台分发)。
159pub fn install(options: &InstallOptions) -> Result<InstallReport, InstallError> {
160    match options.platform {
161        Platform::Codex => Err(InstallError::Unsupported(
162            "codex 主配置是 TOML 文本面(config.toml),文本合并的风险大于收益,本版不支持 \
163             --install 直写;用 `wanning init --platform codex --out <path>` 生成片段后按 \
164             docs/plugins/codex.md 人工追加"
165                .to_string(),
166        )),
167        Platform::DeepSeekHarness => install_dsh(options),
168        Platform::OpenClaw | Platform::Hermes => install_host(options),
169        Platform::ClaudeCode | Platform::Kimi | Platform::Trae | Platform::WorkBuddy => {
170            install_mcp_json(options)
171        }
172    }
173}
174
175// ── 四 mcp.json 平台(claude-code / kimi / trae / workbuddy) ────────────────
176
177fn mcp_json_path(platform: Platform, env: &InstallEnv) -> PathBuf {
178    let relative: &[&str] = match platform {
179        Platform::ClaudeCode => &[".mcp.json"],
180        Platform::Kimi => &[".kimi-code", "mcp.json"],
181        Platform::Trae => &[".trae", "mcp.json"],
182        Platform::WorkBuddy => &[".workbuddy", "mcp.json"],
183        _ => unreachable!("mcp.json 平台才进这里"),
184    };
185    let mut path = env.cwd.to_path_buf();
186    for part in relative {
187        path = path.join(part);
188    }
189    path
190}
191
192/// install 要写入的 wanning 条目 = 生成器产物里的 `mcpServers.wanning`
193/// (单一事实来源:install 不另写一份字段面)。
194fn generated_entry(options: &InstallOptions) -> Result<Value, InstallError> {
195    let artifact = artifact_for(options)?;
196    let document: Value = serde_json::from_str(&artifact.content)
197        .map_err(|error| InstallError::Generate(format!("生成产物不是合法 JSON: {error}")))?;
198    Ok(document["mcpServers"]["wanning"].clone())
199}
200
201fn parse_mcp_document(text: &str) -> Result<Value, InstallError> {
202    let document: Value = serde_json::from_str(text).map_err(|error| {
203        InstallError::BadExisting(format!(
204            "现有配置不是合法 JSON({error}),拒绝动它;修好或人工处理后再装"
205        ))
206    })?;
207    if !document.is_object() {
208        return Err(InstallError::BadExisting(format!(
209            "现有配置顶层是{},不是 JSON 对象,拒绝动它",
210            type_name(&document)
211        )));
212    }
213    match document.get("mcpServers") {
214        None | Some(Value::Object(_)) => Ok(document),
215        Some(other) => Err(InstallError::BadExisting(format!(
216            "现有配置的 mcpServers 是{},不是对象,拒绝动它",
217            type_name(other)
218        ))),
219    }
220}
221
222struct McpPlan {
223    unchanged: bool,
224    diff: Vec<String>,
225}
226
227fn plan_mcp_merge(document: Option<&Value>, entry: &Value) -> McpPlan {
228    let existing = document
229        .and_then(|doc| doc.get("mcpServers"))
230        .and_then(|servers| servers.get("wanning"));
231    match existing {
232        Some(current) if current == entry => McpPlan {
233            unchanged: true,
234            diff: Vec::new(),
235        },
236        Some(current) => McpPlan {
237            unchanged: false,
238            diff: entry_diff(current, entry),
239        },
240        None => McpPlan {
241            unchanged: false,
242            diff: Vec::new(),
243        },
244    }
245}
246
247/// 字段级 diff:两对象按键并集逐字段比对,变了的字段打 `- ` 旧行 / `+ ` 新行;
248/// 任一侧不是对象(不该发生,防御性)则整值对比,保证 diff 永远点名改动。
249fn entry_diff(old: &Value, new: &Value) -> Vec<String> {
250    let mut lines = Vec::new();
251    match (old.as_object(), new.as_object()) {
252        (Some(old_map), Some(new_map)) => {
253            let keys: BTreeSet<&String> = old_map.keys().chain(new_map.keys()).collect();
254            for key in keys {
255                let old_value = old_map.get(key);
256                let new_value = new_map.get(key);
257                if old_value == new_value {
258                    continue;
259                }
260                match (old_value, new_value) {
261                    (Some(value), None) => lines.push(format!("- {key}: {}", render(value))),
262                    (None, Some(value)) => lines.push(format!("+ {key}: {}", render(value))),
263                    (Some(old_value), Some(new_value)) => {
264                        lines.push(format!("- {key}: {}", render(old_value)));
265                        lines.push(format!("+ {key}: {}", render(new_value)));
266                    }
267                    (None, None) => unreachable!("键来自两 map 的并集"),
268                }
269            }
270        }
271        _ => {
272            lines.push(format!("- {}", render(old)));
273            lines.push(format!("+ {}", render(new)));
274        }
275    }
276    lines
277}
278
279fn render(value: &Value) -> String {
280    serde_json::to_string(value).unwrap_or_else(|_| "<不可序列化>".to_string())
281}
282
283fn install_mcp_json(options: &InstallOptions) -> Result<InstallReport, InstallError> {
284    let path = mcp_json_path(options.platform, options.env);
285    let entry = generated_entry(options)?;
286    let existed = path.exists();
287    let raw = if existed { read_optional(&path)? } else { None };
288    let document = match &raw {
289        Some(text) => Some(parse_mcp_document(text)?),
290        None => None,
291    };
292    let plan = plan_mcp_merge(document.as_ref(), &entry);
293
294    if options.dry_run {
295        let mut actions = vec![format!("将写入 {}", path.display())];
296        if plan.unchanged {
297            actions.push("已是最新,--dry-run 也不会有任何改动".to_string());
298        } else {
299            if existed {
300                actions.push(format!(
301                    "将先备份原文件到 {}",
302                    backup_path_for(&path).display()
303                ));
304            }
305            if let Some(old) = document
306                .as_ref()
307                .and_then(|doc| doc.get("mcpServers"))
308                .and_then(|servers| servers.get("wanning"))
309            {
310                for line in entry_diff(old, &entry) {
311                    actions.push(format!("  {line}"));
312                }
313            }
314        }
315        return Ok(InstallReport {
316            state: InstallState::DryRun,
317            target: None,
318            backup: None,
319            diff: plan.diff,
320            actions,
321            printed: None,
322        });
323    }
324
325    if plan.unchanged {
326        return Ok(InstallReport {
327            state: InstallState::AlreadyCurrent,
328            target: Some(path.clone()),
329            backup: None,
330            diff: Vec::new(),
331            actions: vec![format!("{} 已是最新,未改动", path.display())],
332            printed: None,
333        });
334    }
335
336    // merge:他人条目原样保留(mcpServers 里只换 wanning 一段),没有 mcpServers
337    // 就建(serde_json 对 Null 的下标赋值会自动升级成对象)。
338    let mut merged = document.unwrap_or_else(|| serde_json::json!({}));
339    merged["mcpServers"]["wanning"] = entry;
340    let mut content = serde_json::to_string_pretty(&merged)
341        .map_err(|error| InstallError::Io(format!("序列化配置失败: {error}")))?;
342    content.push('\n');
343
344    // 先备份(原文件字节),后写入。
345    let backup = if existed {
346        let backup_path = backup_path_for(&path);
347        fs::copy(&path, &backup_path)
348            .map_err(|error| InstallError::Io(format!("备份 {} 失败: {error}", path.display())))?;
349        Some(backup_path)
350    } else {
351        None
352    };
353    if let Some(parent) = path.parent() {
354        if !parent.as_os_str().is_empty() {
355            fs::create_dir_all(parent).map_err(|error| {
356                InstallError::Io(format!("创建 {} 失败: {error}", parent.display()))
357            })?;
358        }
359    }
360    fs::write(&path, content.as_bytes())
361        .map_err(|error| InstallError::Io(format!("写入 {} 失败: {error}", path.display())))?;
362
363    let mut actions = vec![format!(
364        "{} → {}",
365        path.display(),
366        if existed {
367            "更新 wanning 条目"
368        } else {
369            "全新创建"
370        }
371    )];
372    if let Some(backup) = &backup {
373        actions.push(format!("原文件已备份到 {}", backup.display()));
374    }
375    for line in &plan.diff {
376        actions.push(format!("  {line}"));
377    }
378    Ok(InstallReport {
379        state: if existed {
380            InstallState::Updated
381        } else {
382            InstallState::Fresh
383        },
384        target: Some(path),
385        backup,
386        diff: plan.diff,
387        actions,
388        printed: None,
389    })
390}
391
392// ── deepseek-harness(cordis.patch.yml 文本块级 merge) ──────────────────────
393
394fn dsh_patch_path(env: &InstallEnv) -> Result<PathBuf, InstallError> {
395    let Some(dsh_home) = env.dsh_home else {
396        return Err(InstallError::TargetUnresolved(
397            "deepseek-harness 的落点是 $DSH_HOME/cordis.patch.yml,但 DSH_HOME 未设置;\
398             不猜落点,设好 DSH_HOME 后重试"
399                .to_string(),
400        ));
401    };
402    Ok(dsh_home.join("cordis.patch.yml"))
403}
404
405/// 生成产物里的 `- insert:` 块(头部注释行丢弃,块行原样)。
406fn dsh_block_lines(options: &InstallOptions) -> Result<Vec<String>, InstallError> {
407    let artifact = artifact_for(options)?;
408    let block: Vec<String> = artifact
409        .content
410        .lines()
411        .skip_while(|line| !line.starts_with("- "))
412        .map(str::to_string)
413        .collect();
414    if block.is_empty() {
415        return Err(InstallError::Generate(
416            "生成产物里没有 `- insert:` 块".to_string(),
417        ));
418    }
419    Ok(block)
420}
421
422/// 顶层块扫描:cordis.patch.yml 顶层是 insert 列表(`- ` 行起头,缩进行/空行/
423/// `#` 注释行延续到下一个列 0 行);列 0 出现非 `- ` 非注释非空行 = 顶层不是
424/// 列表,fail-closed。返回每个块的 [起,止) 行区间。
425fn scan_patch_blocks(text: &str) -> Result<Vec<(usize, usize)>, InstallError> {
426    let lines: Vec<&str> = text.lines().collect();
427    let mut blocks = Vec::new();
428    let mut index = 0;
429    while index < lines.len() {
430        let line = lines[index];
431        if line.starts_with("- ") {
432            let start = index;
433            index += 1;
434            while index < lines.len() {
435                let follow = lines[index];
436                if follow.starts_with("- ") {
437                    break;
438                }
439                if follow.trim().is_empty()
440                    || follow.starts_with('#')
441                    || follow.starts_with(' ')
442                    || follow.starts_with('\t')
443                {
444                    index += 1;
445                    continue;
446                }
447                return Err(InstallError::BadExisting(format!(
448                    "cordis.patch.yml 顶层不是 insert 列表(第 {} 行 `{}`),拒绝动它",
449                    index + 1,
450                    follow
451                )));
452            }
453            blocks.push((start, index));
454        } else if line.trim().is_empty() || line.starts_with('#') {
455            index += 1;
456        } else {
457            return Err(InstallError::BadExisting(format!(
458                "cordis.patch.yml 顶层不是 insert 列表(第 {} 行 `{}`),拒绝动它",
459                index + 1,
460                line
461            )));
462        }
463    }
464    Ok(blocks)
465}
466
467fn install_dsh(options: &InstallOptions) -> Result<InstallReport, InstallError> {
468    let path = dsh_patch_path(options.env)?;
469    let block = dsh_block_lines(options)?;
470    let existing = read_optional(&path)?;
471
472    let Some(existing_text) = existing else {
473        let content = block.join("\n") + "\n";
474        if options.dry_run {
475            let mut actions = vec![format!("将写入 {}", path.display())];
476            for line in &block {
477                actions.push(format!("  + {line}"));
478            }
479            return Ok(dry_run_report(actions));
480        }
481        fs::create_dir_all(options.env.dsh_home.expect("上面已判定存在"))
482            .map_err(|error| InstallError::Io(format!("创建 {} 失败: {error}", path.display())))?;
483        fs::write(&path, content.as_bytes())
484            .map_err(|error| InstallError::Io(format!("写入 {} 失败: {error}", path.display())))?;
485        return Ok(InstallReport {
486            state: InstallState::Fresh,
487            target: Some(path.clone()),
488            backup: None,
489            diff: Vec::new(),
490            actions: vec![format!("{} → 全新创建(append 块写入)", path.display())],
491            printed: None,
492        });
493    };
494
495    let lines: Vec<&str> = existing_text.lines().collect();
496    let blocks = scan_patch_blocks(&existing_text)?;
497    let span = blocks.iter().copied().find(|&(start, end)| {
498        lines[start..end]
499            .iter()
500            .any(|line| line.contains("id: wanning-gate"))
501    });
502
503    let (new_text, diff, state) = match span {
504        None => {
505            // 追加在文件尾:他人块逐字节保留(W-44 纪律,append 勿整文件覆盖)。
506            let mut text = existing_text.clone();
507            if !text.ends_with('\n') && !text.is_empty() {
508                text.push('\n');
509            }
510            let mut diff = Vec::new();
511            for line in &block {
512                diff.push(format!("+ {line}"));
513            }
514            (text + &block.join("\n") + "\n", diff, InstallState::Updated)
515        }
516        Some((start, end))
517            if lines[start..end]
518                .iter()
519                .copied()
520                .eq(block.iter().map(String::as_str)) =>
521        {
522            (
523                existing_text.clone(),
524                Vec::new(),
525                InstallState::AlreadyCurrent,
526            )
527        }
528        Some((start, end)) => {
529            // 替换发生在原 wanning 块位置,他人块不动。
530            let mut diff = Vec::new();
531            for line in &lines[start..end] {
532                diff.push(format!("- {line}"));
533            }
534            for line in &block {
535                diff.push(format!("+ {line}"));
536            }
537            let mut rebuilt: Vec<&str> = Vec::new();
538            rebuilt.extend_from_slice(&lines[..start]);
539            rebuilt.extend(block.iter().map(String::as_str));
540            rebuilt.extend_from_slice(&lines[end..]);
541            let mut text = rebuilt.join("\n");
542            text.push('\n');
543            (text, diff, InstallState::Updated)
544        }
545    };
546
547    if state == InstallState::AlreadyCurrent {
548        return Ok(InstallReport {
549            state,
550            target: Some(path.clone()),
551            backup: None,
552            diff: Vec::new(),
553            actions: vec![format!("{} 已是最新,未改动", path.display())],
554            printed: None,
555        });
556    }
557
558    if options.dry_run {
559        let mut actions = vec![format!("将写入 {}", path.display())];
560        for line in &diff {
561            actions.push(format!("  {line}"));
562        }
563        return Ok(dry_run_report(actions));
564    }
565
566    let backup_path = backup_path_for(&path);
567    fs::copy(&path, &backup_path)
568        .map_err(|error| InstallError::Io(format!("备份 {} 失败: {error}", path.display())))?;
569    fs::write(&path, new_text.as_bytes())
570        .map_err(|error| InstallError::Io(format!("写入 {} 失败: {error}", path.display())))?;
571    let mut actions = vec![format!("{} → {}", path.display(), "合并 wanning 块")];
572    actions.push(format!("原文件已备份到 {}", backup_path.display()));
573    for line in &diff {
574        actions.push(format!("  {line}"));
575    }
576    Ok(InstallReport {
577        state: InstallState::Updated,
578        target: Some(path),
579        backup: Some(backup_path),
580        diff,
581        actions,
582        printed: None,
583    })
584}
585
586fn dry_run_report(actions: Vec<String>) -> InstallReport {
587    InstallReport {
588        state: InstallState::DryRun,
589        target: None,
590        backup: None,
591        diff: Vec::new(),
592        actions,
593        printed: None,
594    }
595}
596
597// ── openclaw / hermes(宿主 CLI;--yes 才执行) ───────────────────────────────
598
599fn host_name(platform: Platform) -> &'static str {
600    match platform {
601        Platform::OpenClaw => "openclaw",
602        Platform::Hermes => "hermes",
603        _ => unreachable!("宿主 CLI 平台才进这里"),
604    }
605}
606
607fn install_host(options: &InstallOptions) -> Result<InstallReport, InstallError> {
608    let artifact = artifact_for(options)?;
609    let printed = artifact.content.clone();
610    let name = host_name(options.platform);
611
612    if options.dry_run {
613        let mut actions = vec![format!("将执行宿主 CLI:{}", printed.trim_end())];
614        actions.push("dry-run 不执行,零副作用".to_string());
615        return Ok(InstallReport {
616            state: InstallState::DryRun,
617            target: None,
618            backup: None,
619            diff: Vec::new(),
620            actions,
621            printed: Some(printed),
622        });
623    }
624
625    if !options.yes {
626        return Ok(InstallReport {
627            state: InstallState::HostPrinted,
628            target: None,
629            backup: None,
630            diff: Vec::new(),
631            actions: vec![format!(
632                "复制执行下面的命令即完成挂载(或加 --yes 让 wanning 代执行)"
633            )],
634            printed: Some(printed),
635        });
636    }
637
638    let args = host_args(options, &printed)?;
639    let program = resolve_host(name, options.host_bin, options.env.path_env)?;
640    let (code, stdout, stderr) = exec_host(&program, &args, options.platform == Platform::Hermes)?;
641    if code != 0 {
642        return Err(InstallError::HostFailed(format!(
643            "宿主 CLI {} 退出码 {code},安装失败(fail-closed,不回滚宿主配置);\
644             stdout: {} stderr: {}",
645            program.display(),
646            stdout.trim(),
647            stderr.trim()
648        )));
649    }
650    Ok(InstallReport {
651        state: InstallState::HostExecuted,
652        target: None,
653        backup: None,
654        diff: Vec::new(),
655        actions: vec![format!("已执行:{} {}", program.display(), args.join(" "))],
656        printed: Some(printed),
657    })
658}
659
660/// 宿主 CLI argv:openclaw 的 payload 从打印命令行里原样剥出(打印与执行同源);
661/// hermes 的 argv 与生成器命令行同一组值构成。
662fn host_args(options: &InstallOptions, printed: &str) -> Result<Vec<String>, InstallError> {
663    match options.platform {
664        Platform::OpenClaw => {
665            let line = printed.trim_end();
666            let payload = line
667                .strip_prefix("openclaw mcp set wanning '")
668                .and_then(|rest| rest.strip_suffix('\''))
669                .ok_or_else(|| {
670                    InstallError::Generate(
671                        "openclaw 命令行形态不符合预期(单引号包裹 payload)".to_string(),
672                    )
673                })?;
674            Ok(vec![
675                "mcp".to_string(),
676                "set".to_string(),
677                "wanning".to_string(),
678                payload.to_string(),
679            ])
680        }
681        Platform::Hermes => Ok(vec![
682            "mcp".to_string(),
683            "add".to_string(),
684            "wanning".to_string(),
685            "--command".to_string(),
686            slash(&options.resolved.mcp_bin),
687            "--args".to_string(),
688            "--wal".to_string(),
689            slash(&options.resolved.wal),
690            "--budget".to_string(),
691            budget_arg(),
692        ]),
693        _ => unreachable!("宿主 CLI 平台才进这里"),
694    }
695}
696
697/// 宿主 CLI 解析:显式 `--host-bin` 必须是真实文件;否则按 PATH 逐目录找
698/// (Windows 补 .exe/.cmd/.bat 后缀)。找不到 = fail-closed。
699fn resolve_host(
700    name: &str,
701    host_bin: Option<&Path>,
702    path_env: Option<&OsStr>,
703) -> Result<PathBuf, InstallError> {
704    if let Some(explicit) = host_bin {
705        if explicit.is_file() {
706            return Ok(explicit.to_path_buf());
707        }
708        return Err(InstallError::HostNotFound(format!(
709            "宿主 CLI {name} 在指定路径 {} 不存在(--host-bin 必须指向真实可执行文件)",
710            explicit.display()
711        )));
712    }
713    let Some(path_env) = path_env else {
714        return Err(InstallError::HostNotFound(format!(
715            "环境里没有 PATH,解析不到宿主 CLI {name};用 --host-bin 显式指定"
716        )));
717    };
718    for dir in std::env::split_paths(path_env) {
719        let mut candidates = vec![dir.join(name)];
720        if cfg!(windows) {
721            for ext in [".exe", ".cmd", ".bat"] {
722                candidates.push(dir.join(format!("{name}{ext}")));
723            }
724        }
725        for candidate in candidates {
726            if candidate.is_file() {
727                return Ok(candidate);
728            }
729        }
730    }
731    Err(InstallError::HostNotFound(format!(
732        "宿主 CLI {name} 不在 PATH 里;先安装它,或用 --host-bin 显式指定"
733    )))
734}
735
736/// 执行宿主 CLI。hermes 非 TTY 下 `mcp add` 会问确认(W-45 实测),喂 `y\n` 后
737/// **关闭写端**;openclaw 不喂 stdin。必须手动 spawn + take(stdin):`output()`
738/// 会立即关掉管道写端,确认就读不到了。
739fn exec_host(
740    program: &Path,
741    args: &[String],
742    feed_yes: bool,
743) -> Result<(i32, String, String), InstallError> {
744    let mut command = Command::new(program);
745    command.args(args);
746    command.stdout(Stdio::piped()).stderr(Stdio::piped());
747    command.stdin(if feed_yes {
748        Stdio::piped()
749    } else {
750        Stdio::null()
751    });
752    let mut child = command
753        .spawn()
754        .map_err(|error| InstallError::HostNotFound(format!("宿主 CLI 无法启动({error})")))?;
755    if feed_yes {
756        if let Some(mut stdin) = child.stdin.take() {
757            let _ = stdin.write_all(b"y\n");
758        }
759    }
760    let output = child
761        .wait_with_output()
762        .map_err(|error| InstallError::HostFailed(format!("等待宿主 CLI 失败: {error}")))?;
763    let code = output.status.code().unwrap_or(-1);
764    Ok((
765        code,
766        String::from_utf8_lossy(&output.stdout).into_owned(),
767        String::from_utf8_lossy(&output.stderr).into_owned(),
768    ))
769}
770
771// ── 读取面(doctor 复用) ────────────────────────────────────────────────────
772
773/// 读一个平台已装的 wanning 条目;未装 = `Ok(None)`;配置形状坏 = 报错
774/// (doctor 据此给修复指引,绝不静默当未装)。
775pub fn read_installed_entry(
776    platform: Platform,
777    env: &InstallEnv,
778) -> Result<Option<InstalledEntry>, InstallError> {
779    match platform {
780        Platform::ClaudeCode | Platform::Kimi | Platform::Trae | Platform::WorkBuddy => {
781            let path = mcp_json_path(platform, env);
782            let Some(text) = read_optional(&path)? else {
783                return Ok(None);
784            };
785            let document = parse_mcp_document(&text)?;
786            match document
787                .get("mcpServers")
788                .and_then(|servers| servers.get("wanning"))
789            {
790                Some(value) => entry_from_value(path, value),
791                None => Ok(None),
792            }
793        }
794        Platform::Codex => {
795            let Some(home) = env.codex_home else {
796                return Ok(None);
797            };
798            let path = home.join("config.toml");
799            let Some(text) = read_optional(&path)? else {
800                return Ok(None);
801            };
802            read_codex_fragment(path, &text)
803        }
804        Platform::OpenClaw => {
805            let Some(dir) = env.openclaw_state_dir else {
806                return Ok(None);
807            };
808            let path = dir.join("openclaw.json");
809            let Some(text) = read_optional(&path)? else {
810                return Ok(None);
811            };
812            let document: Value = serde_json::from_str(&text).map_err(|error| {
813                InstallError::BadExisting(format!(
814                    "{} 不是合法 JSON({error}),拒绝解读",
815                    path.display()
816                ))
817            })?;
818            match document.pointer("/mcp/servers/wanning") {
819                Some(value) => entry_from_value(path, value),
820                None => Ok(None),
821            }
822        }
823        Platform::Hermes => {
824            let Some(dir) = env.hermes_home else {
825                return Ok(None);
826            };
827            let path = dir.join("config.yaml");
828            let Some(text) = read_optional(&path)? else {
829                return Ok(None);
830            };
831            read_hermes_config(path, &text)
832        }
833        Platform::DeepSeekHarness => {
834            let path = dsh_patch_path(env)?;
835            let Some(text) = read_optional(&path)? else {
836                return Ok(None);
837            };
838            read_dsh_block(path, &text)
839        }
840    }
841}
842
843fn entry_from_value(path: PathBuf, value: &Value) -> Result<Option<InstalledEntry>, InstallError> {
844    let Some(object) = value.as_object() else {
845        return Err(InstallError::BadExisting(format!(
846            "{} 里的 wanning 条目不是对象,拒绝解读",
847            path.display()
848        )));
849    };
850    let Some(command) = object.get("command").and_then(Value::as_str) else {
851        return Err(InstallError::BadExisting(format!(
852            "{} 里的 wanning 条目缺 command 字符串,拒绝解读",
853            path.display()
854        )));
855    };
856    let Some(args) = object.get("args").and_then(Value::as_array) else {
857        return Err(InstallError::BadExisting(format!(
858            "{} 里的 wanning 条目缺 args 数组,拒绝解读",
859            path.display()
860        )));
861    };
862    let mut parsed = Vec::new();
863    for arg in args {
864        let Some(text) = arg.as_str() else {
865            return Err(InstallError::BadExisting(format!(
866                "{} 里的 wanning 条目 args 含非字符串项,拒绝解读",
867                path.display()
868            )));
869        };
870        parsed.push(text.to_string());
871    }
872    Ok(Some(InstalledEntry {
873        path,
874        command: command.to_string(),
875        args: parsed,
876    }))
877}
878
879/// codex 的 config.toml 是 TOML 文本面:容忍式读 `[mcp_servers.wanning]` 段
880/// (不引 toml 依赖;段边界 = 下一个 `[` 行)。
881fn read_codex_fragment(path: PathBuf, text: &str) -> Result<Option<InstalledEntry>, InstallError> {
882    let lines: Vec<&str> = text.lines().collect();
883    for (index, line) in lines.iter().enumerate() {
884        if line.trim() != "[mcp_servers.wanning]" {
885            continue;
886        }
887        let mut command: Option<String> = None;
888        let mut args: Vec<String> = Vec::new();
889        for follow in &lines[index + 1..] {
890            if follow.trim_start().starts_with('[') {
891                break;
892            }
893            let content = follow.trim();
894            if content.is_empty() || content.starts_with('#') {
895                continue;
896            }
897            if let Some(value) = toml_value_after_key(content, "command") {
898                command = Some(unquote(value));
899            } else if let Some(value) = toml_value_after_key(content, "args") {
900                args = parse_flow_strings(value);
901            }
902        }
903        return match command {
904            Some(command) => Ok(Some(InstalledEntry {
905                path,
906                command,
907                args,
908            })),
909            None => Err(InstallError::BadExisting(format!(
910                "{} 的 [mcp_servers.wanning] 段缺 command,拒绝解读",
911                path.display()
912            ))),
913        };
914    }
915    Ok(None)
916}
917
918/// `key = value` 取值(前缀匹配防误伤:键名必须紧随 `=`,`commands` 不会命中
919/// `command`)。
920fn toml_value_after_key<'a>(line: &'a str, key: &str) -> Option<&'a str> {
921    line.strip_prefix(key)?
922        .trim_start()
923        .strip_prefix('=')?
924        .trim_start()
925        .into()
926}
927
928/// hermes 的 config.yaml:`wanning:` 键的块(缩进更深的后续行,到空行或缩进
929/// 回落为止)。
930fn read_hermes_config(path: PathBuf, text: &str) -> Result<Option<InstalledEntry>, InstallError> {
931    let lines: Vec<&str> = text.lines().collect();
932    for (index, line) in lines.iter().enumerate() {
933        if line.trim() != "wanning:" {
934            continue;
935        }
936        let key_indent = indent_of(line);
937        let mut end = index + 1;
938        while end < lines.len() {
939            let follow = lines[end];
940            if follow.trim().is_empty() || indent_of(follow) <= key_indent {
941                break;
942            }
943            end += 1;
944        }
945        return read_yamlish_entry(path, &lines[index + 1..end]);
946    }
947    Ok(None)
948}
949
950/// dsh 的 cordis.patch.yml:找含 `id: wanning-gate` 的顶层块,块内读
951/// command/args。
952fn read_dsh_block(path: PathBuf, text: &str) -> Result<Option<InstalledEntry>, InstallError> {
953    let blocks = scan_patch_blocks(text)?;
954    let lines: Vec<&str> = text.lines().collect();
955    let span = blocks.iter().copied().find(|&(start, end)| {
956        lines[start..end]
957            .iter()
958            .any(|line| line.contains("id: wanning-gate"))
959    });
960    match span {
961        Some((start, end)) => read_yamlish_entry(path, &lines[start..end]),
962        None => Ok(None),
963    }
964}
965
966/// YAML 形态的容忍式条目读取(hermes config.yaml 块 / dsh patch 块共用):
967/// `command:` 标量 + `args:`(行内 flow 形态或块列表形态)。
968fn read_yamlish_entry(
969    path: PathBuf,
970    lines: &[&str],
971) -> Result<Option<InstalledEntry>, InstallError> {
972    let mut command: Option<String> = None;
973    let mut args: Vec<String> = Vec::new();
974    let mut index = 0;
975    while index < lines.len() {
976        let content = lines[index].trim();
977        if let Some(value) = yaml_scalar_after_key(content, "command") {
978            command = Some(unquote(value));
979        } else if content == "args:" {
980            // 块列表:后面缩进更深的 `- ` 项(hermes 落盘形态)。
981            let base_indent = indent_of(lines[index]);
982            let mut block = Vec::new();
983            let mut scan = index + 1;
984            while scan < lines.len() {
985                let follow = lines[scan];
986                if follow.trim().is_empty() || indent_of(follow) <= base_indent {
987                    break;
988                }
989                match follow.trim().strip_prefix("- ") {
990                    Some(item) => block.push(unquote(item)),
991                    None => break,
992                }
993                scan += 1;
994            }
995            args = block;
996            index = scan;
997            continue;
998        } else if let Some(value) = yaml_scalar_after_key(content, "args") {
999            args = parse_flow_strings(value);
1000        }
1001        index += 1;
1002    }
1003    match command {
1004        Some(command) => Ok(Some(InstalledEntry {
1005            path,
1006            command,
1007            args,
1008        })),
1009        None => Ok(None),
1010    }
1011}
1012
1013/// `key: value` 取值(hermes/dsh 的 YAML 标量;前缀匹配防误伤)。
1014fn yaml_scalar_after_key<'a>(line: &'a str, key: &str) -> Option<&'a str> {
1015    line.strip_prefix(key)?
1016        .strip_prefix(':')?
1017        .trim_start()
1018        .into()
1019}
1020
1021fn parse_flow_strings(text: &str) -> Vec<String> {
1022    let Some(inner) = text
1023        .trim()
1024        .strip_prefix('[')
1025        .and_then(|rest| rest.strip_suffix(']'))
1026    else {
1027        return Vec::new();
1028    };
1029    inner
1030        .split(',')
1031        .map(str::trim)
1032        .filter(|item| !item.is_empty())
1033        .map(unquote)
1034        .collect()
1035}
1036
1037/// 去掉成对的单/双引号(YAML/TOML 标量;不处理转义,闸配置的路径面没有引号字符)。
1038fn unquote(text: &str) -> String {
1039    let trimmed = text.trim();
1040    if trimmed.len() >= 2 {
1041        let bytes = trimmed.as_bytes();
1042        let first = bytes[0];
1043        let last = bytes[trimmed.len() - 1];
1044        if (first == b'\'' || first == b'"') && first == last {
1045            return trimmed[1..trimmed.len() - 1].to_string();
1046        }
1047    }
1048    trimmed.to_string()
1049}
1050
1051fn indent_of(line: &str) -> usize {
1052    line.len() - line.trim_start().len()
1053}
1054
1055fn type_name(value: &Value) -> &'static str {
1056    match value {
1057        Value::Null => "null",
1058        Value::Bool(_) => "布尔值",
1059        Value::Number(_) => "数字",
1060        Value::String(_) => "字符串",
1061        Value::Array(_) => "数组",
1062        Value::Object(_) => "对象",
1063    }
1064}
1065
1066/// 读文件;不存在 = `Ok(None)`(未装),其它错误上抛。
1067fn read_optional(path: &Path) -> Result<Option<String>, InstallError> {
1068    match fs::read_to_string(path) {
1069        Ok(text) => Ok(Some(text)),
1070        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
1071        Err(error) => Err(InstallError::Io(format!(
1072            "读 {} 失败: {error}",
1073            path.display()
1074        ))),
1075    }
1076}
1077
1078/// 备份路径 = `<file>.wanning.bak`(与目标同目录)。
1079fn backup_path_for(path: &Path) -> PathBuf {
1080    let mut name = path.file_name().expect("安装落点必有文件名").to_os_string();
1081    name.push(".wanning.bak");
1082    path.with_file_name(name)
1083}
1084
1085fn artifact_for(options: &InstallOptions) -> Result<crate::Artifact, InstallError> {
1086    generate_with(options.platform, options.resolved)
1087        .map_err(|error| InstallError::Generate(error.message()))
1088}