use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use serde_json::json;
pub mod install;
pub const DEFAULT_BUDGET_CENTS: u64 = 1_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Platform {
ClaudeCode,
Codex,
Kimi,
Trae,
WorkBuddy,
DeepSeekHarness,
OpenClaw,
Hermes,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InitError {
UnknownPlatform(String),
McpBinaryNotFound { searched: Vec<PathBuf> },
McpBinaryInvalid(String),
WalPathInvalid(String),
TraeIncompatiblePath(String),
}
impl InitError {
pub fn message(&self) -> String {
match self {
InitError::UnknownPlatform(input) => format!(
"未知平台 '{input}'。--platform 支持矩阵:\n \
claude-code → 项目根 .mcp.json(type: stdio;W-19 实测)\n \
codex → config.toml [mcp_servers.wanning] 片段(无路径变量;W-35)\n \
kimi → .kimi-code/mcp.json(无 type 无变量;W-40 实测)\n \
trae → .trae/mcp.json(command 不能含空格;W-17)\n \
workbuddy → .workbuddy/mcp.json(无 type 无变量;W-37 直核)\n \
deepseek-harness → Cordis overlay patch(- insert: 列表;W-44)\n \
openclaw → `openclaw mcp set` 命令行(mcp.servers 段;W-45 实测)\n \
hermes → `hermes mcp add` 命令行(config.yaml mcp_servers;W-45 实测)\n\
未知值 fail-closed,绝不猜。"
),
InitError::McpBinaryNotFound { searched } => {
let mut message = String::from(
"找不到 wanning-mcp 可执行文件(fail-closed,绝不猜一个命令)。先安装:\n \
cargo install wanning-cli wanning-mcp\n\
或在 Wanning 仓内 cargo build -p wanning-mcp 后,用 --bin 指到 \
target/debug/wanning-mcp(或把该目录加进 PATH)。",
);
if !searched.is_empty() {
message.push_str("\n已搜索的 PATH 目录:");
for dir in searched {
message.push_str(&format!("\n {}", dir.display()));
}
}
message
}
InitError::McpBinaryInvalid(message) => message.clone(),
InitError::WalPathInvalid(message) => message.clone(),
InitError::TraeIncompatiblePath(message) => message.clone(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct GenerateOptions {
pub mcp_bin: Option<PathBuf>,
pub wal: Option<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Resolved {
pub mcp_bin: PathBuf,
pub wal: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Artifact {
pub notes: Vec<String>,
pub content: String,
}
pub fn parse_platform(input: &str) -> Result<Platform, InitError> {
match input {
"claude-code" => Ok(Platform::ClaudeCode),
"codex" => Ok(Platform::Codex),
"kimi" => Ok(Platform::Kimi),
"trae" => Ok(Platform::Trae),
"workbuddy" => Ok(Platform::WorkBuddy),
"deepseek-harness" => Ok(Platform::DeepSeekHarness),
"openclaw" => Ok(Platform::OpenClaw),
"hermes" => Ok(Platform::Hermes),
other => Err(InitError::UnknownPlatform(other.to_string())),
}
}
pub fn resolve_bin(
explicit: Option<&Path>,
path_env: Option<&OsStr>,
) -> Result<PathBuf, InitError> {
if let Some(bin) = explicit {
if bin.is_file() {
return Ok(bin.to_path_buf());
}
return Err(InitError::McpBinaryInvalid(format!(
"--bin 指向的路径不是文件:{bin:?}\n\
先安装:cargo install wanning-cli wanning-mcp\n\
或在 Wanning 仓内 cargo build -p wanning-mcp 后,把 --bin 指到 \
target/debug/wanning-mcp"
)));
}
let exe = format!("wanning-mcp{}", std::env::consts::EXE_SUFFIX);
let mut searched = Vec::new();
if let Some(path_env) = path_env {
for dir in std::env::split_paths(path_env) {
if dir.as_os_str().is_empty() {
continue;
}
searched.push(dir.clone());
let candidate = dir.join(&exe);
if candidate.is_file() {
return Ok(candidate);
}
}
}
Err(InitError::McpBinaryNotFound { searched })
}
pub fn resolve_wal(explicit: Option<&Path>) -> Result<PathBuf, InitError> {
let wal = match explicit {
Some(wal) => wal.to_path_buf(),
None => wanning_core::paths::default_wal_path().ok_or_else(|| {
InitError::WalPathInvalid(
"解析不出默认账本路径(WANNING_HOME / USERPROFILE / HOME 都没有)。\
用 --wal 显式给一个审计 WAL 路径"
.to_string(),
)
})?,
};
if wal.is_absolute() {
return Ok(wal);
}
let current = std::env::current_dir()
.map_err(|e| InitError::WalPathInvalid(format!("解析当前目录失败: {e}")))?;
Ok(current.join(wal))
}
pub fn resolve(options: &GenerateOptions) -> Result<Resolved, InitError> {
let path_env = std::env::var_os("PATH");
Ok(Resolved {
mcp_bin: resolve_bin(options.mcp_bin.as_deref(), path_env.as_deref())?,
wal: resolve_wal(options.wal.as_deref())?,
})
}
pub fn generate(platform: Platform, options: &GenerateOptions) -> Result<Artifact, InitError> {
generate_with(platform, &resolve(options)?)
}
pub fn generate_with(platform: Platform, resolved: &Resolved) -> Result<Artifact, InitError> {
if matches!(platform, Platform::Trae)
&& resolved
.mcp_bin
.to_string_lossy()
.chars()
.any(char::is_whitespace)
{
return Err(InitError::TraeIncompatiblePath(format!(
"Trae 官方文档要求 command 不能含空格(W-17 直核),解析出的 wanning-mcp 路径含空格:{}\n\
把 wanning-mcp 装到无空格路径(cargo install 的默认 bin 目录即可),\
或用 --bin 指定无空格路径",
slash(&resolved.mcp_bin)
)));
}
let artifact = match platform {
Platform::ClaudeCode => claude_code(resolved),
Platform::Trae => trae(resolved),
Platform::Codex => codex(resolved),
Platform::Kimi => kimi(resolved),
Platform::WorkBuddy => workbuddy(resolved),
Platform::DeepSeekHarness => deepseek_harness(resolved),
Platform::OpenClaw => openclaw(resolved),
Platform::Hermes => hermes(resolved),
};
Ok(artifact)
}
fn single_writer_note() -> &'static str {
"多平台同挂一份 WAL 时,第二个写进程 fail-closed 拒启(W-18 单写者锁)是特性不是缺陷"
}
fn slash(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
fn budget_arg() -> String {
DEFAULT_BUDGET_CENTS.to_string()
}
pub fn first_run_notes() -> Vec<String> {
vec![
"① 把生成的配置写进对应位置后,重启你的编码工具(配置只在启动时读取)。".into(),
"② 确认 Wanning 已挂载:工具现身名 mcp__wanning__wanning_gate_evaluate(闸评估)与 mcp__wanning__wanning_audit_tail(读审计尾)。".into(),
"③ 验证闸在工作:让 agent 试一笔超额消费(默认预算 1000 分 = ¥10),应被拒绝且 reason=over_budget;放行与拒绝都落审计账本,`wanning audit` 可对账。".into(),
]
}
fn json_artifact(value: serde_json::Value, mut notes: Vec<String>) -> Artifact {
let mut content = serde_json::to_string_pretty(&value).expect("静态 JSON 序列化");
content.push('\n');
notes.push(first_run_note_line());
Artifact { notes, content }
}
fn first_run_note_line() -> String {
"装完三步:重启工具 → 认工具 mcp__wanning__wanning_gate_evaluate → 试一笔超额消费应被拒(over_budget)".to_string()
}
fn claude_code(resolved: &Resolved) -> Artifact {
let value = json!({
"mcpServers": {
"wanning": {
"type": "stdio",
"command": slash(&resolved.mcp_bin),
"args": ["--wal", slash(&resolved.wal), "--budget", budget_arg()]
}
}
});
json_artifact(
value,
vec![
"Wanning 支付闸 — Claude Code MCP 配置(W-36 生成;W-43a 起写实路径)".into(),
format!(
"写入位置:项目根 .mcp.json。闸:{},审计账本:{}(每个项目目录可以各挂一份,互不相干)",
slash(&resolved.mcp_bin),
slash(&resolved.wal)
),
"字段面依据仓内 .mcp.json 现物(W-19 真插实测):claude-code 需要 type: stdio,别的平台多半不需要".into(),
single_writer_note().into(),
"严格 JSON 不支持注释 → 文件内无注释行;实测与语义见 docs/research/mcp-consumption.md".into(),
],
)
}
fn trae(resolved: &Resolved) -> Artifact {
let value = json!({
"mcpServers": {
"wanning": {
"command": slash(&resolved.mcp_bin),
"args": ["--wal", slash(&resolved.wal), "--budget", budget_arg()]
}
}
});
json_artifact(
value,
vec![
"Wanning 支付闸 — Trae MCP 配置(W-36 生成;W-43a 起写实路径)".into(),
format!(
"写入位置:项目根 .trae/mcp.json。闸:{},审计账本:{}",
slash(&resolved.mcp_bin),
slash(&resolved.wal)
),
"字段面依据仓内 .trae/mcp.json 现物(W-17 直核):无 type 字段,command 不能含空格(含空格的路径已拒绝生成)".into(),
single_writer_note().into(),
"严格 JSON 不支持注释 → 文件内无注释行".into(),
],
)
}
fn codex(resolved: &Resolved) -> Artifact {
Artifact {
notes: vec![
"Wanning 支付闸 — Codex CLI MCP 配置片段(W-36 生成;W-43a 起写实路径,零占位符)".into(),
format!(
"追加到 ~/.codex/config.toml(全局)或 <repo>/.codex/config.toml(project-scoped,trust 机制待实测)。闸:{},审计账本:{}",
slash(&resolved.mcp_bin),
slash(&resolved.wal)
),
single_writer_note().into(),
"会话级使用需 OpenAI 登录(doctor ✗ auth);配置面免登录已实测(W-35)".into(),
first_run_note_line(),
],
content: format!(
concat!(
"# Wanning 支付闸 — Codex CLI MCP 配置片段(W-36 生成;W-43a 起写实路径;字段依据 W-35 调研 docs/research/codex-mcp.md)\n",
"# 用法:追加到 ~/.codex/config.toml(全局)或 <repo>/.codex/config.toml(project-scoped,trust 机制待实测)\n",
"# W-35 直核:codex 配置没有路径变量 → 本片段已是真实绝对路径,无需手改\n",
"# 并发语义:多平台同挂一份 WAL 时,第二个写进程 fail-closed 拒启(W-18 单写者锁)是特性\n",
"[mcp_servers.wanning]\n",
"command = '{bin}'\n",
"args = [\"--wal\", '{wal}', \"--budget\", \"{budget}\"]\n",
"# 可选加固(文档字段,待 OpenAI 登录后实测):required = true —— server 起不来就 fail 启动,与闸 fail-closed 同构\n",
"# cargo run 备选形态与 startup_timeout_sec 说明见 docs/plugins/codex.md\n",
),
bin = slash(&resolved.mcp_bin),
wal = slash(&resolved.wal),
budget = budget_arg(),
),
}
}
fn kimi(resolved: &Resolved) -> Artifact {
let value = json!({
"mcpServers": {
"wanning": {
"command": slash(&resolved.mcp_bin),
"args": ["--wal", slash(&resolved.wal), "--budget", budget_arg()]
}
}
});
json_artifact(
value,
vec![
"Wanning 支付闸 — Kimi Code CLI MCP 配置(W-36 生成;W-40 按本机实测修订;W-43a 起写实路径)".into(),
"写入位置:用户级 ~/.kimi-code/mcp.json(或 $KIMI_CODE_HOME/mcp.json,所有项目生效)或 <repo>/.kimi-code/mcp.json(单项目)".into(),
format!(
"kimi-code 无 ${{...}} 路径变量(W-40 官方文档直核)→ 本配置已是真实绝对路径:闸 {},审计账本 {}",
slash(&resolved.mcp_bin),
slash(&resolved.wal)
),
"项目级 .kimi-code/mcp.json 在未信任目录会弹 workspace trust 提示(默认拒绝信任)——核对其中列出的命令后再确认;用户级挂法不经 trust 提示".into(),
"TUI 内交互管理:/mcp-config(增删改)、/mcp(看连接状态)".into(),
single_writer_note().into(),
"W-40 已实测:真 kimi 0.39.1 二进制拉起 wanning-mcp,工具注入 + 放行/重放拒/超额拒三判定落 WAL(模型侧为本地 mock,真实模型会话待所有者放行烧额度)".into(),
],
)
}
fn workbuddy(resolved: &Resolved) -> Artifact {
let value = json!({
"mcpServers": {
"wanning": {
"command": slash(&resolved.mcp_bin),
"args": ["--wal", slash(&resolved.wal), "--budget", budget_arg()]
}
}
});
json_artifact(
value,
vec![
"Wanning 支付闸 — WorkBuddy MCP 配置(W-36 生成,字段依据 W-37 直核官方 MCP-Guide;W-43a 起写实路径)".into(),
"写入位置:用户级 ~/.workbuddy/mcp.json(所有项目生效)或 <项目目录>/.workbuddy/mcp.json(单项目)".into(),
format!(
"WorkBuddy 文档未提及路径变量 → 本配置已是真实绝对路径:闸 {},审计账本 {}",
slash(&resolved.mcp_bin),
slash(&resolved.wal)
),
"官方示例字段面无 type(与 claude-code 现物带 type:stdio 是刻意差异);也可走 UI:侧边栏 插件 → MCP 服务器 → 配置 MCP".into(),
"传输形态按官方命令启动式示例推断 stdio,真插实测待所有者桌面端(待实测项)".into(),
single_writer_note().into(),
],
)
}
fn deepseek_harness(resolved: &Resolved) -> Artifact {
let content = format!(
concat!(
"# Wanning 支付闸 — DeepSeek Harness (dsh) Cordis overlay patch(W-44 生成;W-43a 起写实路径)\n",
"# 启用二选一:\n",
"# 临时:dsh --profile <名> --patch <本文件>\n",
"# 持久:把下面 insert 块合并追加进 <profile>/cordis.patch.yml 或\n",
"# $DSH_HOME/cordis.patch.yml(合并追加,绝不整文件覆盖)\n",
"- insert:\n",
" - id: wanning-gate # 唯一 id\n",
" name: '@deepseek-ai/dsh-mcp-client'\n",
" config:\n",
" serverName: wanning # 工具将现身为 mcp__wanning__wanning_gate_evaluate\n",
" transport: stdio\n",
" command: {bin}\n",
" args: [\"--wal\", \"{wal}\", \"--budget\", \"{budget}\"]\n",
" env: {{}}\n",
" cwd: !!js process.cwd()\n",
),
bin = slash(&resolved.mcp_bin),
wal = slash(&resolved.wal),
budget = budget_arg(),
);
Artifact {
notes: vec![
"Wanning 支付闸 — DeepSeek Harness (dsh) Cordis overlay patch(W-36 生成,W-44 按官方格式入矩阵;W-43a 起写实路径)".into(),
format!(
"dsh 用 Cordis overlay YAML patch 声明 MCP server(不是 mcp.json);本文件是 patch entry,落盘惯用名 *.cordis.yml(--out 显式给路径,已存在绝不覆盖)。闸 {},审计账本 {}",
slash(&resolved.mcp_bin),
slash(&resolved.wal)
),
"启用二选一:临时 dsh --profile <名> --patch <本文件>;持久 = 把 insert 块合并追加进 <profile>/cordis.patch.yml 或 $DSH_HOME/cordis.patch.yml(合并追加,绝不整文件覆盖)".into(),
"工具现身名:mcp__wanning__wanning_gate_evaluate / mcp__wanning__wanning_audit_tail(serverName: wanning → mcp__<serverName>__<tool>,官方命名契约,与 Claude Code/Codex 同形)".into(),
"dsh stdio 桥启动子进程前丢弃 ambient credential-shaped 与全部 DSH_* 环境变量(scrubbedParentEnv),其余照常继承 → 将来接真实通道时密钥必须写进本 row 的 config.env,不能赌继承".into(),
single_writer_note().into(),
"可选加固:config.failOnStartupError: true(默认 false = 闸起不来插件仍激活但零工具,闸位形同虚设;置 true 则 dsh 拒绝激活,与闸 fail-closed 同构)".into(),
"dsh 0.1.0-rc.7 = developer preview,官方明示会有破坏性变更——升级后本配置可能要跟着改".into(),
"本机 dsh 0.1.0-rc.7 已实测:--dump-config --patch 接受本格式(W-44,隔离 DSH_HOME,零网络零会话);会话级端到端待所有者放行(dsh 会话 = 模型会话 + 网络,红线 2)".into(),
first_run_note_line(),
],
content,
}
}
fn openclaw(resolved: &Resolved) -> Artifact {
let payload = serde_json::to_string(&json!({
"command": slash(&resolved.mcp_bin),
"args": ["--wal", slash(&resolved.wal), "--budget", budget_arg()]
}))
.expect("静态 JSON 序列化");
Artifact {
notes: vec![
"Wanning 支付闸 — OpenClaw MCP 配置(W-45 生成;字段依据本机 2026.5.22 隔离实测 + docs.openclaw.ai/mcp 直核)".into(),
"执行下面这条命令即完成写入(openclaw.json 由宿主管理,openclaw mcp set 只动 mcp.servers.wanning 一段,绝不整文件覆盖)".into(),
format!(
"配置落点:openclaw.json 的 mcp.servers.wanning = {{command, args}}。闸 {},审计账本 {}",
slash(&resolved.mcp_bin),
slash(&resolved.wal)
),
"OpenClaw 2026.5.22 原生支持 MCP(mcp list/show/set/unset 子命令族);W-45 隔离 env(OPENCLAW_STATE_DIR/OPENCLAW_CONFIG_PATH)实测 set/list/show 全绿".into(),
"stdio 字段面(官方文档直核):command/args/env/cwd;env 有安全过滤,拦 NODE_OPTIONS/PYTHONSTARTUP/DYLD_*/LD_* 等键 → 将来接真实通道时密钥必须写进 env,不能赌继承".into(),
"诚实边界:W-45 实测到配置面 + models.providers 挂本地 mock 模型为止;工具现身与判定落 WAL 属 agent 回合,需 gateway/模型会话(烧额度,红线 2,所有者放行)".into(),
single_writer_note().into(),
first_run_note_line(),
],
content: format!("openclaw mcp set wanning '{payload}'\n"),
}
}
fn hermes(resolved: &Resolved) -> Artifact {
let content = format!(
"hermes mcp add wanning --command {bin} --args --wal {wal} --budget {budget}\n",
bin = slash(&resolved.mcp_bin),
wal = slash(&resolved.wal),
budget = budget_arg(),
);
Artifact {
notes: vec![
"Wanning 支付闸 — Hermes Agent MCP 配置(W-45 生成;字段依据本机 hermes v0.19.1 隔离实测 + 包内 cli-config.yaml.example 直核)".into(),
format!(
"执行下面这条命令即完成挂载(mcp add 是 discovery-first:真连一次发现工具,挂载即验证)。配置落点:$HERMES_HOME/config.yaml 的 mcp_servers 段。闸 {},审计账本 {}",
slash(&resolved.mcp_bin),
slash(&resolved.wal)
),
"终端里跑会问 Enable all 2 tools? [Y/n] 回车即全开;脚本/CI 无 TTY 场景用 echo y | 管道喂确认(W-45 实测)".into(),
"落盘形态(实测原文):mcp_servers.wanning = {command: <bin>, args: [--wal, <wal>, --budget, '1000'], enabled: true};管理用 hermes mcp list / mcp test wanning / mcp remove wanning".into(),
"工具现身名: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(),
"one-shot 会话要显式带 toolset:hermes -z \"…\" -t wanning(默认 cli 工具集不含 MCP 工具,W-45 实测)".into(),
"W-45 已实测(真 hermes 二进制 + 本地 mock LLM,零外网零真实消费):allow 400 分落 WAL;二次会话同 nonce → replay 拒,完整性链连续;真实模型会话待所有者放行烧额度(红线 2)".into(),
single_writer_note().into(),
first_run_note_line(),
],
content,
}
}
const USAGE: &str = "wanning-init:给编码工具吐 Wanning MCP 配置(零网络、零真实消费)
用法: wanning-init --platform <名> [--bin <wanning-mcp 路径>] [--wal <审计账本路径>] [--out <文件>]
wanning-init --platform <名> --install [--dry-run] [--yes] [--host-bin <路径>]
--platform <名> 目标平台(必填):claude-code / codex / kimi / trae / workbuddy /
deepseek-harness / openclaw / hermes
--bin <路径> wanning-mcp 可执行文件;缺省从 PATH 解析(找不到 = 拒,给安装指引)
--wal <路径> 审计 WAL 路径;缺省 = 产品默认 ~/.wanning/wal.jsonl(Windows %USERPROFILE%\\.wanning)
--out <文件> 落盘路径;缺省只打印 stdout。已存在的文件**绝不覆盖**(动别人工具的配置 = 危险动作)
--install 直写安装:把配置写进宿主工具的正确位置(四 mcp.json 只 merge
mcpServers.wanning、dsh 合并追加 cordis.patch.yml、openclaw/hermes
执行宿主 CLI)。写前读现物 → 先备份 <file>.wanning.bak → 升级打
diff;只动 wanning 自己的条目,他人条目语义不动
--dry-run 与 --install 同用:打印将做的全部动作,零落盘(连目录都不建)
--yes 与 --install 同用:openclaw/hermes 显式允许 wanning 代执行宿主 CLI
(缺省只打印命令行)
--host-bin <路径> 宿主 CLI 可执行文件显式路径(缺省按 PATH 解析;找不到 = 拒)
-h / --help 打印本说明后退出
";
enum CliError {
Usage(String),
Failed(String),
}
pub fn run_cli(program: &str, args: &[String]) -> ExitCode {
match cli_run(program, args) {
Ok(()) => ExitCode::SUCCESS,
Err(CliError::Usage(message)) => {
eprintln!("{program}: {message}");
ExitCode::from(2)
}
Err(CliError::Failed(message)) => {
eprintln!("{program}: {message}");
ExitCode::FAILURE
}
}
}
fn cli_run(program: &str, args: &[String]) -> Result<(), CliError> {
let mut platform: Option<String> = None;
let mut mcp_bin: Option<PathBuf> = None;
let mut wal: Option<PathBuf> = None;
let mut out: Option<PathBuf> = None;
let mut install = false;
let mut dry_run = false;
let mut yes = false;
let mut host_bin: Option<PathBuf> = None;
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"-h" | "--help" => {
print!("{USAGE}");
return Ok(());
}
"--platform" => platform = Some(next_value(args, &mut index, "--platform")?),
"--bin" => mcp_bin = Some(next_path(args, &mut index, "--bin")?),
"--wal" => wal = Some(next_path(args, &mut index, "--wal")?),
"--out" => out = Some(next_path(args, &mut index, "--out")?),
"--install" => install = true,
"--dry-run" => dry_run = true,
"--yes" => yes = true,
"--host-bin" => host_bin = Some(next_path(args, &mut index, "--host-bin")?),
other => {
return Err(CliError::Usage(format!(
"未知参数: {other}(用 --help 看用法)"
)))
}
}
index += 1;
}
if install && out.is_some() {
return Err(CliError::Usage(
"--install 与 --out 冲突:--install 直写宿主配置的固定位置,--out 是生成到指定文件;\
二选一(用 --help 看用法)"
.to_string(),
));
}
if !install && yes {
return Err(CliError::Usage(
"--yes 只在 --install 下有意义(openclaw/hermes 代执行宿主 CLI 的显式确认);\
单独给出 = 用法错"
.to_string(),
));
}
if !install && dry_run {
return Err(CliError::Usage(
"--dry-run 只在 --install 下有意义(打印将做的安装动作,零落盘);单独给出 = 用法错"
.to_string(),
));
}
if !install && host_bin.is_some() {
return Err(CliError::Usage(
"--host-bin 只在 --install 下有意义(宿主 CLI 解析);单独给出 = 用法错".to_string(),
));
}
let Some(platform_input) = platform else {
return Err(CliError::Usage(format!(
"缺少 --platform <名>(支持矩阵:claude-code / codex / kimi / trae / workbuddy / \
deepseek-harness / openclaw / hermes;--help 看用法;{program} 是 Wanning 的配置生成器)"
)));
};
let platform = parse_platform(&platform_input).map_err(|e| CliError::Usage(e.message()))?;
let resolved =
resolve(&GenerateOptions { mcp_bin, wal }).map_err(|e| CliError::Failed(e.message()))?;
if install {
return cli_install(platform, &platform_input, &resolved, dry_run, yes, host_bin);
}
let artifact = generate_with(platform, &resolved).map_err(|e| CliError::Failed(e.message()))?;
println!("# Wanning 支付闸 — 配置生成完成");
for note in &artifact.notes {
println!("# {note}");
}
for note in first_run_notes() {
println!("# {note}");
}
println!();
match out {
Some(out) => {
let content = artifact.content;
std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&out)
.and_then(|mut file| std::io::Write::write_all(&mut file, content.as_bytes()))
.map_err(|e| {
CliError::Failed(if e.kind() == std::io::ErrorKind::AlreadyExists {
format!(
"拒绝覆盖:{} 已存在。动别人工具的配置 = 危险动作;请先人工确认,\
换个文件名,或把已有内容备份后删掉再生成",
out.display()
)
} else {
format!("写 {} 失败: {e}", out.display())
})
})?;
println!("已写入:{}(绝不覆盖已存在文件)", out.display());
}
None => print!("{content}", content = artifact.content),
}
Ok(())
}
fn cli_install(
platform: Platform,
platform_input: &str,
resolved: &Resolved,
dry_run: bool,
yes: bool,
host_bin: Option<PathBuf>,
) -> Result<(), CliError> {
let cwd =
std::env::current_dir().map_err(|e| CliError::Failed(format!("解析当前目录失败: {e}")))?;
let dsh_home = std::env::var_os("DSH_HOME").map(PathBuf::from);
let openclaw_state_dir = std::env::var_os("OPENCLAW_STATE_DIR").map(PathBuf::from);
let hermes_home = std::env::var_os("HERMES_HOME").map(PathBuf::from);
let kimi_code_home = std::env::var_os("KIMI_CODE_HOME").map(PathBuf::from);
let codex_home = std::env::var_os("CODEX_HOME").map(PathBuf::from);
let path_env = std::env::var_os("PATH");
let env = install::InstallEnv {
cwd: &cwd,
home: None,
dsh_home: dsh_home.as_deref(),
openclaw_state_dir: openclaw_state_dir.as_deref(),
hermes_home: hermes_home.as_deref(),
kimi_code_home: kimi_code_home.as_deref(),
codex_home: codex_home.as_deref(),
path_env: path_env.as_deref(),
};
let options = install::InstallOptions {
platform,
resolved,
env: &env,
dry_run,
yes,
host_bin: host_bin.as_deref(),
};
let report = install::install(&options).map_err(|e| CliError::Failed(e.message()))?;
let artifact = generate_with(platform, resolved).map_err(|e| CliError::Failed(e.message()))?;
println!("# Wanning 支付闸 — 配置生成完成");
for note in &artifact.notes {
println!("# {note}");
}
for note in first_run_notes() {
println!("# {note}");
}
println!();
println!("安装报告:");
println!(" 状态:{}", report.state.label());
if let Some(target) = &report.target {
println!(" 落点:{}", target.display());
}
if let Some(backup) = &report.backup {
println!(" 备份:{}(写前原文件字节)", backup.display());
}
if !report.diff.is_empty() {
println!(" 改动:");
for line in &report.diff {
println!(" {line}");
}
}
for action in &report.actions {
println!(" · {action}");
}
if let Some(printed) = &report.printed {
print!("{printed}");
}
println!(
" 下一步:wanning doctor --platform {platform_input} 验证挂载(真握手 + 账本可写 + 缺项清单)"
);
Ok(())
}
fn next_value(args: &[String], index: &mut usize, flag: &str) -> Result<String, CliError> {
*index += 1;
args.get(*index)
.cloned()
.ok_or_else(|| CliError::Usage(format!("{flag} 缺少取值(用 --help 看用法)")))
}
fn next_path(args: &[String], index: &mut usize, flag: &str) -> Result<PathBuf, CliError> {
Ok(PathBuf::from(next_value(args, index, flag)?))
}