cli-commands について
Rust 製コマンドラインツール `but` のコマンド実装を支援する開発者向けスキルです。引数定義、コマンド振り分け、検証、実行、出力の規約を説明します。
主な機能
- 01コマンドライン引数の構成
- 02コマンド振り分けのパターン
- 03resolve と run の分離
- 04入力検証とエラー処理
- 05出力形式の指針
活用シーン
- 01`but` にコマンドを追加
- 02Git オブジェクトの型付き引数を定義
- 03入力検証と実行を分離
- 04コマンド出力を統一
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ gitbutlerapp/ gitbutler --skill cli-commandsインストール後、Claude Code で/cli-commandsを入力して読み込まれたか確認します。
詳細
新しい commit3 コマンドを実装するとします。その大まかな構成は次のとおりです。
引数
引数は crates/but/src/args にあります。commit コマンドの場合は
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 オブジェクト(コミット、ブランチ、ファイル、ハンクなど)を参照する引数には、String やその他の型が曖昧な値ではなく、crates/but/src/args/atoms/ にある型を使用します。
ブランチ、コミット、ファイルなど、既存の 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 を含めるなど)。
コマンドの処理
コマンドを処理するため、crates/but/src/lib.rs に match アームを追加します。
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() は現在のリポジトリの状態を読み込み、結果を計算します。コミット数、差分、統計情報、ブランチの詳細、現在の先端、ワークスペースの投影は、通常 run() に属します。それらの識別子とコンテキストから導出できる状態も run() に属します。
操作に crate::args::atoms の型を含めてはいけません。
TUI などの非 CLI 呼び出し元は、resolve() で行うリポジトリ照会を再現することなく、ドメイン識別子から操作を構築できる必要があります。操作を構築するためにコミット数、差分、ブランチのメタデータを計算する必要がある場合、それらのフィールドはおそらく run() に属します。
操作には差分指定(通常は Vec<DiffSpec> の形式)を含めません。代わりに CliId を含め、run で DiffSpecBuilder を使って Vec<DiffSpec> に変換します。これにより、ステータス TUI から run を直接呼び出しやすくなります。
ユーザー入力用の選択 UI やプロンプトは、IntermediateChannel::prepare_for_terminal_input を介してアクセスする InputOutputChannel を使って作成します。
JSON 出力には、コミット ID と変更 ID の両方を含めます。
マージ済みのコミットやブランチを変更しないことを確認するには、AllowMergedArg と MergedUpstream を使います。
コミット、ブランチ、変更 ID などの表示には、theme::Branch や theme::Commit など、theme の新しい型を使います。これにより、色付けを統一できます。
不正なユーザー入力のエラーには 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