ziyuan-cli 1.9.0

紫垣 ZiYuan:用中国古代朝廷角色组织多智能体协作的命令行工具,改动须用户审阅批准才写入文件。
//! 帮助文本、子命令清单与 shell 补全脚本。

use std::env;
use ziyuan_core::{Capability, Department, GovernanceMode, Office};

pub(crate) const HELP: &str = concat!(
    "紫垣 ZiYuan ",
    env!("CARGO_PKG_VERSION"),
    r#" — 朝廷式多 Agent 框架(演示版)

用法:
  ziyuan init                          初始化朝廷(在当前目录建立 .ziyuan/)
  ziyuan version                       显示版本号(亦可 --version、-V)
  ziyuan court                         查看制度与朝廷状态
  ziyuan officials                     查看官员、官职、品级、所用模型及任务完成数
  ziyuan recruit [姓名] <部门>         招募新官(不给姓名则随机取名;须另行 appoint 任职)
  ziyuan rename <官员ID> [--name 姓名] [--courtesy 字] [--art 号]
                                       改名、改字或改号(如徐珵改名徐有贞);字号未改过的由官员 ID 而定
  ziyuan edict [--template <模板>] [--dept works] [--scope <路径>]... [--tools <工具>] [--mcp <授权>] [--queue] <内容>
                                       下诏并立即执行;--queue 只入队(默认模拟后端;见下方环境变量)
  ziyuan run [--jobs 4] [--timeout 300]
                                       并发执行全部未结束的诏令;Ctrl-C 撤销执行中的诏令
  ziyuan run --watch [--poll 5] [--no-censor]
                                       常朝:常驻执行,驿传与入队的诏令随到随办,办完自动请都察院复核;Ctrl-C 退朝
  ziyuan tasks                         查看诏令及其状态
  ziyuan serve [--port 8080] [--bind 127.0.0.1] [--new-token] [--viewer]
                                       开奉天门:Web 界面(默认仅本机;令牌存于 .ziyuan/web-token);
                                       --viewer 另发只读的观政令牌(.ziyuan/web-viewer-token)
  ziyuan tui                           启动紫禁城终端界面
  ziyuan migrate                       检查事件簿:本版能否读,至少要哪个版本才能打开
  ziyuan login [--models]              用 ChatGPT 订阅额度调用模型:浏览器登录;--models 列出可选模型
  ziyuan logout                        退出 ChatGPT 订阅登录(撤销令牌)
  ziyuan cancel <诏令ID>               撤销未结束的诏令
  ziyuan reform <chancellor|direct|cabinet>
  ziyuan retreat                       修道怠政(昏君模式):批红、评定由司礼监代批,记入事件簿
  ziyuan return                        回朝亲政
  ziyuan dismiss <官员ID>              罢免现任官职
  ziyuan purge <官员ID>                革职:削去仕籍,永不叙用
  ziyuan abolish                       一键朱元璋:罢中书省,株连百官(全部在任官员革职削籍)
  ziyuan model <官职> <模型|default>   为官职指定承办模型(同一服务地址与 Key)
  ziyuan ceiling <官职> [--scope <路径>]... [--tools <工具>] [--mcp <授权>] | default
                                       设定官职的玉玺上限:该职承办时,诏令玉玺与上限取交集
  ziyuan split <诏令ID> [官员ID:子诏令]...
  ziyuan split <诏令ID> --plan <JSON文件>  创建排程;依赖为数组内从 1 起的索引,重试须同案
                                       多官协作:尚书(模型)把待办诏令拆给本部官员分办,或皇帝御定;
                                       子诏令并发执行(写子诏令范围重叠自动串行,上游失败则下游受阻),
                                       全部结束后尚书汇总回奏
  ziyuan debate <诏令ID> [--rounds 2] [官员ID]...
                                       廷议:数位在任官员(默认宰辅、本部长官、御史)按各自性格文风
                                       就待办诏令轮流辩论;纪要供票拟与承办参考,不改变派工与玉玺
  ziyuan draft <诏令ID|all>            内阁票拟:首辅(模型)拟定承办人并附意见
  ziyuan redraft <诏令ID> <官员ID> [理由] 御笔改票:改定拟派的承办人
  ziyuan approve <诏令ID|all>          内阁制下批红(批红后方可执行;未票拟即批红为中旨,按旧例派工)
  ziyuan appoint <官员ID> <官职>       任命(如 first-secretary / minister:works)
  ziyuan diff <诏令ID> [--side-by-side] [--plain]
                                       查看工部呈报的改动(统一 diff);在终端且找到 git-delta
                                       (ZIYUAN_DELTA 或 PATH 上实测可用的 delta)时经它显示,
                                       --side-by-side 左右并排,--plain 不经 delta
  ziyuan apply <诏令ID> [--skip 1,3] [--commit]
                                       准奏:把呈报的改动写入项目(文件在此期间被改过则拒绝);
                                       --skip 逐段驳回 diff 中所列段号,其余落盘;
                                       --commit 落盘后只把这些文件记为一个 jj(或 git)提交
  ziyuan discard <诏令ID>              驳回呈报的改动,不写入
  ziyuan review <诏令ID> <accept|reject> [备注]
                                       评定已完成的诏令(吏部考绩只采信评定)
  ziyuan censor <诏令ID|all>           都察院复核回奏:核验引用与测试,御史背书或弹劾
  ziyuan budget [court|<官职>|<诏令ID> <tokens|default>]
                                       户部度支:设朝廷总额、官职每件或某件诏令的 token 上限,用尽即停支(default 取消);
                                       不带参数则出具度支奏销(按用途、模型、官员汇总花费)
  ziyuan post [--dept works] [--station 驿站] [--urgent] <内容|->
                                       兵部驿传:外部事件(CI 失败、新 issue)入队为诏令,`-` 读标准输入;
                                       默认玉玺(只读),仍须批红、受度支约束;只入队,不执行
  ziyuan urgent <诏令ID>               八百里加急:插队先行,时限加倍
  ziyuan template                      翰林院:列出诏令模板(内置「审查unsafe」「补测试」「写文档」)
  ziyuan template save <名> [--dept works] [--scope <路径>]... [--tools <工具,…>|none] --criteria <甲;乙> <内容>
                                       存模板(同名覆盖,可覆盖内置的);验收标准以「;」分条
  ziyuan template rm <名>              撤去自定的模板(内置的恢复原样)
  ziyuan edict --template <名> [补充内容]  按模板下诏;--dept/--scope/--tools 可覆盖模板的默认值
  ziyuan exam <官员ID>                 礼部科举:官员应试 .ziyuan/exam/ 下的考题,按要点判分,记入履历
  ziyuan exam --palace <题目>          殿试:天子亲策,在任官员各答一卷
  ziyuan crown <官员ID>                钦点状元(须为最近一次殿试的答卷者)
  ziyuan nominate <官职> [--lots]      廷推:官缺时吏部会同九卿各推一人(只是建议);--lots 枚卜,从被推者中抽签任命
  ziyuan cane <官员ID>                 廷杖:屡遭弹劾(两次以上)者降一级留用,收回 write_file、run_tests,三件获采纳即复
  ziyuan pardon <官员ID>               宽宥受杖者,复其品级与玉玺
  ziyuan flags [诏令ID]                刑部与锦衣卫的密报:外来文书中的劫持指令、回奏中的越权与索取机密
  ziyuan mcp                           外藩名册:.ziyuan/mcp.json 登记的 MCP 服务及其工具
  ziyuan changelog [--version 0.5.0] [--since v0.4.0]
                                       礼部修 CHANGELOG:据提交与实录起草,只可写 CHANGELOG.md,须准奏落盘(只入队)
  ziyuan github pull [--label ziyuan] [--dept works] [--tools <工具,…>] [--scope <路径>]...
                                       兵部驿站·GitHub:带标签的 issue/PR 及成员的 `/ziyuan` 评论入队(去重);
                                       默认只读,--tools 可授工部写权(如 read_file,write_file),之后方能 `github pr`
  ziyuan github reply <诏令ID|all>     已评定、无未平反密报的回奏写回 issue/PR 评论
  ziyuan github pr <诏令ID>            准奏的改动在远端开为草稿 PR(先核对远端原文;不碰本地版本库)
  ziyuan recheck <诏令ID|all>          刑部复勘:刑部官员(模型)逐处判断检出是真有其事还是误报,只是意见
  ziyuan acquit <诏令ID> [序号,…|all]  平反:不给序号则平反刑部判为误报的各处;平反后不再妨碍代批
  ziyuan kaocheng                      考成簿:在任官员的功过与等第(仿张居正考成法)
  ziyuan examine <官员ID>              吏部考绩:经评定与复核的履历
  ziyuan recommend <官职>              吏部荐举:按履历排列可任该职者(不自动任命)
  ziyuan history [--task ID] [--official ID] [--kind 类型] [--since 时间] [--until 时间]
                                       查看起居注(事件记录);时间如 30m、2h、3d、2026-10-25、2026-10-25 14:30,
                                       按 ZIYUAN_TZ(如 +08:00,默认 UTC)
  ziyuan records [检索词]              翰林院实录:已结诏令的诏令、回奏与评定(只增不改);给检索词则查前例
  ziyuan text init --title <标题> --language <ja|en> --source <来源> --rights <权利声明> [--author …] [--version …] [--date …] [--scope …]
  ziyuan text import <项目ID> <章节.txt>...
  ziyuan text show <项目ID>
  ziyuan text check <项目ID> --candidate <译稿.json> [--block <提示块ID>]
                                       离线文本项目:导入 TXT、按段分块、检查候选译稿结构(不需 init,不调用模型;
                                       只查结构,不评判译文质量);`ziyuan text help` 查看完整说明
  ziyuan text translate|review|revise|zhupi|finalize|reopen <项目ID> <块ID> ...
  ziyuan text context approve <项目ID> | status <项目ID> | resume <项目ID> [--yes]
                                       翰林院译书:下诏入队、逐段朱批、上下文批准、御览与续做;text help 查看参数
  ziyuan text memory status|build|search <项目ID> ...
                                       本机翻译记忆:只收有效定稿,跨项目须明确指定;text help 查看参数
  ziyuan text human show|save|submit|approve|finalize|apply <项目ID> <块ID> ...
                                       人工译稿:保存草稿、送审、准奏(并落盘)、定稿;text help 查看参数
  ziyuan note add|list|show|edit|remove|promote ...
                                       御笔札记(不需 init);promote 仅并入草稿,须 text context approve 才生效;
                                       `ziyuan note help` 查看参数
  ziyuan follow add <作品链接|作品代码>
  ziyuan follow list | remove <作品链接|作品代码> | check | updates [--all] | read <提醒ID> | queue [on|off]
                                       作品关注:关注 syosetu 作品,手动检查章节更新并列出提醒(不需 init,
                                       不调用模型,只查作品信息不下载正文;不会在后台自动检查);
                                       `ziyuan follow help` 查看完整说明
  ziyuan <court|officials|tasks|history|records|migrate|flags|mcp|text|follow|note> --json
                                       以 JSON 输出(court 与 Web 的 /api/state 同构),便于脚本使用
  ziyuan completions <bash|zsh|fish>   输出 shell 补全脚本(如 `ziyuan completions bash > ~/.local/share/bash-completion/completions/ziyuan`)
  ziyuan help

官制(品级随制度而变):
  丞相制(仿元·中书省)  chancellor 中书右丞相、vice-chancellor 中书左丞相
  废相亲政(仿明初)    不设宰辅,皇帝直领六部
  内阁制(仿明中后期)  first-secretary 内阁首辅、second-secretary 内阁次辅
  六部(各制度通用)    minister:<部门> 尚书、vice-minister:<部门> 侍郎、clerk:<部门> 郎中
  都察院(各制度通用)  censor 左都御史(元称御史台御史大夫),不入派工路线
  正职空缺时由副职署理。
部门:personnel / revenue / rites / war / justice / works

玉玺(承办官员的权限,随诏令记入事件簿):
  --scope <路径>   可查阅的项目路径(目录或文件,可重复);默认整个项目
  --tools <工具>   read_file、search_code、write_file、run_tests 的逗号列表,或 none;
                   默认 read_file,search_code(只读)
  --mcp <授权>     外藩 MCP 工具(.ziyuan/mcp.json 登记的服务):服务、服务/工具 或 * 的逗号列表;
                   默认不授。外部工具不受 --scope 约束,只授信得过的服务
  write_file、run_tests 只能授予工部:改动只暂存,诏令完成后呈报,须 `ziyuan apply` 准奏才落盘;
  run_tests 在项目的临时副本(含暂存改动)中运行 ZIYUAN_TEST_COMMAND,不传 API Key 等环境变量。
  无论玉玺如何,.ziyuan、.git、.jj 与 .env* 均不可读写。

真实模型(OpenAI 兼容 Chat Completions API):
  ZIYUAN_LLM_MODEL     模型名;设置后 edict 由模型以只读工具查阅当前目录
  ZIYUAN_LLM_BASE_URL  默认 https://api.openai.com/v1(如 https://api.deepseek.com/v1、http://localhost:11434/v1)
  ZIYUAN_LLM_API_KEY   API Key(本地 Ollama 可不设)
  ZIYUAN_TEST_COMMAND  run_tests 运行的测试命令(如 cargo test;由皇帝设定,模型不能更改)
  ZIYUAN_SANDBOX       号舍(run_tests 的沙箱):auto(默认)与 require 有则用、没有则不跑;off 不入号舍
  ZIYUAN_SANDBOX_ALLOW 号舍内可访问的域名,逗号分隔(如 crates.io,*.github.com);未设则全断网
  ZIYUAN_LLM_PRICE     估算花费用的单价「输入,输出[,币种]」(每百万 token);
                       deepseek-flash / deepseek-v4-pro 内置官方高峰价(估算上限)
  ZIYUAN_LLM_RETRIES   网络错误、429、5xx 时的重试次数(默认 3,指数退避,遵守 Retry-After)
  ZIYUAN_MAX_STEPS     单件诏令最多的模型调用步数(默认 12);用尽后官员不带工具据已查阅的内容回奏
  ZIYUAN_CONTEXT_TOKENS 旧工具输出的节略阈值(正整数,默认 48000);不是模型窗口或度支上限
  ZIYUAN_LLM_PROVIDER  设为 chatgpt 时改用 ChatGPT 订阅(先 `ziyuan login`),走 Responses API
  ZIYUAN_TZ            起居注显示与筛选时间所用的时区,如 +08:00(默认 UTC)
  ZIYUAN_CONFIG_DIR    用户配置目录(存 ChatGPT 订阅凭据;默认 $XDG_CONFIG_HOME/ziyuan 或 ~/.config/ziyuan)
  ZIYUAN_GITHUB_REPO   兵部驿站·GitHub 的仓库(owner/repo);与 ZIYUAN_GITHUB_TOKEN 一同设置后可用 github 子命令
  ZIYUAN_GITHUB_TOKEN  GitHub 令牌(只在 ziyuan-github 中使用,不交给模型)

示例:
  ziyuan init
  ziyuan edict --dept works "检查 Rust 项目的 unsafe 代码"
  ziyuan reform direct
  ziyuan edict --dept works "重新检查"
  ziyuan reform cabinet
  ziyuan dismiss 6
  ziyuan appoint 6 first-secretary
  ziyuan edict --dept works "请内阁协调检查"
  ziyuan edict --queue --scope crates/ziyuan-storage --tools search_code "检查 storage crate"
  ziyuan edict --queue "检查 runtime crate"
  ziyuan run --jobs 2 --timeout 120
"#
);

#[derive(Clone, Copy)]
pub(crate) enum Shell {
    Bash,
    Zsh,
    Fish,
}

/// 全部子命令(补全用;测试保证每个都能被 `parse` 识别)。
pub(crate) const SUBCOMMANDS: &[&str] = &[
    "help",
    "version",
    "init",
    "court",
    "officials",
    "history",
    "records",
    "recruit",
    "rename",
    "edict",
    "template",
    "run",
    "tasks",
    "tui",
    "migrate",
    "login",
    "logout",
    "serve",
    "cancel",
    "retreat",
    "return",
    "reform",
    "dismiss",
    "purge",
    "abolish",
    "model",
    "ceiling",
    "draft",
    "redraft",
    "approve",
    "appoint",
    "examine",
    "diff",
    "kaocheng",
    "post",
    "urgent",
    "exam",
    "crown",
    "nominate",
    "cane",
    "pardon",
    "flags",
    "recheck",
    "acquit",
    "github",
    "changelog",
    "mcp",
    "budget",
    "split",
    "debate",
    "censor",
    "apply",
    "discard",
    "review",
    "recommend",
    "completions",
    "text",
    "follow",
    "note",
];

/// 支持 `--json` 的子命令。
pub(crate) const JSON_COMMANDS: &[&str] = &[
    "court",
    "officials",
    "tasks",
    "history",
    "records",
    "migrate",
    "flags",
    "mcp",
    "text",
    "follow",
    "note",
];

/// shell 补全脚本:首词补子命令;官职、部门、制度、工具等参数按所在子命令补全。
pub(crate) fn completions(shell: Shell) -> String {
    let words = |items: Vec<String>| items.join(" ");
    let offices = words(Office::all().into_iter().map(|o| o.code()).collect());
    let departments = words(
        Department::ALL
            .iter()
            .map(|d| d.code().to_owned())
            .collect(),
    );
    let modes = words(
        GovernanceMode::ALL
            .iter()
            .map(|m| m.code().to_owned())
            .collect(),
    );
    let tools = words(
        Capability::ALL
            .iter()
            .map(|c| c.code().to_owned())
            .collect(),
    );
    let commands = SUBCOMMANDS.join(" ");
    let json = JSON_COMMANDS.join(" ");
    let bash = format!(
        r#"_ziyuan() {{
  # 官职代码含冒号,而 bash 默认在冒号处分词:从 COMP_LINE 取完整的当前词。
  local line="${{COMP_LINE:0:$COMP_POINT}}" words=""
  local cur="${{line##*[[:space:]]}}" rest="${{line%[[:space:]]*}}"
  local prev="${{rest##*[[:space:]]}}"
  if [ "$COMP_CWORD" -eq 1 ]; then
    COMPREPLY=($(compgen -W "{commands}" -- "$cur")); return
  fi
  case "$prev" in
    --dept) words="{departments}" ;;
    --kind) words="word passage" ;;
    --tools) words="{tools} none" ;;
    --plan) COMPREPLY=($(compgen -f -- "$cur")); return ;;
    *) case "${{COMP_WORDS[1]}}" in
         appoint|model|ceiling|nominate|recommend) words="{offices}" ;;
         recruit) words="{departments}" ;;
         reform) words="{modes}" ;;
         completions) words="bash zsh fish" ;;
         split) words="--plan" ;;
         text) words="init import show check recovery human save submit apply translate review revise zhupi finalize reopen context approve status resume memory build search help --project --query --limit --dept --reason --clear --show --yes --json" ;;
         note) words="add list show edit remove promote help --source --zh --kind --remark --project --block --para --search --json" ;;
         follow) words="add list remove check updates read queue on off help --all --json" ;;
         apply) words="--skip --commit" ;;
         {json_cases}) words="--json" ;;
       esac ;;
  esac
  COMPREPLY=($(compgen -W "$words" -- "$cur"))
  # bash 在冒号处分词,冒号前的部分已视为另一个词,补全结果中去掉;zsh(bashcompinit)不分,原样给出。
  local colon="${{cur%"${{cur##*:}}"}}"
  [ -n "$colon" ] && [[ "$COMP_WORDBREAKS" == *:* ]] && COMPREPLY=("${{COMPREPLY[@]#"$colon"}}")
}}
complete -F _ziyuan ziyuan
"#,
        json_cases = JSON_COMMANDS.join("|")
    );
    match shell {
        Shell::Bash => bash,
        // zsh 借 bashcompinit 复用同一份脚本。
        Shell::Zsh => format!("autoload -U +X bashcompinit && bashcompinit\n{bash}"),
        Shell::Fish => {
            let seen =
                |cmds: &str| format!("complete -c ziyuan -n '__fish_seen_subcommand_from {cmds}'");
            [
                "complete -c ziyuan -f".to_owned(),
                format!("complete -c ziyuan -n __fish_use_subcommand -a '{commands}'"),
                format!(
                    "{} -a '{offices}'",
                    seen("appoint model ceiling nominate recommend")
                ),
                format!("{} -a '{departments}'", seen("recruit")),
                format!("{} -a '{modes}'", seen("reform")),
                format!("{} -a 'bash zsh fish'", seen("completions")),
                format!("{} -l plan -r", seen("split")),
                format!("{} -l skip -x", seen("apply")),
                format!("{} -l commit", seen("apply")),
                format!("{} -a 'init import show check recovery human save submit apply translate review revise zhupi finalize reopen context approve status resume memory build search help'", seen("text")),
                format!("{} -l project -r", seen("text")),
                format!("{} -l query -r", seen("text")),
                format!("{} -l limit -r", seen("text")),
                format!("{} -a 'add list show edit remove promote help'", seen("note")),
                format!("{} -l kind -x -a 'word passage'", seen("note")),
                format!("{} -l source -r", seen("note")),
                format!("{} -l zh -r", seen("note")),
                format!("{} -l remark -r", seen("note")),
                format!("{} -l project -r", seen("note")),
                format!("{} -l block -r", seen("note text")),
                format!("{} -l para -r", seen("note")),
                format!("{} -l search -r", seen("note")),
                format!("{} -l reason -r", seen("text")),
                format!("{} -l clear", seen("text")),
                format!("{} -l show", seen("text")),
                format!("{} -l yes", seen("text")),
                format!(
                    "{} -a 'add list remove check updates read queue on off help'",
                    seen("follow")
                ),
                format!("{} -l all", seen("follow")),
                format!("{} -l json", seen(&json)),
                format!(
                    "{} -l dept -x -a '{departments}'",
                    seen("edict post template text")
                ),
                format!(
                    "{} -l tools -x -a '{tools} none'",
                    seen("edict template ceiling github")
                ),
            ]
            .join("\n")
                + "\n"
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::args::parse;

    #[test]
    fn every_completed_subcommand_is_recognised() {
        for name in SUBCOMMANDS {
            if let Err(e) = parse(vec![name.to_string()]) {
                assert!(!e.to_string().contains("未知命令"), "{name}: {e}");
            }
        }
        let script = completions(Shell::Bash);
        assert!(script.contains("minister:works") && script.contains("write_file"));
    }
}