Skip to main content

wanning_init/
lib.rs

1//! wanning-init:给编码工具吐 Wanning MCP 配置的生成器(W-36;W-43a 产品化改版)。
2//!
3//! 产品边界(与 wanning-demo 演示台分开):本 crate 是**对端工具的接入生成器**——
4//! 零网络、零真实消费、零文件副作用(默认只打印 stdout;写文件必须显式 `--out`
5//! 且绝不覆盖已存在文件,动别人工具的配置 = 危险动作,拒)。W-51a 起另有显式
6//! `--install` 直写面([`install`] 模块):写前读现物 → 只动 wanning 自己的条目 →
7//! 先备份 `<file>.wanning.bak` → 升级打 diff → `--dry-run` 零落盘;codex 拒装给
8//! 人工指引。
9//!
10//! **W-43a 产品化**:配置不再吐占位符——`wanning-mcp` 可执行文件与审计 WAL 路径在
11//! 生成时解析成**真实绝对路径**直写进配置(新用户拿到就能用,不必手改
12//! `{{WAL_PATH}}`),默认预算策略 `--budget` 显式写进 args(保守默认,用户可改);
13//! 不给 `--wal` 时落产品默认账本 `~/.wanning/wal.jsonl`(Windows
14//! `%USERPROFILE%\.wanning\wal.jsonl`,见 [`wanning_core::paths`])。路径一律转成
15//! **正斜杠**(Windows 反斜杠在 JSON/YAML/TOML 里都要转义,正斜杠 Windows 也认)。
16//!
17//! 平台契约来源(零编造):
18//! - claude-code:仓内 `.mcp.json` 现物(W-19 真插实测)——字段面带 `type: stdio`;
19//! - trae:仓内 `.trae/mcp.json` 现物(W-17 直核)——字段面无 `type`,且官方明示
20//!   command **不能含空格**(解析出的路径含空格 = 拒绝生成,绝不产出装不上的配置);
21//! - codex:`~/.codex/config.toml` 的 `[mcp_servers.<id>]` 片段,W-35 直核**无路径
22//!   变量**(W-43a 起写实路径,连占位符也不留)+ TOML `#` 注释;
23//! - kimi:`.kimi-code/mcp.json`(用户级 `$KIMI_CODE_HOME/mcp.json` 或项目级),
24//!   W-40 本机隔离实验修订——kimi-code 0.39.1 实测无 `kimi mcp` 子命令(W-17 的
25//!   `kimi mcp add` 属 legacy kimi-cli 挂法),mcpServers 形态无 `type` 字段、
26//!   无 `${...}` 变量,严格 JSON 无注释;
27//! - workbuddy:`.workbuddy/mcp.json`(W-37 直核官方 MCP-Guide),`mcpServers` 结构
28//!   同款但字段面无 `type`(官方示例只有 command/args/env),文档未提及 `${...}`
29//!   变量;W-17 曾查不到,W-37 换路数(robots/sitemap 绕开 JS 首页)破冰,
30//!   见 docs/research/workbuddy.md;
31//! - deepseek-harness:**不是 mcp.json**——Cordis overlay YAML patch(W-44 任务书
32//!   直核官方 `docs/user/guide/mcp-memory.md` 通用格式 + 本机 dsh 0.1.0-rc.7 包内
33//!   `@deepseek-ai/dsh-mcp-client` README 字段表)。patch entry = `- insert:` 列表,
34//!   `cwd: !!js process.cwd()` 的 js-tag 按官方示例原样;YAML 支持 `#` 注释(与
35//!   TOML/shell 同侧)。真实 dsh 二进制取证:`dsh --profile headless --dump-config
36//!   --patch <生成文件>` exit 0 且 wanning 行进入组合树(W-44 轮,零网络零会话);
37//!   会话级端到端待所有者(dsh 会话 = 模型会话 + 网络,红线 2)。
38//! - openclaw:**原生支持 MCP**(本机 2026.5.22 有 `openclaw mcp` 子命令族,W-45
39//!   隔离实测 `mcp set/list/show` 全绿)——产出 `openclaw mcp set` **命令行**而非
40//!   文件内容:openclaw.json 由宿主自己管理(实测落盘含 commands/messages/agents
41//!   等骨架段),CLI 写入只动 `mcp.servers.wanning` 一段,天然满足「绝不覆盖」。
42//!   字段面 `{command, args}` 与实测落盘逐字一致;官方 docs.openclaw.ai/mcp 直核
43//!   stdio 字段 command/args/env/cwd + env 安全过滤(拦 NODE_OPTIONS 等)。
44//! - hermes:**原生支持 MCP**(本机 hermes-agent v0.19.1 有 `hermes mcp` 子命令族,
45//!   W-45 隔离实测)——产出 `hermes mcp add wanning --command <bin> --args <args>`
46//!   命令行(discovery-first:add 即真连发现工具,挂载即验证;实测 2/2 工具现身,
47//!   落 `$HERMES_HOME/config.yaml` 的 `mcp_servers.<name>` = {command, args,
48//!   enabled: true})。W-45 隔离实测全链路:`hermes -z -t wanning` + 本地 mock LLM
49//!   → allow 400 落 WAL;二次会话同 nonce → replay 拒(链连续)。工具现身名
50//!   `mcp__wanning__*`(deferred catalog),模型侧经 `tool_call(name, arguments)`
51//!   间接调用——直接调 mcp__ 名会报 does not exist(实测教训,写进 notes)。
52//!
53//! 注释纪律:各工具注释语法不同——TOML/shell/YAML 用 `#` 行内注释;**严格 JSON
54//! 没有注释语法**,claude-code/trae/kimi/workbuddy 的说明只能打在 stdout 的
55//! [`Artifact::notes`](文件内容保持纯净 JSON,防解析崩)。
56
57use std::ffi::OsStr;
58use std::path::{Path, PathBuf};
59use std::process::ExitCode;
60
61use serde_json::json;
62
63pub mod install;
64
65/// 生成配置里写死的默认总预算(分)。保守默认,用户可在生成配置里改这个数。
66/// 与 `wanning-mcp::DEFAULT_CAP_CENTS` 同值——刻意不引依赖同步,改任一侧时两边
67/// 一起改(契约测试钉住 1000 这个数)。
68pub const DEFAULT_BUDGET_CENTS: u64 = 1_000;
69
70/// 支持的平台(生成器矩阵;字段权威=仓内现物与调研文档,见模块文档)。
71#[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/// 生成失败。全部 fail-closed:宁可拒生成,绝不产出一份装不上/对不上账的配置。
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub enum InitError {
86    /// 未知平台,报错列全矩阵。
87    UnknownPlatform(String),
88    /// PATH 上找不到 `wanning-mcp` 可执行文件;`searched` = 搜过的目录(报错点名)。
89    McpBinaryNotFound { searched: Vec<PathBuf> },
90    /// 显式 `--bin` 指向的路径不是文件。
91    McpBinaryInvalid(String),
92    /// WAL 路径解析失败(默认路径解析不出家目录,或当前目录不可得)。
93    WalPathInvalid(String),
94    /// Trae 的 command 不能含空格(官方明示),解析出的路径含空格 = 拒绝生成。
95    TraeIncompatiblePath(String),
96}
97
98impl InitError {
99    /// 人可读报错;未知平台必须列出全矩阵(契约测试锁定)。
100    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/// 生成入参:`None` 的字段按产品默认解析。
137#[derive(Debug, Clone, Default, PartialEq, Eq)]
138pub struct GenerateOptions {
139    /// `wanning-mcp` 可执行文件;`None` = 从 PATH 解析。
140    pub mcp_bin: Option<PathBuf>,
141    /// 审计 WAL 路径;`None` = 产品默认 `~/.wanning/wal.jsonl`。
142    pub wal: Option<PathBuf>,
143}
144
145/// 解析完成的一对真实路径(生成内容只吃这个,占位符从此不存在)。
146#[derive(Debug, Clone, PartialEq, Eq)]
147pub struct Resolved {
148    pub mcp_bin: PathBuf,
149    pub wal: PathBuf,
150}
151
152/// 一份生成物:`notes` 打在 stdout(说明/警示),`content` 是落盘/复制的净内容。
153#[derive(Debug, Clone, PartialEq, Eq)]
154pub struct Artifact {
155    pub notes: Vec<String>,
156    pub content: String,
157}
158
159/// 解析 `--platform` 取值;未知值 fail-closed 列全矩阵。
160pub 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
174/// 解析 `wanning-mcp` 可执行文件路径(纯函数,PATH 由调用方传入便于测试):
175/// 显式路径必须是已存在的文件;否则按 PATH 逐目录找 `wanning-mcp`(+平台可执行
176/// 后缀);都找不到 = [`InitError::McpBinaryNotFound`] 并列出搜过的目录。
177pub 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
209/// 解析审计 WAL 路径:显式路径按当前目录转绝对;缺省 = 产品默认
210/// `~/.wanning/wal.jsonl`(家目录解析不出 = fail-closed,绝不猜落点)。
211pub 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
230/// 解析一对生成入参(PATH 取自进程环境)。
231pub 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
239/// 生成一份平台配置(W-43a 产品形态:真实绝对路径 + 默认预算,零占位符)。
240/// 内容与仓内现物/调研的字段面契约锁定(见 `crates/wanning-init/tests/matrix.rs`)。
241pub fn generate(platform: Platform, options: &GenerateOptions) -> Result<Artifact, InitError> {
242    generate_with(platform, &resolve(options)?)
243}
244
245/// 已解析路径直接生成(调用方已 [`resolve`] 过时免得扫两遍 PATH)。
246pub 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
278/// 路径统一正斜杠:Windows 反斜杠在 JSON/YAML/TOML 里都要转义,正斜杠 Windows 也认。
279fn slash(path: &Path) -> String {
280    path.to_string_lossy().replace('\\', "/")
281}
282
283fn budget_arg() -> String {
284    DEFAULT_BUDGET_CENTS.to_string()
285}
286
287/// first-run 三行引导(W-43a):重启 → 认工具 → 验闸。打在 stdout(不进配置文件)。
288pub 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    // 与仓内 .mcp.json 现物字段面全等(type: stdio,W-19 实测同款);严格 JSON →
309    // 无注释,说明走 notes。W-43a 起命令/账本都是解析出的真实绝对路径。
310    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    // 字段权威 = W-40 本机隔离实验(docs/research/kimi-code-cli.md):
394    // 本机 kimi-code 0.39.1 实测无 `kimi mcp` 子命令(W-17 直核的 kimi mcp add 属
395    // legacy kimi-cli 挂法,所有者机器 ~/.kimi → ~/.kimi-code 迁移痕迹佐证);官方挂法
396    // = $KIMI_CODE_HOME/mcp.json(用户级)或 <repo>/.kimi-code/mcp.json(项目级),
397    // mcpServers → command/args/env;官方文档与 W-40 实验现物均无 type 字段
398    // (stdio 由 command 字段隐含),未提及 ${...} 变量(W-43a 起直接写实路径)。
399    // W-40 实验同款形态被真 kimi 二进制接受并完成 MCP 往返(allow/replay/
400    // over_budget 三判定落 WAL)。严格 JSON → 无注释,说明走 notes。
401    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    // 字段权威 = workbuddy.cn 官方 MCP-Guide(W-37 直核,docs/research/workbuddy.md):
429    // mcpServers → 名字键 → command/args/env;官方示例无 type 字段;未提及 ${...}
430    // 变量(W-43a 起直接写实路径)。严格 JSON → 无注释,说明走 notes。
431    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    // 字段权威 = W-44 任务书直核(官方 docs/user/guide/mcp-memory.md 的通用
458    // Cordis overlay patch 格式)+ 本机 dsh 0.1.0-rc.7 包内
459    // @deepseek-ai/dsh-mcp-client README(字段表 serverName/transport/command/
460    // args/env/cwd;工具命名 mcp__<serverName>__<rawName>;环境变量经
461    // scrubbedParentEnv 丢弃 credential-shaped 与 DSH_* 后显式 env 才合并)。
462    // patch entry 形态 = 顶层 YAML 数组的 `- insert:` 列表(dsh 官方 patch 语义,
463    // boss 侧 headless profile 自带说明「insert lists」同证)。js-tag 按官方示例
464    // 原样生成,不自创字段。真实 dsh 二进制取证(零网络零会话):隔离 DSH_HOME 下
465    // `dsh --profile headless --dump-config --patch <本文件>` exit 0,wanning 行作为
466    // 独立 patch 层进入组合树;坏 YAML 对照组 exit 1(取证在档
467    // W-44 节)。
468    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    // 字段权威 = 本机 OpenClaw 2026.5.22 (a374c3a) 隔离实测(W-45,取证落
513    // W-45 节(取证在档)):`openclaw mcp set wanning '<json>'` 落
514    // $OPENCLAW_STATE_DIR/openclaw.json 的 mcp.servers.<name> = {command, args}
515    // (与 Claude Code mcpServers 同形,无 type 字段);官方 docs.openclaw.ai/mcp
516    // 直核:stdio 字段 command/args/env/cwd + env 安全过滤(env 中拦 NODE_OPTIONS/
517    // PYTHONSTARTUP/DYLD_*/LD_* 等键)。产出 = CLI 命令行:openclaw.json 由宿主
518    // 自己管理(实测落盘含 commands/messages/agents/meta 骨架段),`mcp set` 只动
519    // mcp.servers.wanning 一段,「绝不覆盖」天然成立。诚实边界:W-45 实测到配置面
520    // + models.providers 挂本地 mock 模型为止,工具现身与判定落 WAL 属 agent 回合,
521    // 需 gateway/模型会话(烧额度,红线 2,所有者放行)。
522    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    // 字段权威 = 本机 hermes-agent v0.19.1 (2026.7.30) 隔离实测(W-45):
548    // `hermes mcp add wanning --command <bin> --args <args…>` discovery-first
549    // 即真连发现工具(实测 ✓ Connected, Found 2 tool(s), 2/2 enabled),落
550    // $HERMES_HOME/config.yaml 的 mcp_servers.<name> = {command, args, enabled:
551    // true};`hermes mcp test wanning` 真连 141ms;`hermes -z -t wanning` + 本地
552    // mock LLM → allow 400 落 WAL,二次会话同 nonce → replay 拒(链连续)。
553    // 工具现身名 mcp__wanning__wanning_gate_evaluate / …_audit_tail(deferred
554    // catalog),模型侧经 tool_call(name, arguments) 间接调用——直接调 mcp__ 名
555    // 报 does not exist(W-45 实测教训,写进 notes)。产出 = CLI 命令行(挂载即
556    // 验证;--args 必须是最后一个选项)。
557    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
583// ── CLI 面(W-43a:统一入口 `wanning init` 与旧 bin `wanning-init` 共用) ────
584
585const 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
606/// CLI 错误分层:用法错(退出码 2)与运行失败(退出码 1)。
607enum CliError {
608    Usage(String),
609    Failed(String),
610}
611
612/// 统一 CLI 主体:旧 bin `wanning-init` 与统一入口 `wanning init` 走同一段逻辑
613/// (`program` 只进报错前缀,保证两个入口的报错口径一致)。
614/// 退出码:0 成功;2 用法错(参数缺失/未知);1 运行失败(解析不到路径/拒绝覆盖)。
615pub 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
735/// `--install` 直写安装(CLI 面):进程环境在这里读(库层 [`install::install`] 只吃
736/// 显式 [`install::InstallEnv`],测试确定性),报告打成人可读的「安装报告」块。
737fn 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    // 头部与无 --install 的 stdout 同一形态(notes/first-run 引导照打),随后是安装报告。
774    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}