mcpField
C
cobra-viper

cobra-viper

github.com/spf13開発687129 回インストール4 日前に更新
リポジトリ

cobra-viper について

cobra-viperは、CobraとViperを使ったGo製コマンドラインアプリの構築・保守向けスキルです。コマンド構成、設定管理、エラー処理、テストを扱い、CLIのルーティングとビジネスロジックの分離を重視します。

主な機能

  1. 01ファクトリー関数によるコマンドツリー構築
  2. 02RunEによるエラーの返却
  3. 03Viperによる設定ソースの統合
  4. 04型付き構造体への設定デコード
  5. 05コンテキスト伝達とテスト可能な出力

活用シーン

  1. 01Go CLIのコマンド構成の設計
  2. 02Cobraコマンドへの設定ファイルや環境変数の接続
  3. 03コマンド出力とエラー処理のテスト

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/spf13/go-skills --skill cobra-viper

インストール後、Claude Code で/cobra-viperを入力して読み込まれたか確認します。

同梱ファイル.claude-plugin/plugin.json

詳細

Go CLI アーキテクチャ:Cobra と Viper

Cobra と Viper を使用して、堅牢で設定駆動型のコマンドラインインターフェイスを構築するための、慣用的なパターンとベストプラクティスです。

使用するタイミング

  • Go で新しい CLI アプリケーションを作成するとき
  • 既存の Cobra アプリケーションにコマンド、サブコマンド、またはフラグを追加するとき
  • 設定ファイル、環境変数、またはフラグの管理に Viper を統合するとき
  • Cobra や Viper を使用する CLI コードをレビューまたはリファクタリングするとき
  • CLI ツールのコマンド構造や設定スキーマを設計するとき
  • CLI コマンドをテストするとき

基本理念

コマンド優先のアーキテクチャ

アプリケーションのバイナリをコマンドのルーターとして扱います。CLI フレームワーク(Cobra)が担当するのは、フラグ、引数、ルーティングだけにします。コアのビジネスロジックは CLI 層を完全に意識しないようにし、テストや再利用を容易にします。

統一された設定

設定は環境に対応し、統一されている必要があります。Viper を単一の情報源として、デフォルト値、設定ファイル、環境変数、コマンドラインフラグを一貫した状態にまとめてから、アプリケーションロジックに渡します。

コマンドは宣言せずに構築する

パッケージレベルの var 宣言ではなく、ファクトリー関数(NewRootCmd())を使ってコマンドツリーを構築します。グローバル変数があると、コマンドがテストできなくなり(テスト実行間で状態が漏れます)、ライブラリとしても利用できません。適切に構築された CLI で唯一のグローバルな処理は、main.go からファクトリーを呼び出すことです。

CLI パッケージの構成

アンチパターン: すべてのコマンドとコアロジックを internal/ ディレクトリツリーの奥深くに隠したり、すべてを main.go に詰め込んだりすることです。

見つけやすく、フラットな構成

コマンドのルーティングとビジネスロジックは、標準的で論理的な名前のパッケージに配置します。cmd/ パッケージが CLI の表層を担当し、その他のトップレベルパッケージがドメインロジックを担当します。

mycli/
├── main.go               # Minimal entry point: strictly calls cmd.Execute()
├── cmd/                  # The Cobra routing layer
│   ├── root.go           # Root command factory, global flags, Viper setup
│   ├── serve.go          # The 'serve' subcommand
│   └── build.go          # The 'build' subcommand
├── engine/               # Core business logic (name based on your domain)
│   ├── server.go
│   └── compiler.go
├── go.mod
└── go.sum

main.go は意図的に最小限にします。

package main

import (
    "os"

    "github.com/spf13/myapp/cmd"
)

func main() {
    if err := cmd.Execute(); err != nil {
        os.Exit(1)
    }
}
ファクトリーパターン(cmd/root.go)

各コマンドファイルはコンストラクターをエクスポートします。ルートファクトリーは独自の Viper インスタンスを作成し、すべてを接続します。パッケージレベルの状態は使いません。

func Execute() error {
    return NewRootCmd().Execute()
}

func NewRootCmd() *cobra.Command {
    v := viper.New()

    rootCmd := &cobra.Command{
        Use:           "mycli",
        Short:         "A brief description",
        SilenceUsage:  true,
        SilenceErrors: true,
        PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
            return initConfig(v, cmd)
        },
    }

    rootCmd.PersistentFlags().String("config", "", "config file path")
    rootCmd.PersistentFlags().String("log-level", "info", "log level")

    rootCmd.AddCommand(NewServeCmd(v))
    rootCmd.AddCommand(NewBuildCmd(v))
    return rootCmd
}

グローバル変数を使ってもよい場合: プロセス内でテストされることも、組み込まれることもない、小規模で用途が単一のツールです。テストを書いた時点で、ファクトリーに切り替えてください。

コマンドと実行処理を分離する

cmd/ パッケージ内のファイルが行うことは、次の 3 つだけです。

  1. Cobra コマンド、そのエイリアス、ヘルプテキストを定義します。
  2. そのコマンドに固有の Viper フラグと設定をバインドします。
  3. 解析済みの設定とコマンドのコンテキストを渡して、コアロジックパッケージ(例:engine)内の関数を呼び出します。

コアロジック(engine パッケージ)は、github.com/spf13/cobra または github.com/spf13/viper を一切インポートしないでください。

Cobra のベストプラクティス

以下のフラグ解析、コンテキストの受け渡し、コマンドツリーに関する内容は、Go のリリースによって変わりません。Go 1.26 や Go 1.27 で、Cobra のフラグ解析、cmd.Context() のコードへの受け渡し、コマンドの構築や追加の方法が変わることはありません。このスキルにおけるバージョンごとのガイダンスは、エラーの検査と設定のデコードにのみ関係し、該当箇所で説明します。

1. ネイティブなエラー処理には RunE を使う

Run は避けてください。コマンドが失敗した場合は、RunE を使って実行チェーンの上位にエラーを返します。これにより、defer 文を迂回する log.Fatal の呼び出しが各所に散在するのではなく、ルートコマンドでエラーを適切かつ一貫して処理できます。

// Idiomatic: Returning errors to be handled by the executor
func NewServeCmd(v *viper.Viper) *cobra.Command {
    cmd := &cobra.Command{
        Use:   "serve",
        Short: "Starts the primary application server",
        RunE: func(cmd *cobra.Command, args []string) error {
            var cfg engine.Config
            if err := v.Unmarshal(&cfg); err != nil {
                return fmt.Errorf("decoding config: %w", err)
            }
            return engine.Serve(cmd.Context(), cfg)
        },
    }
    cmd.Flags().String("addr", ":8080", "listen address")
    return cmd
}
2. アプリケーションエラーでは使用方法の表示を抑制する

デフォルトでは、エラーが返されるたびに Cobra はヘルプテキスト全体を表示します。エラーが構文エラーではなく、ネットワークタイムアウトのような実行時エラーの場合、これは混乱を招きます。ルートで SilenceUsage: true と SilenceErrors: true を設定し(上記を参照)、main.go でエラーを一度だけ出力してください。

3. コンテキストを考慮したコマンド

現代の Go では、キャンセルやタイムアウトに context.Context が広く使われています。Cobra コマンドのコンテキストを、そのままビジネスロジックに渡してください。

RunE: func(cmd *cobra.Command, args []string) error {
    return engine.Process(cmd.Context(), args)
}

そのコンテキストが Ctrl+C に対応するようにするには、main.go でシグナル処理を設定します。

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
err := cmd.NewRootCmd().ExecuteContext(ctx)
4. Args で位置引数を検証する

RunE の中で引数の数を検証してはいけません。Cobra が RunE の実行前に検証し、適切な使用法エラーを表示します。

cmd := &cobra.Command{
    Use:  "build [target]",
    Args: cobra.ExactArgs(1),
    // Also: cobra.NoArgs, cobra.MinimumNArgs(n), cobra.MaximumNArgs(n),
    // cobra.RangeArgs(min, max), cobra.OnlyValidArgs (with ValidArgs set)
    // Combine: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs)
}
5. 共通のセットアップには PersistentPreRunE を使う

ルートコマンドの PersistentPreRunE を使って、フラグの解析後、サブコマンドの実行前にセットアップ(設定の読み込み、ロギングなど)を行います。Cobra はすべてのサブコマンドに対して自動的にこれを実行します。

注意点: サブコマンドも PersistentPreRunE を定義している場合、親のものは置き換えられます。Cobra はデフォルトではこれらを連鎖して実行しません。親の処理を明示的に呼び出すか、cobra.EnableTraverseRunHooks = true を設定してグローバルに連鎖を有効にしてください(Cobra 1.8 以降)。

6. フラグの設計
// Persistent flags — inherited by all subcommands
rootCmd.PersistentFlags().String("config", "", "config file path")
rootCmd.PersistentFlags().BoolP("verbose", "v", false, "enable verbose output")

// Local flags — only for this command
serveCmd.Flags().String("addr", ":8080", "listen address")

// Required flags — Cobra validates before RunE is called
serveCmd.Flags().String("name", "", "required name")
serveCmd.MarkFlagRequired("name")

// Flag relationships (Cobra 1.8+ for OneRequired)
serveCmd.MarkFlagsMutuallyExclusive("json", "yaml")
serveCmd.MarkFlagsRequiredTogether("user", "password")
serveCmd.MarkFlagsOneRequired("file", "url")
  • 横断的な設定(設定ファイル、詳細度、出力形式)には PersistentFlags を使います。
  • コマンド固有のオプションには Flags を使います。
  • よく使うオプションには、必ず短いフラグ(-v、-o)を用意します。
7. ヘルプ出力でコマンドをグループ化する(Cobra 1.6 以降)

サブコマンドが多い CLI では、--help が単なる羅列ではなく、流れのある説明になるようにグループ化します。

rootCmd.AddGroup(&cobra.Group{ID: "core", Title: "Core Commands:"})
rootCmd.AddGroup(&cobra.Group{ID: "admin", Title: "Admin Commands:"})
serveCmd.GroupID = "core"
migrateCmd.GroupID = "admin"
8. シェル補完

Cobra では、シェル補完を簡単に生成できます。フラグと位置引数の両方に動的補完を追加します。

// Flag value completion
serveCmd.RegisterFlagCompletionFunc("output", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
    return []string{"json", "yaml", "table"}, cobra.ShellCompDirectiveNoFileComp
})

// Positional argument completion
buildCmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
    return listTargets(), cobra.ShellCompDirectiveNoFileComp
}
myapp completion bash        > /etc/bash_completion.d/myapp
myapp completion zsh         > "${fpath[1]}/_myapp"
myapp completion fish        > ~/.config/fish/completions/myapp.fish
myapp completion powershell  | Out-File -Encoding utf8 "$PROFILE\myapp.ps1"
9. fmt.Printf ではなくコマンド経由で出力する

コマンド内では、cmd.OutOrStdout()/cmd.ErrOrStderr()(または cmd.Println)を使って出力します。fmt.Printf を直接使うと SetOut/SetErr を迂回するため、テストで出力を捕捉できなくなります。

Viper の設定パターン

1. グローバルなシングルトンより Viper のインスタンスを優先する

viper.New() を使うと、インスタンスを注入し、テストで分離し、並行して実行できます。パッケージレベルの viper.Get* シングルトンは、小規模なツールで便利に使うためのものです。これは pflag のデフォルトの FlagSet と同様のトレードオフです。テストや複数のコマンドを含む場合は、(上記のファクトリーの例と同様に)*viper.Viper を明示的に渡してください。

2. 型付きの構造体にアンマーシャルする

アンチパターン: ビジネスロジックの奥深くで v.GetString("database.host") を呼び出すことです。これではドメインが Viper に強く結び付けられ、コードベース全体にマジック文字列が散在します。

代わりに、厳密に型付けされた設定構造体を定義し、ルーティング層(cmd/)で Viper の状態をその構造体にアンマーシャルしてから、構造体を下位層に渡します。

type Config struct {
    Host string `mapstructure:"host"`
    Port int    `mapstructure:"port"`
}

var cfg Config
if err := v.Unmarshal(&cfg); err != nil {
    return fmt.Errorf("unable to decode config: %w", err)
}

マーシャルも行う設定構造体(config dump コマンドや --json 出力モードなど)では、同じフィールドに JSON タグが必要です。また、ゼロ値を扱うオプションは互換性がありません。time.Time やネストされた設定構造体など、構造体型のフィールドには omitempty ではなく omitzero(Go 1.24)を使ってください。omitempty は構造体を空とは見なさないため、未設定の time.Time は "0001-01-01T00:00:00Z" としてマーシャルされますが、omitzero なら省略されます。

type Config struct {
    Host     string        `mapstructure:"host"     json:"host"`
    Started  time.Time     `mapstructure:"started"  json:"started,omitzero"` // omitempty would emit the zero time
    Listen   ListenConfig  `mapstructure:"listen"   json:"listen,omitzero"`  // omitempty never omits a struct
}
3. バインドの優先順位

Viper は、次の順序(高い → 低い)で設定ソースをマージします。

  1. コード内で明示的に呼び出した Set()
  2. フラグ(BindPFlag/BindPFlags でバインド)
  3. 環境変数(MYCLI_PORT)
  4. 設定ファイル(~/.mycli.yaml、./.mycli.yaml)
  5. デフォルト値(v.SetDefault)

各ソースは明示的にバインドする必要があります。フラグを一つずつではなく、フラグセット全体をまとめてバインドしてください。

// In PersistentPreRunE — after flags are parsed, before RunE
if err := v.BindPFlags(cmd.Flags()); err != nil {
    return err
}

init() ではなく PersistentPreRunE でバインドすると、2 つのサブコマンドが異なるフラグを同じ Viper キーにバインドし、最後に実行された init() が黙って優先されるという、よくある衝突も避けられます。

4. 環境変数のマッピング

v.SetEnvPrefix("MYAPP") と v.AutomaticEnv() を使用した場合:

Viper のキー環境変数
log-levelMYAPP_LOG_LEVEL
serve.addrMYAPP_SERVE_ADDR
db.passwordMYAPP_DB_PASSWORD

ドットやハイフンを含むネストされたキーを環境変数名に正しくマッピングするには、置換関数が必要です:

v.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_"))

重大な落とし穴 — AutomaticEnv と Unmarshal の組み合わせ: Unmarshal は、Viper がすでに把握しているキー(デフォルト値、設定ファイル、または明示的なバインドから)だけを走査します。環境変数だけで設定された値は、そのキーが登録されていない限り Unmarshal から見えません。対処法は、設定構造体内のすべてのキーに対して v.SetDefault を呼び出すか、各キーを明示的に v.BindEnv でバインドすることです。これは、Viper を使うアプリケーションで「環境変数が無視される」という最もよくあるバグです。

5. 設定ファイルのセットアップ
func initConfig(v *viper.Viper, cmd *cobra.Command) error {
    if cfgFile, _ := cmd.Flags().GetString("config"); cfgFile != "" {
        v.SetConfigFile(cfgFile)
    } else {
        home, err := os.UserHomeDir()
        if err != nil {
            return err
        }
        v.AddConfigPath(home)
        v.AddConfigPath(".")
        v.SetConfigType("yaml")
        v.SetConfigName(".myapp")
    }

    v.SetEnvPrefix("MYAPP")
    v.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_"))
    v.AutomaticEnv()

    if err := v.ReadInConfig(); err != nil {
        // Go 1.26+: generic, type-safe, and faster than errors.As.
        if _, ok := errors.AsType[viper.ConfigFileNotFoundError](err); !ok {
            return fmt.Errorf("reading config: %w", err)
        }
        // Not found is fine — defaults, env, and flags still apply
    }

    return v.BindPFlags(cmd.Flags())
}

errors.AsType[T](err)(Go 1.26 以降)は、一致した値と ok ブール値を返すため、事前にターゲット変数を宣言する必要がなく、ポインター以外を渡して誤用することもありません。Go 1.26 より前では、従来の errors.As を使う形式を使用してください。こちらも引き続き正しく動作し、バージョンを限定した場合の代替手段となります:

var notFound viper.ConfigFileNotFoundError
if !errors.As(err, &notFound) {
    return fmt.Errorf("reading config: %w", err)
}

デフォルトの形式には、読みやすく、ネストにも対応している YAML を使用してください:

# ~/.myapp.yaml
log-level: debug

serve:
  addr: ":9090"
  port: 9090

db:
  host: localhost
  port: 5432
  name: myapp
6. JSON 設定ファイルのデコードは厳密です(Go 1.27)

Go 1.27 以降、encoding/json は encoding/json/v2 の実装を基盤としており、Viper は .json 設定ファイルを encoding/json でデコードします。新しいデフォルトはより厳密になり、以前は見過ごされていた次のどちらもエラーになりました:

  • JSON 文字列内の無効な UTF-8。
  • 同じオブジェクト内での名前の重複。

CLI への影響は明確です:以前は解析できていた設定ファイルが、今は失敗することがあります。v.ReadInConfig() はデコードエラーを返し、設定の読み込みは PersistentPreRunE 内で実行されるため、RunE の前にコマンドが停止します。同じファイルを変更していなくても、新しいツールチェーンだけで失敗します:

{
  "db": { "host": "localhost", "host": "db.internal" }
}

このオブジェクトは以前から曖昧でした。以前のデコーダーでは最後の host が黙って採用されていました。このファイルがユーザーの .myapp.json であれば、アップグレードによって、正常に動作していたコマンドが起動時に設定エラーで終了するようになります。

回避策は 2 つあります。どちらもコードの一部ではなく、v1 のセマンティクスに限定されます。encoding/json.Options は v2 実装上で v1 の動作を設定し、GOEXPERIMENT=nojsonv2 はプロセス全体で以前の実装に戻します。この環境変数は移行を支援するためのもので、将来のリリースで削除される予定です。そのため、これに依存するより、設定ファイルを修正するか、Options を意図的に使用してください。

v1 の動作はそれ以外では維持されていますが、エラーメッセージのテキストは異なる場合があります。JSON デコードエラーのテキストで判定せず、型で判定してください。

v.Unmarshal 自体はこの切り替えの影響を受けません。encoding/json ではなく mapstructure タグを使ってフィールドをマッピングするため、変わるのはデコードのステップだけです。

バージョンの処理

Cobra にはバージョン表示の仕組みが組み込まれています。独自にサブコマンドを実装するより、こちらを優先してください:

// Set at build time via -ldflags
var (
    version = "dev"
    commit  = "none"
    date    = "unknown"
)

rootCmd := &cobra.Command{
    Use:     "myapp",
    Version: fmt.Sprintf("%s (commit: %s, built: %s)", version, commit, date),
}
// `myapp --version` now works for free.
// Customize the output with rootCmd.SetVersionTemplate if needed.
go build -ldflags="-X 'github.com/spf13/myapp/cmd.version=1.2.3' \
                   -X 'github.com/spf13/myapp/cmd.commit=$(git rev-parse --short HEAD)' \
                   -X 'github.com/spf13/myapp/cmd.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)'"

構造化出力(version --json)が必要な場合に限り、version サブコマンドを追加してください。出力には fmt.Printf ではなく、cmd.OutOrStdout() を使用します。

CLI コマンドのテスト

アンチパターン: バイナリをコンパイルして os/exec を使い、CLI コマンドをテストする方法です。非常に遅く、壊れやすいうえに、テストカバレッジの測定も困難になります。

2 つ目のアンチパターン: パッケージレベルのサブコマンドを直接実行する方法です。cmd.Execute() は常にコマンドツリーのルートから実行されます。また、グローバルコマンドを変更すると、テスト間でフラグの状態が漏れます。コマンドをファクトリーで構築するのは、まさにこのためです。

// Idiomatic: In-memory CLI testing via the factory
func TestServeCommand(t *testing.T) {
    tests := []struct {
        name    string
        args    []string
        want    string
        wantErr bool
    }{
        {"default port", []string{"serve"}, "listening on :8080", false},
        {"custom port", []string{"serve", "--addr", ":9090"}, "listening on :9090", false},
        {"bad flag", []string{"serve", "--bogus"}, "", true},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            buf := new(bytes.Buffer)

            root := cmd.NewRootCmd() // fresh tree + fresh Viper every test
            root.SetOut(buf)
            root.SetErr(buf)
            root.SetArgs(tt.args) // full CLI invocation, exactly as a user types it

            err := root.ExecuteContext(t.Context())
            if (err != nil) != tt.wantErr {
                t.Fatalf("Execute() error = %v, wantErr %v", err, tt.wantErr)
            }
            if !strings.Contains(buf.String(), tt.want) {
                t.Errorf("output = %q, want substring %q", buf.String(), tt.want)
            }
        })
    }
}
  • 必ずルートから実行し、サブコマンド名を含む SetArgs を指定してください。フラグの解析や PersistentPreRunE を含め、ユーザーが実際に通るコードパスを実行できます。
  • テストケースごとに新しい NewRootCmd() を作成すれば、状態の共有がなくなり、viper.Reset() も不要になって、テストを並列実行できます。
  • グローバルな Viper シングルトンを使わざるを得ない場合は、テスト間で viper.Reset() を呼び出してください。状態がテストケース間に漏れます。
  • 出力のキャプチャには SetOut / SetErr を使ってください。これが機能するのは、コマンドが cmd.OutOrStdout() 経由で出力する場合に限られます。
  • 設定の環境変数は t.Setenv で設定してください。自動的に元に戻され、誤った t.Parallel の使用も防げます。

よくある間違い

  • テストでサブコマンド変数を直接実行する: serverCmd.Execute() はそのコマンドだけではなく、ルートからツリー全体を実行します。ファクトリーで新しいツリーを構築し、SetArgs 経由で実行してください。
  • Unmarshal 後に環境変数のみの値が欠落する: AutomaticEnv はキーを登録しません。設定構造体にあるすべてのキーに対して SetDefault または BindEnv を設定してください(上記の注意点を参照)。
  • 一度解析できた JSON 設定は今後も解析できると思い込む: Go 1.27 以降、デコーダーは無効な UTF-8 と重複するオブジェクト名を拒否するため、ツールチェーンをアップグレードしただけで、設定ファイルが ReadInConfig で失敗し始めることがあります(上記の §6 を参照)。
  • 設定の読み込み前に Viper にアクセスする: セットアップ(PersistentPreRunE または cobra.OnInitialize)が実行されるまで、値は空です。init() 関数や var ブロック内で Viper を読み取らないでください。
  • BindPFlags を忘れる: フラグは自動的には Viper から参照できません。コマンドのフラグセットを明示的にバインドしてください。
  • SetEnvKeyReplacer を設定し忘れる: ドットを含むネストされたキー(例:serve.addr)は、置換処理を設定しないと MYAPP_SERVE_ADDR と一致しません。
  • ビジネスロジックで Cobra/Viper をインポートする: engine パッケージでは、Cobra や Viper を絶対にインポートしないでください。型付きの設定構造体を渡します。
  • コマンド内で fmt.Printf を使う: SetOut を迂回するため、テストでの出力キャプチャが機能しなくなります。cmd.OutOrStdout() を使ってください。
  • サブコマンドをネストしすぎる: 2 階層(app command subcommand)が通常は適切な深さです。3 階層以上にすると、ユーザーが混乱します。

コメント