1use std::ffi::OsStr;
58use std::path::{Path, PathBuf};
59use std::process::ExitCode;
60
61use serde_json::json;
62
63pub mod install;
64
65pub const DEFAULT_BUDGET_CENTS: u64 = 1_000;
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum Platform {
73 ClaudeCode,
74 Codex,
75 Kimi,
76 Trae,
77 WorkBuddy,
78 DeepSeekHarness,
79 OpenClaw,
80 Hermes,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
85pub enum InitError {
86 UnknownPlatform(String),
88 McpBinaryNotFound { searched: Vec<PathBuf> },
90 McpBinaryInvalid(String),
92 WalPathInvalid(String),
94 TraeIncompatiblePath(String),
96}
97
98impl InitError {
99 pub fn message(&self) -> String {
101 match self {
102 InitError::UnknownPlatform(input) => format!(
103 "未知平台 '{input}'。--platform 支持矩阵:\n \
104 claude-code → 项目根 .mcp.json(type: stdio;W-19 实测)\n \
105 codex → config.toml [mcp_servers.wanning] 片段(无路径变量;W-35)\n \
106 kimi → .kimi-code/mcp.json(无 type 无变量;W-40 实测)\n \
107 trae → .trae/mcp.json(command 不能含空格;W-17)\n \
108 workbuddy → .workbuddy/mcp.json(无 type 无变量;W-37 直核)\n \
109 deepseek-harness → Cordis overlay patch(- insert: 列表;W-44)\n \
110 openclaw → `openclaw mcp set` 命令行(mcp.servers 段;W-45 实测)\n \
111 hermes → `hermes mcp add` 命令行(config.yaml mcp_servers;W-45 实测)\n\
112 未知值 fail-closed,绝不猜。"
113 ),
114 InitError::McpBinaryNotFound { searched } => {
115 let mut message = String::from(
116 "找不到 wanning-mcp 可执行文件(fail-closed,绝不猜一个命令)。先安装:\n \
117 cargo install wanning-cli wanning-mcp\n\
118 或在 Wanning 仓内 cargo build -p wanning-mcp 后,用 --bin 指到 \
119 target/debug/wanning-mcp(或把该目录加进 PATH)。",
120 );
121 if !searched.is_empty() {
122 message.push_str("\n已搜索的 PATH 目录:");
123 for dir in searched {
124 message.push_str(&format!("\n {}", dir.display()));
125 }
126 }
127 message
128 }
129 InitError::McpBinaryInvalid(message) => message.clone(),
130 InitError::WalPathInvalid(message) => message.clone(),
131 InitError::TraeIncompatiblePath(message) => message.clone(),
132 }
133 }
134}
135
136#[derive(Debug, Clone, Default, PartialEq, Eq)]
138pub struct GenerateOptions {
139 pub mcp_bin: Option<PathBuf>,
141 pub wal: Option<PathBuf>,
143}
144
145#[derive(Debug, Clone, PartialEq, Eq)]
147pub struct Resolved {
148 pub mcp_bin: PathBuf,
149 pub wal: PathBuf,
150}
151
152#[derive(Debug, Clone, PartialEq, Eq)]
154pub struct Artifact {
155 pub notes: Vec<String>,
156 pub content: String,
157}
158
159pub fn parse_platform(input: &str) -> Result<Platform, InitError> {
161 match input {
162 "claude-code" => Ok(Platform::ClaudeCode),
163 "codex" => Ok(Platform::Codex),
164 "kimi" => Ok(Platform::Kimi),
165 "trae" => Ok(Platform::Trae),
166 "workbuddy" => Ok(Platform::WorkBuddy),
167 "deepseek-harness" => Ok(Platform::DeepSeekHarness),
168 "openclaw" => Ok(Platform::OpenClaw),
169 "hermes" => Ok(Platform::Hermes),
170 other => Err(InitError::UnknownPlatform(other.to_string())),
171 }
172}
173
174pub fn resolve_bin(
178 explicit: Option<&Path>,
179 path_env: Option<&OsStr>,
180) -> Result<PathBuf, InitError> {
181 if let Some(bin) = explicit {
182 if bin.is_file() {
183 return Ok(bin.to_path_buf());
184 }
185 return Err(InitError::McpBinaryInvalid(format!(
186 "--bin 指向的路径不是文件:{bin:?}\n\
187 先安装:cargo install wanning-cli wanning-mcp\n\
188 或在 Wanning 仓内 cargo build -p wanning-mcp 后,把 --bin 指到 \
189 target/debug/wanning-mcp"
190 )));
191 }
192 let exe = format!("wanning-mcp{}", std::env::consts::EXE_SUFFIX);
193 let mut searched = Vec::new();
194 if let Some(path_env) = path_env {
195 for dir in std::env::split_paths(path_env) {
196 if dir.as_os_str().is_empty() {
197 continue;
198 }
199 searched.push(dir.clone());
200 let candidate = dir.join(&exe);
201 if candidate.is_file() {
202 return Ok(candidate);
203 }
204 }
205 }
206 Err(InitError::McpBinaryNotFound { searched })
207}
208
209pub fn resolve_wal(explicit: Option<&Path>) -> Result<PathBuf, InitError> {
212 let wal = match explicit {
213 Some(wal) => wal.to_path_buf(),
214 None => wanning_core::paths::default_wal_path().ok_or_else(|| {
215 InitError::WalPathInvalid(
216 "解析不出默认账本路径(WANNING_HOME / USERPROFILE / HOME 都没有)。\
217 用 --wal 显式给一个审计 WAL 路径"
218 .to_string(),
219 )
220 })?,
221 };
222 if wal.is_absolute() {
223 return Ok(wal);
224 }
225 let current = std::env::current_dir()
226 .map_err(|e| InitError::WalPathInvalid(format!("解析当前目录失败: {e}")))?;
227 Ok(current.join(wal))
228}
229
230pub fn resolve(options: &GenerateOptions) -> Result<Resolved, InitError> {
232 let path_env = std::env::var_os("PATH");
233 Ok(Resolved {
234 mcp_bin: resolve_bin(options.mcp_bin.as_deref(), path_env.as_deref())?,
235 wal: resolve_wal(options.wal.as_deref())?,
236 })
237}
238
239pub fn generate(platform: Platform, options: &GenerateOptions) -> Result<Artifact, InitError> {
242 generate_with(platform, &resolve(options)?)
243}
244
245pub fn generate_with(platform: Platform, resolved: &Resolved) -> Result<Artifact, InitError> {
247 if matches!(platform, Platform::Trae)
248 && resolved
249 .mcp_bin
250 .to_string_lossy()
251 .chars()
252 .any(char::is_whitespace)
253 {
254 return Err(InitError::TraeIncompatiblePath(format!(
255 "Trae 官方文档要求 command 不能含空格(W-17 直核),解析出的 wanning-mcp 路径含空格:{}\n\
256 把 wanning-mcp 装到无空格路径(cargo install 的默认 bin 目录即可),\
257 或用 --bin 指定无空格路径",
258 slash(&resolved.mcp_bin)
259 )));
260 }
261 let artifact = match platform {
262 Platform::ClaudeCode => claude_code(resolved),
263 Platform::Trae => trae(resolved),
264 Platform::Codex => codex(resolved),
265 Platform::Kimi => kimi(resolved),
266 Platform::WorkBuddy => workbuddy(resolved),
267 Platform::DeepSeekHarness => deepseek_harness(resolved),
268 Platform::OpenClaw => openclaw(resolved),
269 Platform::Hermes => hermes(resolved),
270 };
271 Ok(artifact)
272}
273
274fn single_writer_note() -> &'static str {
275 "多平台同挂一份 WAL 时,第二个写进程 fail-closed 拒启(W-18 单写者锁)是特性不是缺陷"
276}
277
278fn slash(path: &Path) -> String {
280 path.to_string_lossy().replace('\\', "/")
281}
282
283fn budget_arg() -> String {
284 DEFAULT_BUDGET_CENTS.to_string()
285}
286
287pub fn first_run_notes() -> Vec<String> {
289 vec![
290 "① 把生成的配置写进对应位置后,重启你的编码工具(配置只在启动时读取)。".into(),
291 "② 确认 Wanning 已挂载:工具现身名 mcp__wanning__wanning_gate_evaluate(闸评估)与 mcp__wanning__wanning_audit_tail(读审计尾)。".into(),
292 "③ 验证闸在工作:让 agent 试一笔超额消费(默认预算 1000 分 = ¥10),应被拒绝且 reason=over_budget;放行与拒绝都落审计账本,`wanning audit` 可对账。".into(),
293 ]
294}
295
296fn json_artifact(value: serde_json::Value, mut notes: Vec<String>) -> Artifact {
297 let mut content = serde_json::to_string_pretty(&value).expect("静态 JSON 序列化");
298 content.push('\n');
299 notes.push(first_run_note_line());
300 Artifact { notes, content }
301}
302
303fn first_run_note_line() -> String {
304 "装完三步:重启工具 → 认工具 mcp__wanning__wanning_gate_evaluate → 试一笔超额消费应被拒(over_budget)".to_string()
305}
306
307fn claude_code(resolved: &Resolved) -> Artifact {
308 let value = json!({
311 "mcpServers": {
312 "wanning": {
313 "type": "stdio",
314 "command": slash(&resolved.mcp_bin),
315 "args": ["--wal", slash(&resolved.wal), "--budget", budget_arg()]
316 }
317 }
318 });
319 json_artifact(
320 value,
321 vec![
322 "Wanning 支付闸 — Claude Code MCP 配置(W-36 生成;W-43a 起写实路径)".into(),
323 format!(
324 "写入位置:项目根 .mcp.json。闸:{},审计账本:{}(每个项目目录可以各挂一份,互不相干)",
325 slash(&resolved.mcp_bin),
326 slash(&resolved.wal)
327 ),
328 "字段面依据仓内 .mcp.json 现物(W-19 真插实测):claude-code 需要 type: stdio,别的平台多半不需要".into(),
329 single_writer_note().into(),
330 "严格 JSON 不支持注释 → 文件内无注释行;实测与语义见 docs/research/mcp-consumption.md".into(),
331 ],
332 )
333}
334
335fn trae(resolved: &Resolved) -> Artifact {
336 let value = json!({
337 "mcpServers": {
338 "wanning": {
339 "command": slash(&resolved.mcp_bin),
340 "args": ["--wal", slash(&resolved.wal), "--budget", budget_arg()]
341 }
342 }
343 });
344 json_artifact(
345 value,
346 vec![
347 "Wanning 支付闸 — Trae MCP 配置(W-36 生成;W-43a 起写实路径)".into(),
348 format!(
349 "写入位置:项目根 .trae/mcp.json。闸:{},审计账本:{}",
350 slash(&resolved.mcp_bin),
351 slash(&resolved.wal)
352 ),
353 "字段面依据仓内 .trae/mcp.json 现物(W-17 直核):无 type 字段,command 不能含空格(含空格的路径已拒绝生成)".into(),
354 single_writer_note().into(),
355 "严格 JSON 不支持注释 → 文件内无注释行".into(),
356 ],
357 )
358}
359
360fn codex(resolved: &Resolved) -> Artifact {
361 Artifact {
362 notes: vec![
363 "Wanning 支付闸 — Codex CLI MCP 配置片段(W-36 生成;W-43a 起写实路径,零占位符)".into(),
364 format!(
365 "追加到 ~/.codex/config.toml(全局)或 <repo>/.codex/config.toml(project-scoped,trust 机制待实测)。闸:{},审计账本:{}",
366 slash(&resolved.mcp_bin),
367 slash(&resolved.wal)
368 ),
369 single_writer_note().into(),
370 "会话级使用需 OpenAI 登录(doctor ✗ auth);配置面免登录已实测(W-35)".into(),
371 first_run_note_line(),
372 ],
373 content: format!(
374 concat!(
375 "# Wanning 支付闸 — Codex CLI MCP 配置片段(W-36 生成;W-43a 起写实路径;字段依据 W-35 调研 docs/research/codex-mcp.md)\n",
376 "# 用法:追加到 ~/.codex/config.toml(全局)或 <repo>/.codex/config.toml(project-scoped,trust 机制待实测)\n",
377 "# W-35 直核:codex 配置没有路径变量 → 本片段已是真实绝对路径,无需手改\n",
378 "# 并发语义:多平台同挂一份 WAL 时,第二个写进程 fail-closed 拒启(W-18 单写者锁)是特性\n",
379 "[mcp_servers.wanning]\n",
380 "command = '{bin}'\n",
381 "args = [\"--wal\", '{wal}', \"--budget\", \"{budget}\"]\n",
382 "# 可选加固(文档字段,待 OpenAI 登录后实测):required = true —— server 起不来就 fail 启动,与闸 fail-closed 同构\n",
383 "# cargo run 备选形态与 startup_timeout_sec 说明见 docs/plugins/codex.md\n",
384 ),
385 bin = slash(&resolved.mcp_bin),
386 wal = slash(&resolved.wal),
387 budget = budget_arg(),
388 ),
389 }
390}
391
392fn kimi(resolved: &Resolved) -> Artifact {
393 let value = json!({
402 "mcpServers": {
403 "wanning": {
404 "command": slash(&resolved.mcp_bin),
405 "args": ["--wal", slash(&resolved.wal), "--budget", budget_arg()]
406 }
407 }
408 });
409 json_artifact(
410 value,
411 vec![
412 "Wanning 支付闸 — Kimi Code CLI MCP 配置(W-36 生成;W-40 按本机实测修订;W-43a 起写实路径)".into(),
413 "写入位置:用户级 ~/.kimi-code/mcp.json(或 $KIMI_CODE_HOME/mcp.json,所有项目生效)或 <repo>/.kimi-code/mcp.json(单项目)".into(),
414 format!(
415 "kimi-code 无 ${{...}} 路径变量(W-40 官方文档直核)→ 本配置已是真实绝对路径:闸 {},审计账本 {}",
416 slash(&resolved.mcp_bin),
417 slash(&resolved.wal)
418 ),
419 "项目级 .kimi-code/mcp.json 在未信任目录会弹 workspace trust 提示(默认拒绝信任)——核对其中列出的命令后再确认;用户级挂法不经 trust 提示".into(),
420 "TUI 内交互管理:/mcp-config(增删改)、/mcp(看连接状态)".into(),
421 single_writer_note().into(),
422 "W-40 已实测:真 kimi 0.39.1 二进制拉起 wanning-mcp,工具注入 + 放行/重放拒/超额拒三判定落 WAL(模型侧为本地 mock,真实模型会话待所有者放行烧额度)".into(),
423 ],
424 )
425}
426
427fn workbuddy(resolved: &Resolved) -> Artifact {
428 let value = json!({
432 "mcpServers": {
433 "wanning": {
434 "command": slash(&resolved.mcp_bin),
435 "args": ["--wal", slash(&resolved.wal), "--budget", budget_arg()]
436 }
437 }
438 });
439 json_artifact(
440 value,
441 vec![
442 "Wanning 支付闸 — WorkBuddy MCP 配置(W-36 生成,字段依据 W-37 直核官方 MCP-Guide;W-43a 起写实路径)".into(),
443 "写入位置:用户级 ~/.workbuddy/mcp.json(所有项目生效)或 <项目目录>/.workbuddy/mcp.json(单项目)".into(),
444 format!(
445 "WorkBuddy 文档未提及路径变量 → 本配置已是真实绝对路径:闸 {},审计账本 {}",
446 slash(&resolved.mcp_bin),
447 slash(&resolved.wal)
448 ),
449 "官方示例字段面无 type(与 claude-code 现物带 type:stdio 是刻意差异);也可走 UI:侧边栏 插件 → MCP 服务器 → 配置 MCP".into(),
450 "传输形态按官方命令启动式示例推断 stdio,真插实测待所有者桌面端(待实测项)".into(),
451 single_writer_note().into(),
452 ],
453 )
454}
455
456fn deepseek_harness(resolved: &Resolved) -> Artifact {
457 let content = format!(
469 concat!(
470 "# Wanning 支付闸 — DeepSeek Harness (dsh) Cordis overlay patch(W-44 生成;W-43a 起写实路径)\n",
471 "# 启用二选一:\n",
472 "# 临时:dsh --profile <名> --patch <本文件>\n",
473 "# 持久:把下面 insert 块合并追加进 <profile>/cordis.patch.yml 或\n",
474 "# $DSH_HOME/cordis.patch.yml(合并追加,绝不整文件覆盖)\n",
475 "- insert:\n",
476 " - id: wanning-gate # 唯一 id\n",
477 " name: '@deepseek-ai/dsh-mcp-client'\n",
478 " config:\n",
479 " serverName: wanning # 工具将现身为 mcp__wanning__wanning_gate_evaluate\n",
480 " transport: stdio\n",
481 " command: {bin}\n",
482 " args: [\"--wal\", \"{wal}\", \"--budget\", \"{budget}\"]\n",
483 " env: {{}}\n",
484 " cwd: !!js process.cwd()\n",
485 ),
486 bin = slash(&resolved.mcp_bin),
487 wal = slash(&resolved.wal),
488 budget = budget_arg(),
489 );
490 Artifact {
491 notes: vec![
492 "Wanning 支付闸 — DeepSeek Harness (dsh) Cordis overlay patch(W-36 生成,W-44 按官方格式入矩阵;W-43a 起写实路径)".into(),
493 format!(
494 "dsh 用 Cordis overlay YAML patch 声明 MCP server(不是 mcp.json);本文件是 patch entry,落盘惯用名 *.cordis.yml(--out 显式给路径,已存在绝不覆盖)。闸 {},审计账本 {}",
495 slash(&resolved.mcp_bin),
496 slash(&resolved.wal)
497 ),
498 "启用二选一:临时 dsh --profile <名> --patch <本文件>;持久 = 把 insert 块合并追加进 <profile>/cordis.patch.yml 或 $DSH_HOME/cordis.patch.yml(合并追加,绝不整文件覆盖)".into(),
499 "工具现身名:mcp__wanning__wanning_gate_evaluate / mcp__wanning__wanning_audit_tail(serverName: wanning → mcp__<serverName>__<tool>,官方命名契约,与 Claude Code/Codex 同形)".into(),
500 "dsh stdio 桥启动子进程前丢弃 ambient credential-shaped 与全部 DSH_* 环境变量(scrubbedParentEnv),其余照常继承 → 将来接真实通道时密钥必须写进本 row 的 config.env,不能赌继承".into(),
501 single_writer_note().into(),
502 "可选加固:config.failOnStartupError: true(默认 false = 闸起不来插件仍激活但零工具,闸位形同虚设;置 true 则 dsh 拒绝激活,与闸 fail-closed 同构)".into(),
503 "dsh 0.1.0-rc.7 = developer preview,官方明示会有破坏性变更——升级后本配置可能要跟着改".into(),
504 "本机 dsh 0.1.0-rc.7 已实测:--dump-config --patch 接受本格式(W-44,隔离 DSH_HOME,零网络零会话);会话级端到端待所有者放行(dsh 会话 = 模型会话 + 网络,红线 2)".into(),
505 first_run_note_line(),
506 ],
507 content,
508 }
509}
510
511fn openclaw(resolved: &Resolved) -> Artifact {
512 let payload = serde_json::to_string(&json!({
523 "command": slash(&resolved.mcp_bin),
524 "args": ["--wal", slash(&resolved.wal), "--budget", budget_arg()]
525 }))
526 .expect("静态 JSON 序列化");
527 Artifact {
528 notes: vec![
529 "Wanning 支付闸 — OpenClaw MCP 配置(W-45 生成;字段依据本机 2026.5.22 隔离实测 + docs.openclaw.ai/mcp 直核)".into(),
530 "执行下面这条命令即完成写入(openclaw.json 由宿主管理,openclaw mcp set 只动 mcp.servers.wanning 一段,绝不整文件覆盖)".into(),
531 format!(
532 "配置落点:openclaw.json 的 mcp.servers.wanning = {{command, args}}。闸 {},审计账本 {}",
533 slash(&resolved.mcp_bin),
534 slash(&resolved.wal)
535 ),
536 "OpenClaw 2026.5.22 原生支持 MCP(mcp list/show/set/unset 子命令族);W-45 隔离 env(OPENCLAW_STATE_DIR/OPENCLAW_CONFIG_PATH)实测 set/list/show 全绿".into(),
537 "stdio 字段面(官方文档直核):command/args/env/cwd;env 有安全过滤,拦 NODE_OPTIONS/PYTHONSTARTUP/DYLD_*/LD_* 等键 → 将来接真实通道时密钥必须写进 env,不能赌继承".into(),
538 "诚实边界:W-45 实测到配置面 + models.providers 挂本地 mock 模型为止;工具现身与判定落 WAL 属 agent 回合,需 gateway/模型会话(烧额度,红线 2,所有者放行)".into(),
539 single_writer_note().into(),
540 first_run_note_line(),
541 ],
542 content: format!("openclaw mcp set wanning '{payload}'\n"),
543 }
544}
545
546fn hermes(resolved: &Resolved) -> Artifact {
547 let content = format!(
558 "hermes mcp add wanning --command {bin} --args --wal {wal} --budget {budget}\n",
559 bin = slash(&resolved.mcp_bin),
560 wal = slash(&resolved.wal),
561 budget = budget_arg(),
562 );
563 Artifact {
564 notes: vec![
565 "Wanning 支付闸 — Hermes Agent MCP 配置(W-45 生成;字段依据本机 hermes v0.19.1 隔离实测 + 包内 cli-config.yaml.example 直核)".into(),
566 format!(
567 "执行下面这条命令即完成挂载(mcp add 是 discovery-first:真连一次发现工具,挂载即验证)。配置落点:$HERMES_HOME/config.yaml 的 mcp_servers 段。闸 {},审计账本 {}",
568 slash(&resolved.mcp_bin),
569 slash(&resolved.wal)
570 ),
571 "终端里跑会问 Enable all 2 tools? [Y/n] 回车即全开;脚本/CI 无 TTY 场景用 echo y | 管道喂确认(W-45 实测)".into(),
572 "落盘形态(实测原文):mcp_servers.wanning = {command: <bin>, args: [--wal, <wal>, --budget, '1000'], enabled: true};管理用 hermes mcp list / mcp test wanning / mcp remove wanning".into(),
573 "工具现身名:mcp__wanning__wanning_gate_evaluate / mcp__wanning__wanning_audit_tail(与 Claude Code/Codex/dsh 同形);hermes 把 MCP 工具放进 deferred catalog,模型侧经 tool_call(name, arguments) 间接调用——直接调 mcp__ 名会报 does not exist(W-45 实测教训)".into(),
574 "one-shot 会话要显式带 toolset:hermes -z \"…\" -t wanning(默认 cli 工具集不含 MCP 工具,W-45 实测)".into(),
575 "W-45 已实测(真 hermes 二进制 + 本地 mock LLM,零外网零真实消费):allow 400 分落 WAL;二次会话同 nonce → replay 拒,完整性链连续;真实模型会话待所有者放行烧额度(红线 2)".into(),
576 single_writer_note().into(),
577 first_run_note_line(),
578 ],
579 content,
580 }
581}
582
583const USAGE: &str = "wanning-init:给编码工具吐 Wanning MCP 配置(零网络、零真实消费)
586
587用法: wanning-init --platform <名> [--bin <wanning-mcp 路径>] [--wal <审计账本路径>] [--out <文件>]
588 wanning-init --platform <名> --install [--dry-run] [--yes] [--host-bin <路径>]
589
590 --platform <名> 目标平台(必填):claude-code / codex / kimi / trae / workbuddy /
591 deepseek-harness / openclaw / hermes
592 --bin <路径> wanning-mcp 可执行文件;缺省从 PATH 解析(找不到 = 拒,给安装指引)
593 --wal <路径> 审计 WAL 路径;缺省 = 产品默认 ~/.wanning/wal.jsonl(Windows %USERPROFILE%\\.wanning)
594 --out <文件> 落盘路径;缺省只打印 stdout。已存在的文件**绝不覆盖**(动别人工具的配置 = 危险动作)
595 --install 直写安装:把配置写进宿主工具的正确位置(四 mcp.json 只 merge
596 mcpServers.wanning、dsh 合并追加 cordis.patch.yml、openclaw/hermes
597 执行宿主 CLI)。写前读现物 → 先备份 <file>.wanning.bak → 升级打
598 diff;只动 wanning 自己的条目,他人条目语义不动
599 --dry-run 与 --install 同用:打印将做的全部动作,零落盘(连目录都不建)
600 --yes 与 --install 同用:openclaw/hermes 显式允许 wanning 代执行宿主 CLI
601 (缺省只打印命令行)
602 --host-bin <路径> 宿主 CLI 可执行文件显式路径(缺省按 PATH 解析;找不到 = 拒)
603 -h / --help 打印本说明后退出
604";
605
606enum CliError {
608 Usage(String),
609 Failed(String),
610}
611
612pub fn run_cli(program: &str, args: &[String]) -> ExitCode {
616 match cli_run(program, args) {
617 Ok(()) => ExitCode::SUCCESS,
618 Err(CliError::Usage(message)) => {
619 eprintln!("{program}: {message}");
620 ExitCode::from(2)
621 }
622 Err(CliError::Failed(message)) => {
623 eprintln!("{program}: {message}");
624 ExitCode::FAILURE
625 }
626 }
627}
628
629fn cli_run(program: &str, args: &[String]) -> Result<(), CliError> {
630 let mut platform: Option<String> = None;
631 let mut mcp_bin: Option<PathBuf> = None;
632 let mut wal: Option<PathBuf> = None;
633 let mut out: Option<PathBuf> = None;
634 let mut install = false;
635 let mut dry_run = false;
636 let mut yes = false;
637 let mut host_bin: Option<PathBuf> = None;
638 let mut index = 0;
639 while index < args.len() {
640 match args[index].as_str() {
641 "-h" | "--help" => {
642 print!("{USAGE}");
643 return Ok(());
644 }
645 "--platform" => platform = Some(next_value(args, &mut index, "--platform")?),
646 "--bin" => mcp_bin = Some(next_path(args, &mut index, "--bin")?),
647 "--wal" => wal = Some(next_path(args, &mut index, "--wal")?),
648 "--out" => out = Some(next_path(args, &mut index, "--out")?),
649 "--install" => install = true,
650 "--dry-run" => dry_run = true,
651 "--yes" => yes = true,
652 "--host-bin" => host_bin = Some(next_path(args, &mut index, "--host-bin")?),
653 other => {
654 return Err(CliError::Usage(format!(
655 "未知参数: {other}(用 --help 看用法)"
656 )))
657 }
658 }
659 index += 1;
660 }
661 if install && out.is_some() {
662 return Err(CliError::Usage(
663 "--install 与 --out 冲突:--install 直写宿主配置的固定位置,--out 是生成到指定文件;\
664 二选一(用 --help 看用法)"
665 .to_string(),
666 ));
667 }
668 if !install && yes {
669 return Err(CliError::Usage(
670 "--yes 只在 --install 下有意义(openclaw/hermes 代执行宿主 CLI 的显式确认);\
671 单独给出 = 用法错"
672 .to_string(),
673 ));
674 }
675 if !install && dry_run {
676 return Err(CliError::Usage(
677 "--dry-run 只在 --install 下有意义(打印将做的安装动作,零落盘);单独给出 = 用法错"
678 .to_string(),
679 ));
680 }
681 if !install && host_bin.is_some() {
682 return Err(CliError::Usage(
683 "--host-bin 只在 --install 下有意义(宿主 CLI 解析);单独给出 = 用法错".to_string(),
684 ));
685 }
686 let Some(platform_input) = platform else {
687 return Err(CliError::Usage(format!(
688 "缺少 --platform <名>(支持矩阵:claude-code / codex / kimi / trae / workbuddy / \
689 deepseek-harness / openclaw / hermes;--help 看用法;{program} 是 Wanning 的配置生成器)"
690 )));
691 };
692 let platform = parse_platform(&platform_input).map_err(|e| CliError::Usage(e.message()))?;
693
694 let resolved =
695 resolve(&GenerateOptions { mcp_bin, wal }).map_err(|e| CliError::Failed(e.message()))?;
696 if install {
697 return cli_install(platform, &platform_input, &resolved, dry_run, yes, host_bin);
698 }
699 let artifact = generate_with(platform, &resolved).map_err(|e| CliError::Failed(e.message()))?;
700 println!("# Wanning 支付闸 — 配置生成完成");
701 for note in &artifact.notes {
702 println!("# {note}");
703 }
704 for note in first_run_notes() {
705 println!("# {note}");
706 }
707 println!();
708
709 match out {
710 Some(out) => {
711 let content = artifact.content;
712 std::fs::OpenOptions::new()
713 .write(true)
714 .create_new(true)
715 .open(&out)
716 .and_then(|mut file| std::io::Write::write_all(&mut file, content.as_bytes()))
717 .map_err(|e| {
718 CliError::Failed(if e.kind() == std::io::ErrorKind::AlreadyExists {
719 format!(
720 "拒绝覆盖:{} 已存在。动别人工具的配置 = 危险动作;请先人工确认,\
721 换个文件名,或把已有内容备份后删掉再生成",
722 out.display()
723 )
724 } else {
725 format!("写 {} 失败: {e}", out.display())
726 })
727 })?;
728 println!("已写入:{}(绝不覆盖已存在文件)", out.display());
729 }
730 None => print!("{content}", content = artifact.content),
731 }
732 Ok(())
733}
734
735fn cli_install(
738 platform: Platform,
739 platform_input: &str,
740 resolved: &Resolved,
741 dry_run: bool,
742 yes: bool,
743 host_bin: Option<PathBuf>,
744) -> Result<(), CliError> {
745 let cwd =
746 std::env::current_dir().map_err(|e| CliError::Failed(format!("解析当前目录失败: {e}")))?;
747 let dsh_home = std::env::var_os("DSH_HOME").map(PathBuf::from);
748 let openclaw_state_dir = std::env::var_os("OPENCLAW_STATE_DIR").map(PathBuf::from);
749 let hermes_home = std::env::var_os("HERMES_HOME").map(PathBuf::from);
750 let kimi_code_home = std::env::var_os("KIMI_CODE_HOME").map(PathBuf::from);
751 let codex_home = std::env::var_os("CODEX_HOME").map(PathBuf::from);
752 let path_env = std::env::var_os("PATH");
753 let env = install::InstallEnv {
754 cwd: &cwd,
755 home: None,
756 dsh_home: dsh_home.as_deref(),
757 openclaw_state_dir: openclaw_state_dir.as_deref(),
758 hermes_home: hermes_home.as_deref(),
759 kimi_code_home: kimi_code_home.as_deref(),
760 codex_home: codex_home.as_deref(),
761 path_env: path_env.as_deref(),
762 };
763 let options = install::InstallOptions {
764 platform,
765 resolved,
766 env: &env,
767 dry_run,
768 yes,
769 host_bin: host_bin.as_deref(),
770 };
771 let report = install::install(&options).map_err(|e| CliError::Failed(e.message()))?;
772
773 let artifact = generate_with(platform, resolved).map_err(|e| CliError::Failed(e.message()))?;
775 println!("# Wanning 支付闸 — 配置生成完成");
776 for note in &artifact.notes {
777 println!("# {note}");
778 }
779 for note in first_run_notes() {
780 println!("# {note}");
781 }
782 println!();
783
784 println!("安装报告:");
785 println!(" 状态:{}", report.state.label());
786 if let Some(target) = &report.target {
787 println!(" 落点:{}", target.display());
788 }
789 if let Some(backup) = &report.backup {
790 println!(" 备份:{}(写前原文件字节)", backup.display());
791 }
792 if !report.diff.is_empty() {
793 println!(" 改动:");
794 for line in &report.diff {
795 println!(" {line}");
796 }
797 }
798 for action in &report.actions {
799 println!(" · {action}");
800 }
801 if let Some(printed) = &report.printed {
802 print!("{printed}");
803 }
804 println!(
805 " 下一步:wanning doctor --platform {platform_input} 验证挂载(真握手 + 账本可写 + 缺项清单)"
806 );
807 Ok(())
808}
809
810fn next_value(args: &[String], index: &mut usize, flag: &str) -> Result<String, CliError> {
811 *index += 1;
812 args.get(*index)
813 .cloned()
814 .ok_or_else(|| CliError::Usage(format!("{flag} 缺少取值(用 --help 看用法)")))
815}
816
817fn next_path(args: &[String], index: &mut usize, flag: &str) -> Result<PathBuf, CliError> {
818 Ok(PathBuf::from(next_value(args, index, flag)?))
819}