mcpField
C
cli-commands

cli-commands

github.com/gitbutlerapp开发21.9k39 次安装今天更新
代码仓库

关于 cli-commands

这项贡献者技能介绍如何为 Rust 命令行工具 `but` 实现命令,涵盖参数定义、命令分发、校验、执行与输出约定,帮助保持实现结构一致。

主要功能

  1. 01命令行参数结构
  2. 02命令分发模式
  3. 03分离解析与执行
  4. 04输入校验与错误处理
  5. 05输出格式指引

适用场景

  1. 01为 `but` 添加命令
  2. 02为 Git 对象定义类型化参数
  3. 03将输入校验与执行分开
  4. 04保持命令输出一致

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/gitbutlerapp/gitbutler --skill cli-commands

安装后在 Claude Code 中输入/cli-commands确认已加载。

详细介绍

假设我们要实现一个新的 commit3 命令,其总体结构如下。

参数

参数位于 crates/but/src/args 中。对于我们的提交命令,相应文件是 crates/but/src/args/commit3.rs:

use crate::args::atoms::CliIdArg;

/// Create a commit.
///
/// More details about the command here...
#[derive(Debug, clap::Parser)]
#[cfg_attr(feature = "raw-clap-docs", clap(verbatim_doc_comment))]
#[deny(missing_docs)]
pub struct Platform {
    /// The message to use for the commit.
    #[clap(short, long, group = "commit_message")]
    pub message: Option<Vec<String>>,

    /// Place the commit on the branch `BRANCH`.
    #[clap(short, long, value_name = "BRANCH", group = "targeting")]
    pub branch: Option<Option<CliIdArg>>,

    /// One or more changes to commit.
    pub changes: Vec<CliIdArg>,
}

在 crates/but/src/args/mod.rs 中:

#[cfg(feature = "legacy")]
#[cfg_attr(feature = "raw-clap-docs", clap(verbatim_doc_comment))]
#[clap(hide = true, name = "_commit3")]
Commit3(commit3::Platform),

注意事项:

引用 Git 对象(如提交、分支、文件、代码块等)的参数,应使用 crates/but/src/args/atoms/ 中的某种类型,而不是 String 或其他宽泛类型。

对于引用现有 Git 对象(如分支、提交、文件等)的参数,请使用 CliIdArg。这样用户就可以使用短 ID 或完全限定名称。

String 只应用于真正宽泛的文本输入,例如提交消息。

Platform 及其所有字段都必须有文档。Subcommands 变体有意不添加文档注释,因为 clap 会从 Platform 获取命令文档。

使用 #[clap(group = "...")] 创建互斥的参数组。

语法复杂的命令可以在 Platform 旁定义一个 pub(crate) const ERROR_EXAMPLES,并在 args::error_examples 中注册;该内容会附加在 clap 解析错误之后。最多 4 行,每行是一个可直接运行的 but <cmd> ... # what it does 调用(例如,如果省略 -m 会打开编辑器,则应包含 -m)。

处理命令

在 crates/but/src/lib.rs 中添加一个匹配分支来处理该命令:

match cmd {
    Subcommands::Commit3(commit_args) => {
        use crate::utils::IntermediateChannel;

        let status_after = args.status_after;
        let mut ctx = setup::init_ctx(
            &args,
            InitCtxOptions {
                background_sync: BackgroundSync::Enabled { silent: false },
                ..Default::default()
            },
            out,
        )?;
        out.begin_status_after(status_after);

        let outcome = command::legacy::commit3::commit(
            &mut ctx,
            IntermediateChannel::new(out),
            commit_args,
        )
        .emit_metrics(metrics_ctx)?;
        out.print_cli_output(outcome)?;

        run_status_after_if_requested(status_after, &mut ctx, out);

        Ok(())
    }

    // all the other commands...
}

注意事项:

使用 IntermediateChannel。不要将 OutputChannel 传递给命令。

使用 OutputChannel::print_cli_output 打印命令的最终输出。这样可确保我们处理所有受支持的格式。如果只支持人类可读格式,请使用 OutputChannel::print_cli_output_human。

实现命令

位于 crates/but/src/command/legacy/commit3.rs

pub fn commit(
    ctx: &mut Context,
    out: IntermediateChannel<'_>,
    args: Platform,
) -> CliResult<CommitOutcome> {
    // get whatever dependencies we need from `Context` such as
    // `RepoExclusiveGuard`, `IdMap`, `RefInfo`, etc.

    // resolve the arguments into a `CommitOperation`
    let commit_operation = resolve(ctx, args)?;

    // Run the operation
    let outcome = run(ctx, commit_operation)?;

    // Return the outcome which will be printed by the caller
    Ok(outcome)
}

fn resolve(ctx: &mut Context, args: Platform) -> CliResult<CommitOperation> {
    let Platform { message, branch, changes } = args;
    // ...
}

fn run(ctx: &mut Context, commit_op: CommitOperation) -> anyhow::Result<CommitOutcome> {
    match commit_op {
        // ...
    }
}

#[must_use]
struct CommitOutcome {
    new_commit: ObjectId,
}

impl CliOutputHuman for CommitOutcome {
    fn on_human(self, out: &mut dyn WriteWithUtils, _theme: &Theme) -> anyhow::Result<()> {
        let Self { new_commit } = self;

        writeln!(
            out,
            "Created commit {}",
            theme::Commit(new_commit, None),
        )?;

        Ok(())
    }
}

impl CliOutput for CommitOutcome {
    fn on_shell(self, out: &mut dyn WriteWithUtils) -> anyhow::Result<()> {
        let Self { new_commit } = self;

        writeln!(out, "{}", new_commit.to_hex_with_len(7))?;

        Ok(())
    }

    fn on_json(self) -> impl serde::Serialize {
        #[derive(Serialize)]
        struct Output {
            commit: HexHash,
        }

        let Self { new_commit } = self;

        Output { commit: new_commit.into() }
    }
}

注意事项:

命令采用先 resolve、再 run 的结构。

run 不会打印最终输出。它会返回实现了 CliOutput / CliOutputHuman 的内容,之后调用方可以打印这些内容。

resolve 返回 CliResult,因为它需要拒绝无效的用户输入。

run 返回 anyhow::Result,因为它只会遇到内部错误。无效的用户输入由 resolve 处理。

resolve 会将 CLI 参数转换为领域目标,并验证用户输入。它可以查询仓库状态来消除歧义或拒绝输入,但不应在操作中保留派生数据。

run() 会加载当前仓库状态并计算结果。提交数量、差异、统计信息、分支详情、当前 tip 和工作区投影通常都应在 run() 中处理。可根据这些标识符和上下文推导出的状态也应在 run() 中处理。

操作中不应包含 crate::args::atoms 中的类型。

非 CLI 调用方(例如 TUI)应该能够根据领域标识符构建操作,而无需复现 resolve() 中的仓库查询。如果构建操作时必须计算数量、差异或分支元数据,这些字段很可能应该在 run() 中处理。

操作不包含差异规格(通常以 Vec<DiffSpec> 的形式表示)。它应改为包含 CliId,由 run 使用 DiffSpecBuilder 将其转换为 Vec<DiffSpec>。这样状态 TUI 就能更轻松地直接调用 run。

用户输入选择器和提示词通过 InputOutputChannel 创建,该通道通过 IntermediateChannel::prepare_for_terminal_input 访问。

JSON 输出同时包含提交 ID 和变更 ID。

使用 AllowMergedArg 和 MergedUpstream 验证我们不会修改已合并的提交和分支。

打印提交、分支、变更 ID 等内容时,使用 theme 中的 newtype,例如 theme::Branch 和 theme::Commit。这样可确保颜色一致。

无效用户输入错误使用 bad_input(...),并可选择添加 .arg_name()、.arg_value() 和 .hint()。

避免让操作或结果类型实现 serde::Serialize。在 fn on_json 中定义所需的特定类型。这样可以降低意外破坏兼容性的风险。复用现有领域类型中的 Serialize 实现没有问题。

更多示例

如需查看这种结构的实际示例,请参阅

  • crates/but/src/command/legacy/commit.rs
  • crates/but/src/command/legacy/move.rs
  • crates/but/src/command/legacy/squash.rs
  • crates/but/src/command/legacy/diff.rs

评论

支持 Markdown