1use 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#[derive(Debug, Clone, Copy)]
36pub struct InstallEnv<'a> {
37 pub cwd: &'a Path,
40 pub home: Option<&'a Path>,
42 pub dsh_home: Option<&'a Path>,
44 pub openclaw_state_dir: Option<&'a Path>,
46 pub hermes_home: Option<&'a Path>,
48 pub kimi_code_home: Option<&'a Path>,
50 pub codex_home: Option<&'a Path>,
52 pub path_env: Option<&'a OsStr>,
54}
55
56#[derive(Debug, Clone, Copy)]
58pub struct InstallOptions<'a> {
59 pub platform: Platform,
60 pub resolved: &'a Resolved,
61 pub env: &'a InstallEnv<'a>,
62 pub dry_run: bool,
64 pub yes: bool,
66 pub host_bin: Option<&'a Path>,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum InstallState {
73 Fresh,
75 Updated,
77 AlreadyCurrent,
79 HostExecuted,
81 HostPrinted,
83 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#[derive(Debug)]
102pub struct InstallReport {
103 pub state: InstallState,
104 pub target: Option<PathBuf>,
106 pub backup: Option<PathBuf>,
108 pub diff: Vec<String>,
110 pub actions: Vec<String>,
112 pub printed: Option<String>,
114}
115
116#[derive(Debug)]
118pub struct InstalledEntry {
119 pub path: PathBuf,
121 pub command: String,
122 pub args: Vec<String>,
123}
124
125#[derive(Debug)]
127pub enum InstallError {
128 Unsupported(String),
130 TargetUnresolved(String),
132 BadExisting(String),
134 Io(String),
136 HostNotFound(String),
138 HostFailed(String),
140 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
158pub 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
175fn 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
192fn 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
247fn 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 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 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
392fn 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
405fn 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
422fn 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 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 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
597fn 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
660fn 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
697fn 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
736fn 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
771pub 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
879fn 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
918fn 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
928fn 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
950fn 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
966fn 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 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
1013fn 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
1037fn 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
1066fn 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
1078fn 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}