Claude Media
Claude CodeでGoのcobra製CLIを作る — go testと--helpで完了を判定

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 0

go 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のテストの書き方そのものはテーブル駆動テストの記事にまとめています。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/common-workflows
  2. code.claude.com/docs/en/hooks-guide
  3. code.claude.com/docs/en/permissions
  4. github.com/spf13/cobra/blob/main/site/content/user_guide.md
  5. pkg.go.dev/github.com/spf13/cobra
  6. pkg.go.dev/cmd/go
この記事を共有:XはてブLinkedIn