关于 cli-commands
这项贡献者技能介绍如何为 Rust 命令行工具 `but` 实现命令,涵盖参数定义、命令分发、校验、执行与输出约定,帮助保持实现结构一致。
主要功能
- 01命令行参数结构
- 02命令分发模式
- 03分离解析与执行
- 04输入校验与错误处理
- 05输出格式指引
适用场景
- 01为 `but` 添加命令
- 02为 Git 对象定义类型化参数
- 03将输入校验与执行分开
- 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.rscrates/but/src/command/legacy/move.rscrates/but/src/command/legacy/squash.rscrates/but/src/command/legacy/diff.rs