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//! 且绝不覆盖已存在文件,动别人工具的配置 = 危险动作,拒)。
6//!
7//! **W-43a 产品化**:配置不再吐占位符——`wanning-mcp` 可执行文件与审计 WAL 路径在
8//! 生成时解析成**真实绝对路径**直写进配置(新用户拿到就能用,不必手改
9//! `{{WAL_PATH}}`),默认预算策略 `--budget` 显式写进 args(保守默认,用户可改);
10//! 不给 `--wal` 时落产品默认账本 `~/.wanning/wal.jsonl`(Windows
11//! `%USERPROFILE%\.wanning\wal.jsonl`,见 [`wanning_core::paths`])。路径一律转成
12//! **正斜杠**(Windows 反斜杠在 JSON/YAML/TOML 里都要转义,正斜杠 Windows 也认)。
13//!
14//! 平台契约来源(零编造):
15//! - claude-code:仓内 `.mcp.json` 现物(W-19 真插实测)——字段面带 `type: stdio`;
16//! - trae:仓内 `.trae/mcp.json` 现物(W-17 直核)——字段面无 `type`,且官方明示
17//!   command **不能含空格**(解析出的路径含空格 = 拒绝生成,绝不产出装不上的配置);
18//! - codex:`~/.codex/config.toml` 的 `[mcp_servers.<id>]` 片段,W-35 直核**无路径
19//!   变量**(W-43a 起写实路径,连占位符也不留)+ TOML `#` 注释;
20//! - kimi:`.kimi-code/mcp.json`(用户级 `$KIMI_CODE_HOME/mcp.json` 或项目级),
21//!   W-40 本机隔离实验修订——kimi-code 0.39.1 实测无 `kimi mcp` 子命令(W-17 的
22//!   `kimi mcp add` 属 legacy kimi-cli 挂法),mcpServers 形态无 `type` 字段、
23//!   无 `${...}` 变量,严格 JSON 无注释;
24//! - workbuddy:`.workbuddy/mcp.json`(W-37 直核官方 MCP-Guide),`mcpServers` 结构
25//!   同款但字段面无 `type`(官方示例只有 command/args/env),文档未提及 `${...}`
26//!   变量;W-17 曾查不到,W-37 换路数(robots/sitemap 绕开 JS 首页)破冰,
27//!   见 docs/research/workbuddy.md;
28//! - deepseek-harness:**不是 mcp.json**——Cordis overlay YAML patch(W-44 任务书
29//!   直核官方 `docs/user/guide/mcp-memory.md` 通用格式 + 本机 dsh 0.1.0-rc.7 包内
30//!   `@deepseek-ai/dsh-mcp-client` README 字段表)。patch entry = `- insert:` 列表,
31//!   `cwd: !!js process.cwd()` 的 js-tag 按官方示例原样;YAML 支持 `#` 注释(与
32//!   TOML/shell 同侧)。真实 dsh 二进制取证:`dsh --profile headless --dump-config
33//!   --patch <生成文件>` exit 0 且 wanning 行进入组合树(W-44 轮,零网络零会话);
34//!   会话级端到端待所有者(dsh 会话 = 模型会话 + 网络,红线 2)。
35//! - openclaw:**原生支持 MCP**(本机 2026.5.22 有 `openclaw mcp` 子命令族,W-45
36//!   隔离实测 `mcp set/list/show` 全绿)——产出 `openclaw mcp set` **命令行**而非
37//!   文件内容:openclaw.json 由宿主自己管理(实测落盘含 commands/messages/agents
38//!   等骨架段),CLI 写入只动 `mcp.servers.wanning` 一段,天然满足「绝不覆盖」。
39//!   字段面 `{command, args}` 与实测落盘逐字一致;官方 docs.openclaw.ai/mcp 直核
40//!   stdio 字段 command/args/env/cwd + env 安全过滤(拦 NODE_OPTIONS 等)。
41//! - hermes:**原生支持 MCP**(本机 hermes-agent v0.19.1 有 `hermes mcp` 子命令族,
42//!   W-45 隔离实测)——产出 `hermes mcp add wanning --command <bin> --args <args>`
43//!   命令行(discovery-first:add 即真连发现工具,挂载即验证;实测 2/2 工具现身,
44//!   落 `$HERMES_HOME/config.yaml` 的 `mcp_servers.<name>` = {command, args,
45//!   enabled: true})。W-45 隔离实测全链路:`hermes -z -t wanning` + 本地 mock LLM
46//!   → allow 400 落 WAL;二次会话同 nonce → replay 拒(链连续)。工具现身名
47//!   `mcp__wanning__*`(deferred catalog),模型侧经 `tool_call(name, arguments)`
48//!   间接调用——直接调 mcp__ 名会报 does not exist(实测教训,写进 notes)。
49//!
50//! 注释纪律:各工具注释语法不同——TOML/shell/YAML 用 `#` 行内注释;**严格 JSON
51//! 没有注释语法**,claude-code/trae/kimi/workbuddy 的说明只能打在 stdout 的
52//! [`Artifact::notes`](文件内容保持纯净 JSON,防解析崩)。
53
54use std::ffi::OsStr;
55use std::path::{Path, PathBuf};
56use std::process::ExitCode;
57
58use serde_json::json;
59
60/// 生成配置里写死的默认总预算(分)。保守默认,用户可在生成配置里改这个数。
61/// 与 `wanning-mcp::DEFAULT_CAP_CENTS` 同值——刻意不引依赖同步,改任一侧时两边
62/// 一起改(契约测试钉住 1000 这个数)。
63pub const DEFAULT_BUDGET_CENTS: u64 = 1_000;
64
65/// 支持的平台(生成器矩阵;字段权威=仓内现物与调研文档,见模块文档)。
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum Platform {
68    ClaudeCode,
69    Codex,
70    Kimi,
71    Trae,
72    WorkBuddy,
73    DeepSeekHarness,
74    OpenClaw,
75    Hermes,
76}
77
78/// 生成失败。全部 fail-closed:宁可拒生成,绝不产出一份装不上/对不上账的配置。
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub enum InitError {
81    /// 未知平台,报错列全矩阵。
82    UnknownPlatform(String),
83    /// PATH 上找不到 `wanning-mcp` 可执行文件;`searched` = 搜过的目录(报错点名)。
84    McpBinaryNotFound { searched: Vec<PathBuf> },
85    /// 显式 `--bin` 指向的路径不是文件。
86    McpBinaryInvalid(String),
87    /// WAL 路径解析失败(默认路径解析不出家目录,或当前目录不可得)。
88    WalPathInvalid(String),
89    /// Trae 的 command 不能含空格(官方明示),解析出的路径含空格 = 拒绝生成。
90    TraeIncompatiblePath(String),
91}
92
93impl InitError {
94    /// 人可读报错;未知平台必须列出全矩阵(契约测试锁定)。
95    pub fn message(&self) -> String {
96        match self {
97            InitError::UnknownPlatform(input) => format!(
98                "未知平台 '{input}'。--platform 支持矩阵:\n  \
99                 claude-code   → 项目根 .mcp.json(type: stdio;W-19 实测)\n  \
100                 codex         → config.toml [mcp_servers.wanning] 片段(无路径变量;W-35)\n  \
101                 kimi          → .kimi-code/mcp.json(无 type 无变量;W-40 实测)\n  \
102                 trae          → .trae/mcp.json(command 不能含空格;W-17)\n  \
103                 workbuddy     → .workbuddy/mcp.json(无 type 无变量;W-37 直核)\n  \
104                 deepseek-harness → Cordis overlay patch(- insert: 列表;W-44)\n  \
105                 openclaw      → `openclaw mcp set` 命令行(mcp.servers 段;W-45 实测)\n  \
106                 hermes        → `hermes mcp add` 命令行(config.yaml mcp_servers;W-45 实测)\n\
107                 未知值 fail-closed,绝不猜。"
108            ),
109            InitError::McpBinaryNotFound { searched } => {
110                let mut message = String::from(
111                    "找不到 wanning-mcp 可执行文件(fail-closed,绝不猜一个命令)。先安装:\n  \
112                     cargo install wanning-cli wanning-mcp\n\
113                     或在 Wanning 仓内 cargo build -p wanning-mcp 后,用 --bin 指到 \
114                     target/debug/wanning-mcp(或把该目录加进 PATH)。",
115                );
116                if !searched.is_empty() {
117                    message.push_str("\n已搜索的 PATH 目录:");
118                    for dir in searched {
119                        message.push_str(&format!("\n  {}", dir.display()));
120                    }
121                }
122                message
123            }
124            InitError::McpBinaryInvalid(message) => message.clone(),
125            InitError::WalPathInvalid(message) => message.clone(),
126            InitError::TraeIncompatiblePath(message) => message.clone(),
127        }
128    }
129}
130
131/// 生成入参:`None` 的字段按产品默认解析。
132#[derive(Debug, Clone, Default, PartialEq, Eq)]
133pub struct GenerateOptions {
134    /// `wanning-mcp` 可执行文件;`None` = 从 PATH 解析。
135    pub mcp_bin: Option<PathBuf>,
136    /// 审计 WAL 路径;`None` = 产品默认 `~/.wanning/wal.jsonl`。
137    pub wal: Option<PathBuf>,
138}
139
140/// 解析完成的一对真实路径(生成内容只吃这个,占位符从此不存在)。
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct Resolved {
143    pub mcp_bin: PathBuf,
144    pub wal: PathBuf,
145}
146
147/// 一份生成物:`notes` 打在 stdout(说明/警示),`content` 是落盘/复制的净内容。
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub struct Artifact {
150    pub notes: Vec<String>,
151    pub content: String,
152}
153
154/// 解析 `--platform` 取值;未知值 fail-closed 列全矩阵。
155pub fn parse_platform(input: &str) -> Result<Platform, InitError> {
156    match input {
157        "claude-code" => Ok(Platform::ClaudeCode),
158        "codex" => Ok(Platform::Codex),
159        "kimi" => Ok(Platform::Kimi),
160        "trae" => Ok(Platform::Trae),
161        "workbuddy" => Ok(Platform::WorkBuddy),
162        "deepseek-harness" => Ok(Platform::DeepSeekHarness),
163        "openclaw" => Ok(Platform::OpenClaw),
164        "hermes" => Ok(Platform::Hermes),
165        other => Err(InitError::UnknownPlatform(other.to_string())),
166    }
167}
168
169/// 解析 `wanning-mcp` 可执行文件路径(纯函数,PATH 由调用方传入便于测试):
170/// 显式路径必须是已存在的文件;否则按 PATH 逐目录找 `wanning-mcp`(+平台可执行
171/// 后缀);都找不到 = [`InitError::McpBinaryNotFound`] 并列出搜过的目录。
172pub fn resolve_bin(
173    explicit: Option<&Path>,
174    path_env: Option<&OsStr>,
175) -> Result<PathBuf, InitError> {
176    if let Some(bin) = explicit {
177        if bin.is_file() {
178            return Ok(bin.to_path_buf());
179        }
180        return Err(InitError::McpBinaryInvalid(format!(
181            "--bin 指向的路径不是文件:{bin:?}\n\
182             先安装:cargo install wanning-cli wanning-mcp\n\
183             或在 Wanning 仓内 cargo build -p wanning-mcp 后,把 --bin 指到 \
184             target/debug/wanning-mcp"
185        )));
186    }
187    let exe = format!("wanning-mcp{}", std::env::consts::EXE_SUFFIX);
188    let mut searched = Vec::new();
189    if let Some(path_env) = path_env {
190        for dir in std::env::split_paths(path_env) {
191            if dir.as_os_str().is_empty() {
192                continue;
193            }
194            searched.push(dir.clone());
195            let candidate = dir.join(&exe);
196            if candidate.is_file() {
197                return Ok(candidate);
198            }
199        }
200    }
201    Err(InitError::McpBinaryNotFound { searched })
202}
203
204/// 解析审计 WAL 路径:显式路径按当前目录转绝对;缺省 = 产品默认
205/// `~/.wanning/wal.jsonl`(家目录解析不出 = fail-closed,绝不猜落点)。
206pub fn resolve_wal(explicit: Option<&Path>) -> Result<PathBuf, InitError> {
207    let wal = match explicit {
208        Some(wal) => wal.to_path_buf(),
209        None => wanning_core::paths::default_wal_path().ok_or_else(|| {
210            InitError::WalPathInvalid(
211                "解析不出默认账本路径(WANNING_HOME / USERPROFILE / HOME 都没有)。\
212                 用 --wal 显式给一个审计 WAL 路径"
213                    .to_string(),
214            )
215        })?,
216    };
217    if wal.is_absolute() {
218        return Ok(wal);
219    }
220    let current = std::env::current_dir()
221        .map_err(|e| InitError::WalPathInvalid(format!("解析当前目录失败: {e}")))?;
222    Ok(current.join(wal))
223}
224
225/// 解析一对生成入参(PATH 取自进程环境)。
226pub fn resolve(options: &GenerateOptions) -> Result<Resolved, InitError> {
227    let path_env = std::env::var_os("PATH");
228    Ok(Resolved {
229        mcp_bin: resolve_bin(options.mcp_bin.as_deref(), path_env.as_deref())?,
230        wal: resolve_wal(options.wal.as_deref())?,
231    })
232}
233
234/// 生成一份平台配置(W-43a 产品形态:真实绝对路径 + 默认预算,零占位符)。
235/// 内容与仓内现物/调研的字段面契约锁定(见 `crates/wanning-init/tests/matrix.rs`)。
236pub fn generate(platform: Platform, options: &GenerateOptions) -> Result<Artifact, InitError> {
237    generate_with(platform, &resolve(options)?)
238}
239
240/// 已解析路径直接生成(调用方已 [`resolve`] 过时免得扫两遍 PATH)。
241pub fn generate_with(platform: Platform, resolved: &Resolved) -> Result<Artifact, InitError> {
242    if matches!(platform, Platform::Trae)
243        && resolved
244            .mcp_bin
245            .to_string_lossy()
246            .chars()
247            .any(char::is_whitespace)
248    {
249        return Err(InitError::TraeIncompatiblePath(format!(
250            "Trae 官方文档要求 command 不能含空格(W-17 直核),解析出的 wanning-mcp 路径含空格:{}\n\
251             把 wanning-mcp 装到无空格路径(cargo install 的默认 bin 目录即可),\
252             或用 --bin 指定无空格路径",
253            slash(&resolved.mcp_bin)
254        )));
255    }
256    let artifact = match platform {
257        Platform::ClaudeCode => claude_code(resolved),
258        Platform::Trae => trae(resolved),
259        Platform::Codex => codex(resolved),
260        Platform::Kimi => kimi(resolved),
261        Platform::WorkBuddy => workbuddy(resolved),
262        Platform::DeepSeekHarness => deepseek_harness(resolved),
263        Platform::OpenClaw => openclaw(resolved),
264        Platform::Hermes => hermes(resolved),
265    };
266    Ok(artifact)
267}
268
269fn single_writer_note() -> &'static str {
270    "多平台同挂一份 WAL 时,第二个写进程 fail-closed 拒启(W-18 单写者锁)是特性不是缺陷"
271}
272
273/// 路径统一正斜杠:Windows 反斜杠在 JSON/YAML/TOML 里都要转义,正斜杠 Windows 也认。
274fn slash(path: &Path) -> String {
275    path.to_string_lossy().replace('\\', "/")
276}
277
278fn budget_arg() -> String {
279    DEFAULT_BUDGET_CENTS.to_string()
280}
281
282/// first-run 三行引导(W-43a):重启 → 认工具 → 验闸。打在 stdout(不进配置文件)。
283pub fn first_run_notes() -> Vec<String> {
284    vec![
285        "① 把生成的配置写进对应位置后,重启你的编码工具(配置只在启动时读取)。".into(),
286        "② 确认 Wanning 已挂载:工具现身名 mcp__wanning__wanning_gate_evaluate(闸评估)与 mcp__wanning__wanning_audit_tail(读审计尾)。".into(),
287        "③ 验证闸在工作:让 agent 试一笔超额消费(默认预算 1000 分 = ¥10),应被拒绝且 reason=over_budget;放行与拒绝都落审计账本,`wanning audit` 可对账。".into(),
288    ]
289}
290
291fn json_artifact(value: serde_json::Value, mut notes: Vec<String>) -> Artifact {
292    let mut content = serde_json::to_string_pretty(&value).expect("静态 JSON 序列化");
293    content.push('\n');
294    notes.push(first_run_note_line());
295    Artifact { notes, content }
296}
297
298fn first_run_note_line() -> String {
299    "装完三步:重启工具 → 认工具 mcp__wanning__wanning_gate_evaluate → 试一笔超额消费应被拒(over_budget)".to_string()
300}
301
302fn claude_code(resolved: &Resolved) -> Artifact {
303    // 与仓内 .mcp.json 现物字段面全等(type: stdio,W-19 实测同款);严格 JSON →
304    // 无注释,说明走 notes。W-43a 起命令/账本都是解析出的真实绝对路径。
305    let value = json!({
306        "mcpServers": {
307            "wanning": {
308                "type": "stdio",
309                "command": slash(&resolved.mcp_bin),
310                "args": ["--wal", slash(&resolved.wal), "--budget", budget_arg()]
311            }
312        }
313    });
314    json_artifact(
315        value,
316        vec![
317            "Wanning 支付闸 — Claude Code MCP 配置(W-36 生成;W-43a 起写实路径)".into(),
318            format!(
319                "写入位置:项目根 .mcp.json。闸:{},审计账本:{}(每个项目目录可以各挂一份,互不相干)",
320                slash(&resolved.mcp_bin),
321                slash(&resolved.wal)
322            ),
323            "字段面依据仓内 .mcp.json 现物(W-19 真插实测):claude-code 需要 type: stdio,别的平台多半不需要".into(),
324            single_writer_note().into(),
325            "严格 JSON 不支持注释 → 文件内无注释行;实测与语义见 docs/research/mcp-consumption.md".into(),
326        ],
327    )
328}
329
330fn trae(resolved: &Resolved) -> Artifact {
331    let value = json!({
332        "mcpServers": {
333            "wanning": {
334                "command": slash(&resolved.mcp_bin),
335                "args": ["--wal", slash(&resolved.wal), "--budget", budget_arg()]
336            }
337        }
338    });
339    json_artifact(
340        value,
341        vec![
342            "Wanning 支付闸 — Trae MCP 配置(W-36 生成;W-43a 起写实路径)".into(),
343            format!(
344                "写入位置:项目根 .trae/mcp.json。闸:{},审计账本:{}",
345                slash(&resolved.mcp_bin),
346                slash(&resolved.wal)
347            ),
348            "字段面依据仓内 .trae/mcp.json 现物(W-17 直核):无 type 字段,command 不能含空格(含空格的路径已拒绝生成)".into(),
349            single_writer_note().into(),
350            "严格 JSON 不支持注释 → 文件内无注释行".into(),
351        ],
352    )
353}
354
355fn codex(resolved: &Resolved) -> Artifact {
356    Artifact {
357        notes: vec![
358            "Wanning 支付闸 — Codex CLI MCP 配置片段(W-36 生成;W-43a 起写实路径,零占位符)".into(),
359            format!(
360                "追加到 ~/.codex/config.toml(全局)或 <repo>/.codex/config.toml(project-scoped,trust 机制待实测)。闸:{},审计账本:{}",
361                slash(&resolved.mcp_bin),
362                slash(&resolved.wal)
363            ),
364            single_writer_note().into(),
365            "会话级使用需 OpenAI 登录(doctor ✗ auth);配置面免登录已实测(W-35)".into(),
366            first_run_note_line(),
367        ],
368        content: format!(
369            concat!(
370                "# Wanning 支付闸 — Codex CLI MCP 配置片段(W-36 生成;W-43a 起写实路径;字段依据 W-35 调研 docs/research/codex-mcp.md)\n",
371                "# 用法:追加到 ~/.codex/config.toml(全局)或 <repo>/.codex/config.toml(project-scoped,trust 机制待实测)\n",
372                "# W-35 直核:codex 配置没有路径变量 → 本片段已是真实绝对路径,无需手改\n",
373                "# 并发语义:多平台同挂一份 WAL 时,第二个写进程 fail-closed 拒启(W-18 单写者锁)是特性\n",
374                "[mcp_servers.wanning]\n",
375                "command = '{bin}'\n",
376                "args = [\"--wal\", '{wal}', \"--budget\", \"{budget}\"]\n",
377                "# 可选加固(文档字段,待 OpenAI 登录后实测):required = true —— server 起不来就 fail 启动,与闸 fail-closed 同构\n",
378                "# cargo run 备选形态与 startup_timeout_sec 说明见 docs/plugins/codex.md\n",
379            ),
380            bin = slash(&resolved.mcp_bin),
381            wal = slash(&resolved.wal),
382            budget = budget_arg(),
383        ),
384    }
385}
386
387fn kimi(resolved: &Resolved) -> Artifact {
388    // 字段权威 = W-40 本机隔离实验(docs/research/kimi-code-cli.md):
389    // 本机 kimi-code 0.39.1 实测无 `kimi mcp` 子命令(W-17 直核的 kimi mcp add 属
390    // legacy kimi-cli 挂法,所有者机器 ~/.kimi → ~/.kimi-code 迁移痕迹佐证);官方挂法
391    // = $KIMI_CODE_HOME/mcp.json(用户级)或 <repo>/.kimi-code/mcp.json(项目级),
392    // mcpServers → command/args/env;官方文档与 W-40 实验现物均无 type 字段
393    // (stdio 由 command 字段隐含),未提及 ${...} 变量(W-43a 起直接写实路径)。
394    // W-40 实验同款形态被真 kimi 二进制接受并完成 MCP 往返(allow/replay/
395    // over_budget 三判定落 WAL)。严格 JSON → 无注释,说明走 notes。
396    let value = json!({
397        "mcpServers": {
398            "wanning": {
399                "command": slash(&resolved.mcp_bin),
400                "args": ["--wal", slash(&resolved.wal), "--budget", budget_arg()]
401            }
402        }
403    });
404    json_artifact(
405        value,
406        vec![
407            "Wanning 支付闸 — Kimi Code CLI MCP 配置(W-36 生成;W-40 按本机实测修订;W-43a 起写实路径)".into(),
408            "写入位置:用户级 ~/.kimi-code/mcp.json(或 $KIMI_CODE_HOME/mcp.json,所有项目生效)或 <repo>/.kimi-code/mcp.json(单项目)".into(),
409            format!(
410                "kimi-code 无 ${{...}} 路径变量(W-40 官方文档直核)→ 本配置已是真实绝对路径:闸 {},审计账本 {}",
411                slash(&resolved.mcp_bin),
412                slash(&resolved.wal)
413            ),
414            "项目级 .kimi-code/mcp.json 在未信任目录会弹 workspace trust 提示(默认拒绝信任)——核对其中列出的命令后再确认;用户级挂法不经 trust 提示".into(),
415            "TUI 内交互管理:/mcp-config(增删改)、/mcp(看连接状态)".into(),
416            single_writer_note().into(),
417            "W-40 已实测:真 kimi 0.39.1 二进制拉起 wanning-mcp,工具注入 + 放行/重放拒/超额拒三判定落 WAL(模型侧为本地 mock,真实模型会话待所有者放行烧额度)".into(),
418        ],
419    )
420}
421
422fn workbuddy(resolved: &Resolved) -> Artifact {
423    // 字段权威 = workbuddy.cn 官方 MCP-Guide(W-37 直核,docs/research/workbuddy.md):
424    // mcpServers → 名字键 → command/args/env;官方示例无 type 字段;未提及 ${...}
425    // 变量(W-43a 起直接写实路径)。严格 JSON → 无注释,说明走 notes。
426    let value = json!({
427        "mcpServers": {
428            "wanning": {
429                "command": slash(&resolved.mcp_bin),
430                "args": ["--wal", slash(&resolved.wal), "--budget", budget_arg()]
431            }
432        }
433    });
434    json_artifact(
435        value,
436        vec![
437            "Wanning 支付闸 — WorkBuddy MCP 配置(W-36 生成,字段依据 W-37 直核官方 MCP-Guide;W-43a 起写实路径)".into(),
438            "写入位置:用户级 ~/.workbuddy/mcp.json(所有项目生效)或 <项目目录>/.workbuddy/mcp.json(单项目)".into(),
439            format!(
440                "WorkBuddy 文档未提及路径变量 → 本配置已是真实绝对路径:闸 {},审计账本 {}",
441                slash(&resolved.mcp_bin),
442                slash(&resolved.wal)
443            ),
444            "官方示例字段面无 type(与 claude-code 现物带 type:stdio 是刻意差异);也可走 UI:侧边栏 插件 → MCP 服务器 → 配置 MCP".into(),
445            "传输形态按官方命令启动式示例推断 stdio,真插实测待所有者桌面端(待实测项)".into(),
446            single_writer_note().into(),
447        ],
448    )
449}
450
451fn deepseek_harness(resolved: &Resolved) -> Artifact {
452    // 字段权威 = W-44 任务书直核(官方 docs/user/guide/mcp-memory.md 的通用
453    // Cordis overlay patch 格式)+ 本机 dsh 0.1.0-rc.7 包内
454    // @deepseek-ai/dsh-mcp-client README(字段表 serverName/transport/command/
455    // args/env/cwd;工具命名 mcp__<serverName>__<rawName>;环境变量经
456    // scrubbedParentEnv 丢弃 credential-shaped 与 DSH_* 后显式 env 才合并)。
457    // patch entry 形态 = 顶层 YAML 数组的 `- insert:` 列表(dsh 官方 patch 语义,
458    // boss 侧 headless profile 自带说明「insert lists」同证)。js-tag 按官方示例
459    // 原样生成,不自创字段。真实 dsh 二进制取证(零网络零会话):隔离 DSH_HOME 下
460    // `dsh --profile headless --dump-config --patch <本文件>` exit 0,wanning 行作为
461    // 独立 patch 层进入组合树;坏 YAML 对照组 exit 1(取证在档
462    // W-44 节)。
463    let content = format!(
464        concat!(
465            "# Wanning 支付闸 — DeepSeek Harness (dsh) Cordis overlay patch(W-44 生成;W-43a 起写实路径)\n",
466            "# 启用二选一:\n",
467            "#   临时:dsh --profile <名> --patch <本文件>\n",
468            "#   持久:把下面 insert 块合并追加进 <profile>/cordis.patch.yml 或\n",
469            "#         $DSH_HOME/cordis.patch.yml(合并追加,绝不整文件覆盖)\n",
470            "- insert:\n",
471            "    - id: wanning-gate                 # 唯一 id\n",
472            "      name: '@deepseek-ai/dsh-mcp-client'\n",
473            "      config:\n",
474            "        serverName: wanning            # 工具将现身为 mcp__wanning__wanning_gate_evaluate\n",
475            "        transport: stdio\n",
476            "        command: {bin}\n",
477            "        args: [\"--wal\", \"{wal}\", \"--budget\", \"{budget}\"]\n",
478            "        env: {{}}\n",
479            "        cwd: !!js process.cwd()\n",
480        ),
481        bin = slash(&resolved.mcp_bin),
482        wal = slash(&resolved.wal),
483        budget = budget_arg(),
484    );
485    Artifact {
486        notes: vec![
487            "Wanning 支付闸 — DeepSeek Harness (dsh) Cordis overlay patch(W-36 生成,W-44 按官方格式入矩阵;W-43a 起写实路径)".into(),
488            format!(
489                "dsh 用 Cordis overlay YAML patch 声明 MCP server(不是 mcp.json);本文件是 patch entry,落盘惯用名 *.cordis.yml(--out 显式给路径,已存在绝不覆盖)。闸 {},审计账本 {}",
490                slash(&resolved.mcp_bin),
491                slash(&resolved.wal)
492            ),
493            "启用二选一:临时 dsh --profile <名> --patch <本文件>;持久 = 把 insert 块合并追加进 <profile>/cordis.patch.yml 或 $DSH_HOME/cordis.patch.yml(合并追加,绝不整文件覆盖)".into(),
494            "工具现身名:mcp__wanning__wanning_gate_evaluate / mcp__wanning__wanning_audit_tail(serverName: wanning → mcp__<serverName>__<tool>,官方命名契约,与 Claude Code/Codex 同形)".into(),
495            "dsh stdio 桥启动子进程前丢弃 ambient credential-shaped 与全部 DSH_* 环境变量(scrubbedParentEnv),其余照常继承 → 将来接真实通道时密钥必须写进本 row 的 config.env,不能赌继承".into(),
496            single_writer_note().into(),
497            "可选加固:config.failOnStartupError: true(默认 false = 闸起不来插件仍激活但零工具,闸位形同虚设;置 true 则 dsh 拒绝激活,与闸 fail-closed 同构)".into(),
498            "dsh 0.1.0-rc.7 = developer preview,官方明示会有破坏性变更——升级后本配置可能要跟着改".into(),
499            "本机 dsh 0.1.0-rc.7 已实测:--dump-config --patch 接受本格式(W-44,隔离 DSH_HOME,零网络零会话);会话级端到端待所有者放行(dsh 会话 = 模型会话 + 网络,红线 2)".into(),
500            first_run_note_line(),
501        ],
502        content,
503    }
504}
505
506fn openclaw(resolved: &Resolved) -> Artifact {
507    // 字段权威 = 本机 OpenClaw 2026.5.22 (a374c3a) 隔离实测(W-45,取证落
508    // W-45 节(取证在档)):`openclaw mcp set wanning '<json>'` 落
509    // $OPENCLAW_STATE_DIR/openclaw.json 的 mcp.servers.<name> = {command, args}
510    // (与 Claude Code mcpServers 同形,无 type 字段);官方 docs.openclaw.ai/mcp
511    // 直核:stdio 字段 command/args/env/cwd + env 安全过滤(env 中拦 NODE_OPTIONS/
512    // PYTHONSTARTUP/DYLD_*/LD_* 等键)。产出 = CLI 命令行:openclaw.json 由宿主
513    // 自己管理(实测落盘含 commands/messages/agents/meta 骨架段),`mcp set` 只动
514    // mcp.servers.wanning 一段,「绝不覆盖」天然成立。诚实边界:W-45 实测到配置面
515    // + models.providers 挂本地 mock 模型为止,工具现身与判定落 WAL 属 agent 回合,
516    // 需 gateway/模型会话(烧额度,红线 2,所有者放行)。
517    let payload = serde_json::to_string(&json!({
518        "command": slash(&resolved.mcp_bin),
519        "args": ["--wal", slash(&resolved.wal), "--budget", budget_arg()]
520    }))
521    .expect("静态 JSON 序列化");
522    Artifact {
523        notes: vec![
524            "Wanning 支付闸 — OpenClaw MCP 配置(W-45 生成;字段依据本机 2026.5.22 隔离实测 + docs.openclaw.ai/mcp 直核)".into(),
525            "执行下面这条命令即完成写入(openclaw.json 由宿主管理,openclaw mcp set 只动 mcp.servers.wanning 一段,绝不整文件覆盖)".into(),
526            format!(
527                "配置落点:openclaw.json 的 mcp.servers.wanning = {{command, args}}。闸 {},审计账本 {}",
528                slash(&resolved.mcp_bin),
529                slash(&resolved.wal)
530            ),
531            "OpenClaw 2026.5.22 原生支持 MCP(mcp list/show/set/unset 子命令族);W-45 隔离 env(OPENCLAW_STATE_DIR/OPENCLAW_CONFIG_PATH)实测 set/list/show 全绿".into(),
532            "stdio 字段面(官方文档直核):command/args/env/cwd;env 有安全过滤,拦 NODE_OPTIONS/PYTHONSTARTUP/DYLD_*/LD_* 等键 → 将来接真实通道时密钥必须写进 env,不能赌继承".into(),
533            "诚实边界:W-45 实测到配置面 + models.providers 挂本地 mock 模型为止;工具现身与判定落 WAL 属 agent 回合,需 gateway/模型会话(烧额度,红线 2,所有者放行)".into(),
534            single_writer_note().into(),
535            first_run_note_line(),
536        ],
537        content: format!("openclaw mcp set wanning '{payload}'\n"),
538    }
539}
540
541fn hermes(resolved: &Resolved) -> Artifact {
542    // 字段权威 = 本机 hermes-agent v0.19.1 (2026.7.30) 隔离实测(W-45):
543    // `hermes mcp add wanning --command <bin> --args <args…>` discovery-first
544    // 即真连发现工具(实测 ✓ Connected, Found 2 tool(s), 2/2 enabled),落
545    // $HERMES_HOME/config.yaml 的 mcp_servers.<name> = {command, args, enabled:
546    // true};`hermes mcp test wanning` 真连 141ms;`hermes -z -t wanning` + 本地
547    // mock LLM → allow 400 落 WAL,二次会话同 nonce → replay 拒(链连续)。
548    // 工具现身名 mcp__wanning__wanning_gate_evaluate / …_audit_tail(deferred
549    // catalog),模型侧经 tool_call(name, arguments) 间接调用——直接调 mcp__ 名
550    // 报 does not exist(W-45 实测教训,写进 notes)。产出 = CLI 命令行(挂载即
551    // 验证;--args 必须是最后一个选项)。
552    let content = format!(
553        "hermes mcp add wanning --command {bin} --args --wal {wal} --budget {budget}\n",
554        bin = slash(&resolved.mcp_bin),
555        wal = slash(&resolved.wal),
556        budget = budget_arg(),
557    );
558    Artifact {
559        notes: vec![
560            "Wanning 支付闸 — Hermes Agent MCP 配置(W-45 生成;字段依据本机 hermes v0.19.1 隔离实测 + 包内 cli-config.yaml.example 直核)".into(),
561            format!(
562                "执行下面这条命令即完成挂载(mcp add 是 discovery-first:真连一次发现工具,挂载即验证)。配置落点:$HERMES_HOME/config.yaml 的 mcp_servers 段。闸 {},审计账本 {}",
563                slash(&resolved.mcp_bin),
564                slash(&resolved.wal)
565            ),
566            "终端里跑会问 Enable all 2 tools? [Y/n] 回车即全开;脚本/CI 无 TTY 场景用 echo y | 管道喂确认(W-45 实测)".into(),
567            "落盘形态(实测原文):mcp_servers.wanning = {command: <bin>, args: [--wal, <wal>, --budget, '1000'], enabled: true};管理用 hermes mcp list / mcp test wanning / mcp remove wanning".into(),
568            "工具现身名: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(),
569            "one-shot 会话要显式带 toolset:hermes -z \"…\" -t wanning(默认 cli 工具集不含 MCP 工具,W-45 实测)".into(),
570            "W-45 已实测(真 hermes 二进制 + 本地 mock LLM,零外网零真实消费):allow 400 分落 WAL;二次会话同 nonce → replay 拒,完整性链连续;真实模型会话待所有者放行烧额度(红线 2)".into(),
571            single_writer_note().into(),
572            first_run_note_line(),
573        ],
574        content,
575    }
576}
577
578// ── CLI 面(W-43a:统一入口 `wanning init` 与旧 bin `wanning-init` 共用) ────
579
580const USAGE: &str = "wanning-init:给编码工具吐 Wanning MCP 配置(零网络、零真实消费)
581
582用法: wanning-init --platform <名> [--bin <wanning-mcp 路径>] [--wal <审计账本路径>] [--out <文件>]
583
584  --platform <名>  目标平台(必填):claude-code / codex / kimi / trae / workbuddy /
585                   deepseek-harness / openclaw / hermes
586  --bin <路径>     wanning-mcp 可执行文件;缺省从 PATH 解析(找不到 = 拒,给安装指引)
587  --wal <路径>     审计 WAL 路径;缺省 = 产品默认 ~/.wanning/wal.jsonl(Windows %USERPROFILE%\\.wanning)
588  --out <文件>     落盘路径;缺省只打印 stdout。已存在的文件**绝不覆盖**(动别人工具的配置 = 危险动作)
589  -h / --help      打印本说明后退出
590";
591
592/// CLI 错误分层:用法错(退出码 2)与运行失败(退出码 1)。
593enum CliError {
594    Usage(String),
595    Failed(String),
596}
597
598/// 统一 CLI 主体:旧 bin `wanning-init` 与统一入口 `wanning init` 走同一段逻辑
599/// (`program` 只进报错前缀,保证两个入口的报错口径一致)。
600/// 退出码:0 成功;2 用法错(参数缺失/未知);1 运行失败(解析不到路径/拒绝覆盖)。
601pub fn run_cli(program: &str, args: &[String]) -> ExitCode {
602    match cli_run(program, args) {
603        Ok(()) => ExitCode::SUCCESS,
604        Err(CliError::Usage(message)) => {
605            eprintln!("{program}: {message}");
606            ExitCode::from(2)
607        }
608        Err(CliError::Failed(message)) => {
609            eprintln!("{program}: {message}");
610            ExitCode::FAILURE
611        }
612    }
613}
614
615fn cli_run(program: &str, args: &[String]) -> Result<(), CliError> {
616    let mut platform: Option<String> = None;
617    let mut mcp_bin: Option<PathBuf> = None;
618    let mut wal: Option<PathBuf> = None;
619    let mut out: Option<PathBuf> = None;
620    let mut index = 0;
621    while index < args.len() {
622        match args[index].as_str() {
623            "-h" | "--help" => {
624                print!("{USAGE}");
625                return Ok(());
626            }
627            "--platform" => platform = Some(next_value(args, &mut index, "--platform")?),
628            "--bin" => mcp_bin = Some(next_path(args, &mut index, "--bin")?),
629            "--wal" => wal = Some(next_path(args, &mut index, "--wal")?),
630            "--out" => out = Some(next_path(args, &mut index, "--out")?),
631            other => {
632                return Err(CliError::Usage(format!(
633                    "未知参数: {other}(用 --help 看用法)"
634                )))
635            }
636        }
637        index += 1;
638    }
639    let Some(platform) = platform else {
640        return Err(CliError::Usage(format!(
641            "缺少 --platform <名>(支持矩阵:claude-code / codex / kimi / trae / workbuddy / \
642             deepseek-harness / openclaw / hermes;--help 看用法;{program} 是 Wanning 的配置生成器)"
643        )));
644    };
645    let platform = parse_platform(&platform).map_err(|e| CliError::Usage(e.message()))?;
646
647    let resolved =
648        resolve(&GenerateOptions { mcp_bin, wal }).map_err(|e| CliError::Failed(e.message()))?;
649    let artifact = generate_with(platform, &resolved).map_err(|e| CliError::Failed(e.message()))?;
650    println!("# Wanning 支付闸 — 配置生成完成");
651    for note in &artifact.notes {
652        println!("# {note}");
653    }
654    for note in first_run_notes() {
655        println!("# {note}");
656    }
657    println!();
658
659    match out {
660        Some(out) => {
661            let content = artifact.content;
662            std::fs::OpenOptions::new()
663                .write(true)
664                .create_new(true)
665                .open(&out)
666                .and_then(|mut file| std::io::Write::write_all(&mut file, content.as_bytes()))
667                .map_err(|e| {
668                    CliError::Failed(if e.kind() == std::io::ErrorKind::AlreadyExists {
669                        format!(
670                            "拒绝覆盖:{} 已存在。动别人工具的配置 = 危险动作;请先人工确认,\
671                             换个文件名,或把已有内容备份后删掉再生成",
672                            out.display()
673                        )
674                    } else {
675                        format!("写 {} 失败: {e}", out.display())
676                    })
677                })?;
678            println!("已写入:{}(绝不覆盖已存在文件)", out.display());
679        }
680        None => print!("{content}", content = artifact.content),
681    }
682    Ok(())
683}
684
685fn next_value(args: &[String], index: &mut usize, flag: &str) -> Result<String, CliError> {
686    *index += 1;
687    args.get(*index)
688        .cloned()
689        .ok_or_else(|| CliError::Usage(format!("{flag} 缺少取值(用 --help 看用法)")))
690}
691
692fn next_path(args: &[String], index: &mut usize, flag: &str) -> Result<PathBuf, CliError> {
693    Ok(PathBuf::from(next_value(args, index, flag)?))
694}