Claude CodeでGoのcobra製CLIを作る — go testと--helpで完了を判定
Claude CodeにGoのcobra製CLIを作らせるときの構成・規約・テストの書き方と、go testとビルド後の--help出力をStop hookの完了条件にする手順をまとめます。
cobra製CLIの「完了」は、テストとビルド後の--helpの2点で決まる
cobraは、Go向けのCLIライブラリです。サブコマンド、フラグ、位置引数の検証、--helpの自動生成を、コマンドの定義だけで組み立てられます。
小さなCLIをClaude Codeに任せるとき、迷うのは「どこまで動けば終わりか」です。Goではgo buildが通るだけでは足りません。サブコマンドの登録を1つ忘れても、コンパイルは通るからです。
そこで完了条件を2つに分けます。
go testが通る(コマンドの振る舞いをテストで固定する)- ビルドした実行ファイルの
--helpに、期待するサブコマンドが並ぶ
後者は、cobraが「コマンドの定義から--helpを自動生成する」ことを使った確認です。登録漏れはここに現れます。この記事では、題材のCLIを作る構成から、Claudeに渡す規約、テストの書き方、Stop hookまでを順に示します。Go全般の進め方はClaude CodeでGoアプリを開発する手順にあります。
題材はmemo addとmemo listの2コマンド
題材は、メモをJSONファイルに溜めるmemoです。コマンドは2つだけにします。
| コマンド | 役割 | フラグ・引数 |
|---|---|---|
memo add <text> | 役割メモを1件追加する | フラグ・引数位置引数がちょうど1つ |
memo list | 役割保存済みのメモを表示する | フラグ・引数--jsonで配列として出力 |
| 共通 | 役割保存先を切り替える | フラグ・引数-f / --file(既定はmemo.json) |
最初に、プロジェクトの土台を作ります。コマンドは次の形です。
mkdir memo && cd memo
go mod init example.com/memo
go get github.com/spf13/cobra@latestディレクトリ構成は、cobraのユーザーガイドが示す形に合わせます。ガイドは、main.goを「cobraを初期化するだけの薄いファイル」とし、各コマンドをcmd/配下に1ファイルずつ置く構成を示しています。
memo/
├── main.go
├── go.mod
└── cmd/
├── root.go
├── add.go
├── list.go
├── store.go
└── root_test.goガイドの例は、rootCmdをパッケージ変数にしてinit()でサブコマンドを登録する書き方です。この記事では少し変えて、ルートコマンドを返す関数NewRootCmdを作ります。理由はテストにあります。パッケージ変数にバインドしたフラグは、同じプロセス内でExecuteを2回呼ぶと前回の値を引きずる可能性があります。呼ぶたびに新しいコマンドの木を作れば、テスト同士が干渉しません。
コマンドとフラグはこう書く
cmd/root.goは次のとおりです。ガイドの記述に沿った形の例示で、Claudeの実出力ではありません。
package cmd
import "github.com/spf13/cobra"
func NewRootCmd() *cobra.Command {
var file string
root := &cobra.Command{
Use: "memo",
Short: "メモをJSONファイルに溜める小さなCLI",
Version: "0.1.0",
SilenceUsage: true,
}
root.PersistentFlags().StringVarP(&file, "file", "f", "memo.json",
"保存先のJSONファイル")
root.AddCommand(newAddCmd(&file), newListCmd(&file))
return root
}押さえる点は4つです。
- Persistentフラグ: ガイドによれば、persistentなフラグは割り当てたコマンドとその配下すべてで使えます。
--fileをルートに置けば、addでもlistでも効きます - Version: ルートの
Versionを設定すると、cobraが--versionフラグを足します - SilenceUsage: エラー時に使い方を出さない設定です。本物のエラー文が埋もれるのを防げます
- RunE: エラーを呼び出し元に返したいときは、
RunではなくRunEを使います
cmd/add.goとcmd/list.goは、位置引数の検証とローカルフラグを使います。
// cmd/add.go
func newAddCmd(file *string) *cobra.Command {
return &cobra.Command{
Use: "add <text>",
Short: "メモを1件追加する",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
memos, err := load(*file)
if err != nil {
return err
}
memos = append(memos, args[0])
if err := save(*file, memos); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "added: %s\n", args[0])
return nil
},
}
}// cmd/list.go
func newListCmd(file *string) *cobra.Command {
var asJSON bool
c := &cobra.Command{
Use: "list",
Short: "保存済みのメモを表示する",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
memos, err := load(*file)
if err != nil {
return err
}
out := cmd.OutOrStdout()
if asJSON {
return json.NewEncoder(out).Encode(memos)
}
for i, m := range memos {
fmt.Fprintf(out, "%d: %s\n", i+1, m)
}
return nil
},
}
c.Flags().BoolVar(&asJSON, "json", false, "JSON配列として出力する")
return c
}位置引数の個数はArgsで検証します。ExactArgs(n)はちょうどn個でなければエラー、NoArgsは1つでもあればエラーです。Argsを書かなければ任意個数を受け付けるArbitraryArgsになります。引数を取らないコマンドにはNoArgsを明示させます。
出力先がfmt.Printlnではなくcmd.OutOrStdout()である点は、次のテストで効いてきます。store.goのloadとsaveは、JSONの読み書きだけの普通のGoなので省きます。
CLAUDE.mdにcobraの規約を置く
fmt.Printlnで直接出力したり、RunEの中でos.Exitを呼んだりする書き方では、テストが出力やエラーを受け取れません。この2点を含む規約を、CLAUDE.mdに書いておきます。
## Go製CLI(cobra)の規約
- main.go は cmd.NewRootCmd().Execute() を呼ぶだけにする
- コマンドは cmd/ 配下に1コマンド1ファイル。ルートは NewRootCmd() で毎回新しく作る
- 出力は cmd.OutOrStdout()、エラー表示は cmd.ErrOrStderr() に書く(fmt.Println を使わない)
- エラーは RunE で return する。os.Exit は main.go 以外で呼ばない
- 位置引数は Args(ExactArgs / NoArgs など)で必ず検証する
- コマンドを足したら、root_test.go に正常系と引数エラーのテストを足す
- 完了前に go vet ./... と go test ./... を通すmain.goは、ガイドの「薄く、cobraを初期化するだけ」に合わせます。os.Exitをmain.goに閉じ込める点だけが、この記事の追加です。
// main.go
package main
import (
"os"
"example.com/memo/cmd"
)
func main() {
if err := cmd.NewRootCmd().Execute(); err != nil {
os.Exit(1)
}
}権限も先に許可しておくと、確認の中断が減ります。permissions.allowのBash(...)は*を使ったパターンを書けます。たとえばBash(npm run *)はnpm run buildやnpm run test --watchに一致します。Goなら次のようにします。
{
"permissions": {
"allow": [
"Bash(go test *)",
"Bash(go vet *)",
"Bash(go build *)"
]
}
}テストはSetArgsとSetOutで同じ入口を叩く
cobraのコマンドは、引数と出力先を差し替えられます。SetArgsは、コマンドの引数をos.Args[1:]の代わりに設定するもので、パッケージのドキュメントは「テストのときに特に便利」と説明しています。SetOutとSetErrは、出力とエラーの書き込み先を差し替えます。
cmd/root_test.goは次の形です。
package cmd
import (
"bytes"
"path/filepath"
"strings"
"testing"
)
func run(t *testing.T, args ...string) (string, error) {
t.Helper()
var buf bytes.Buffer
root := NewRootCmd()
root.SetOut(&buf)
root.SetErr(&buf)
root.SetArgs(args)
err := root.Execute()
return buf.String(), err
}
func TestAddThenList(t *testing.T) {
f := filepath.Join(t.TempDir(), "memo.json")
if _, err := run(t, "-f", f, "add", "牛乳"); err != nil {
t.Fatalf("add failed: %v", err)
}
got, err := run(t, "-f", f, "list")
if err != nil {
t.Fatalf("list failed: %v", err)
}
if want := "1: 牛乳\n"; got != want {
t.Errorf("list = %q, want %q", got, want)
}
}
func TestAddRequiresOneArg(t *testing.T) {
if _, err := run(t, "add"); err == nil {
t.Error("引数なしの add はエラーになるはずです")
}
}
func TestHelpListsSubcommands(t *testing.T) {
got, err := run(t, "--help")
if err != nil {
t.Fatal(err)
}
for _, sub := range []string{"add", "list"} {
if !strings.Contains(got, sub) {
t.Errorf("--help に %q がありません", sub)
}
}
}保存先はt.TempDir()の下に作るので、テストが実際のメモファイルを汚しません。TestAddRequiresOneArgは、ExactArgs(1)が効いていることの確認です。エラー文の全文は固定せず、err != nilだけを見ます。cobraのバージョンでメッセージが変わっても落ちないようにするためです。
TestHelpListsSubcommandsは、ビルド後の--help確認のテスト版にあたります。ガイドは、サブコマンドがあるとcobraが自動でhelpコマンドを足し、全コマンドに--helpフラグが付くと説明しています。つまり--helpは、定義を足せば自動で更新される出力です。テストで固定するのは「サブコマンド名が並ぶこと」までにとどめ、説明文の全文は固定しません。説明文を直すたびにテストを直す羽目になるからです。
フラグの制約はRunEのifでなく宣言で書かせる
出力形式が増えると、「--jsonと--tsvは同時に指定できない」のような制約が必要になります。RunEの冒頭にifを並べて検証する書き方もできますが、cobraには宣言で書く方法があります。ユーザーガイドは次の関数を挙げています。
| 関数 | 制約 |
|---|---|
MarkFlagRequired("region") | 制約そのフラグを必須にする |
MarkFlagsRequiredTogether("username", "password") | 制約指定するなら全部そろえる |
MarkFlagsMutuallyExclusive("json", "yaml") | 制約同時に指定できない |
MarkFlagsOneRequired("json", "yaml") | 制約少なくとも1つは必要 |
最後の2つを組み合わせると「ちょうど1つ」を表せます。ガイドにはグループの適用範囲の注意もあります。制約が強制されるのは、グループ内のすべてのフラグが定義されているコマンドだけです。ルートにだけ定義したフラグとlistにだけ定義したフラグをまとめても、制約は効かない可能性があります。
宣言で書くと、Claudeに頼む文面も、確認も短くなります。listに--tsvを足す場合の例です。
c.Flags().BoolVar(&asTSV, "tsv", false, "TSV形式で出力する")
c.MarkFlagsMutuallyExclusive("json", "tsv")テストは、2つを同時に渡してエラーになることだけを見ます。
func TestListJSONAndTSVAreExclusive(t *testing.T) {
f := filepath.Join(t.TempDir(), "memo.json")
if _, err := run(t, "-f", f, "list", "--json", "--tsv"); err == nil {
t.Error("--json と --tsv の同時指定はエラーになるはずです")
}
}CLAUDE.mdには「フラグ同士の制約はMarkFlags...で宣言し、RunE内で手書きしない」と1行足しておきます。手書きのifはテストの数が増え、エラー文もコマンドごとにばらつくためです。
Stop hookでテストとビルド後の--helpを完了条件にする
規約とテストを用意しても、Claudeが「できました」と言った時点で通っている保証はありません。Claudeが応答を終えるたびに走るStop hookに、完了条件を持たせます。
.claude/hooks/go-cli-gate.shの例です。stop_hook_activeの扱いは、公式のトラブルシューティングにある書き方に沿っています。
#!/bin/bash
INPUT=$(cat)
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
exit 0
fi
cd "$CLAUDE_PROJECT_DIR" || exit 0
[ -f go.mod ] || exit 0
fail() { echo "$1" >&2; exit 2; }
OUT=$(go vet ./... 2>&1) || fail "go vet が失敗:
$(echo "$OUT" | tail -20)"
OUT=$(go test ./... 2>&1) || fail "go test が失敗:
$(echo "$OUT" | tail -25)"
BIN="$(mktemp -d)/memo"
OUT=$(go build -trimpath -o "$BIN" . 2>&1) || fail "go build が失敗:
$(echo "$OUT" | tail -20)"
HELP=$("$BIN" --help 2>&1) || fail "--help が異常終了しました"
for sub in add list; do
echo "$HELP" | grep -q "^ $sub " || fail "--help に $sub が並んでいません"
done
exit 0go vetを別に回す理由は、範囲の違いです。go testもビルドの一部としてgo vetを実行しますが、使われるのは信頼度の高い一部の検査(printfやtestsなど)に限られます。公式の説明では、全検査を走らせるには-vet=allを付けます。小さなCLIなら素のgo vet ./...を足しても数秒で終わるため、hookに直接書きました。fmt.Fprintfの書式ミスのような、cobraのコマンド内で起きやすい誤りはここで先に止まります。
grepの^ add は、--helpがAvailable Commands:の下に、コマンド名を2文字のインデント付きで並べる形式を前提にしています。ユーザーガイドのヘルプ出力例で、add・completion・help・initがその形で並んでいます。
ビルドの部分はgo build -trimpath -o "$BIN" .としています。go buildは、単一のmainパッケージをコンパイルすると実行ファイルを書き出します。-oはその出力先を指定するフラグです。-trimpathは、実行ファイルに記録される絶対パスを取り除きます。置き場所をmktemp -dにしたので、作業ディレクトリに成果物が残りません。単一バイナリになるので、配布は出来上がったファイル1つを渡せば足ります。
settings.jsonへの登録は次のとおりです。Stop hookは対象を絞るmatcherを持たないため、書きません。
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/go-cli-gate.sh"
}
]
}
]
}
}exit 2で書いたstderrは、Claudeが作業を続ける理由として渡されます。失敗の末尾だけを返す作りにしたので、長いビルドログがコンテキストを占めることもありません。
実際に頼むときの一言
hookを置いたら、頼み方は短くて済みます。公式のワークフロー集にも、テストを書かせたあとの一言として「新しいテストを実行して、失敗を直して」にあたる指示が載っています。cobra製CLIなら、次のように頼みます。
cmd/ に memo の add と list を実装して。規約は CLAUDE.md に従い、
root_test.go に正常系と引数エラーのテストも足して「テストを通して」とまでは書かなくて構いません。通っていなければ、Stop hookが差し戻します。
つまずきやすい点
独自のヘルプテンプレートを入れると、grepが外れる。 SetHelpTemplateでテンプレートを差し替えると、一覧の形式はテンプレートの中身しだいになります。独自テンプレートを使うCLIでは、hookのgrepを合わせて直します。
引数エラーのテストが通らない。 Argsを書き忘れると、ArbitraryArgsになってmemo addが引数なしで通ってしまいます。TestAddRequiresOneArgが先に落ちるので、規約の「必ず検証する」行とテストが組になって効きます。
テストが前のテストの影響を受ける。パッケージ変数にフラグをバインドしていると、同じプロセスで複数のExecuteを呼ぶテストが値を共有します。NewRootCmdの中で変数を宣言する構成は、これを避ける狙いです。
hookが止まらず続く。Stop hookは、Claudeがツールを一度も呼ばずに連続8回ブロックされると上書きされます。stop_hook_activeの判定を入れておけば、同じ失敗で延々と差し戻されるのを防げます。
まとめ
cobra製CLIは、コマンドの定義が--helpの出力にそのまま現れます。この性質があるので、go testに加えて「ビルドした実行ファイルの--helpにサブコマンドが並ぶか」を見るだけで、登録漏れという典型的な取りこぼしを機械的に拾えます。テスト側はNewRootCmdとSetArgs・SetOutで同じ入口を叩く形にしておくと、hookと同じ判定をテストコードの中でも再現できます。Rustならclap製CLIの出力の絞り方、PythonならTyper製CLIのCliRunnerテストが同じ問題の別の解き方です。Goのテストの書き方そのものはテーブル駆動テストの記事にまとめています。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。