CLIツール開発

レッスン25:CLIツール開発

現実世界のアナロジー

自動販売機の前に立っていると想像してください。ボタンを押して飲み物を選択します(引数を渡す)、機械は選択に基づいて出力します(対応する関数を実行)、間違ったボタンを押すと「無効な選択」と表示されます(引数バリデーション)。CLIツールはその自動販売機のようなものです。ユーザーがコマンドラインでコマンドと引数を入力し、プログラムが対応する操作を実行して結果を出力します。優れたCLIツールは設計の良い自動販売機のようです:明確なボタン配置、明示的な操作プロンプト、親切なエラーフィードバックがあります。


コアコンセプト

GoはCLIツールの構築に天然適しています。単一バイナリにコンパイルでき、クロスプラットフォームをサポートし、起動が高速です。以下はコアコンセプトです:

コンセプト 説明
os.Args 生のコマンドライン引数スライス、[0]はプログラム名
flagパッケージ 標準ライブラリが提供する引数解析ツール
cobraライブラリ サブコマンド、自動ヘルプ、シェル補完をサポートするサードパーティCLIフレームワーク
サブコマンド git commitdocker runのように、プログラムがサブコマンドに基づいて異なるロジックを実行する
引数バリデーション ユーザー入力が有効かどうかを検証し、親切なエラーメッセージを提供する
インタラクティブ入力 実行時にユーザー入力を受け取り、インタラクティブなCLI体験を構築する

os.Argsとflagパッケージ

os.Argsは引数を取得する最も原始的な方法で、単純なシナリオに適しています。flagパッケージは型付きフラグ解析を提供します:

コマンドライン構造:
program [flags] [args]
  ↑         ↑       ↑
プログラム名  フラグ  位置引数

例:
go build -o myapp ./cmd
  ↑       ↑     ↑
go     -o myapp  ./cmd

cobraライブラリ

cobraはGoコミュニティで最も人気のあるCLIフレームワークで、kubectl、docker、hugoなどのプロジェクトで使用されています:

cobraのコアコンポーネント:
- RootCmd:ルートコマンド、プログラムのエントリポイント
- SubCmd:サブコマンド、例えば「add」「list」「delete」
- Run/RunE:コマンド実行関数
- Flags:コマンドにバインドされたフラグ引数

基本構文と使い方

1. os.Argsの基本

GO
package main

import (
    "fmt"
    "os"
)

func main() {
    // os.Argsは文字列スライスで、最初の要素はプログラム名です
    args := os.Args
    fmt.Printf("引数の数: %d\n", len(args))
    fmt.Printf("プログラム名: %s\n", args[0])

    // すべての引数を繰り返し処理
    if len(args) > 1 {
        fmt.Println("渡された引数:")
        for i, arg := range args[1:] {
            fmt.Printf("  [%d] %s\n", i, arg)
        }
    }
}
BASH
# テスト実行
$ go run main.go hello world
引数の数: 3
プログラム名: /tmp/go-build.../main
渡された引数:
  [0] hello
  [1] world
💡 ヒント: os.Args[0]は必ずしも入力したプログラム名ではありません。オペレーティングシステムが渡した実行ファイルのパスです。クロスプラットフォームで作業する際はこの違いに注意してください。

2. flagパッケージでフラグを解析する

GO
package main

import (
    "flag"
    "fmt"
)

func main() {
    // フラグ引数を定義
    name := flag.String("name", "World", "あなたの名前")
    age := flag.Int("age", 18, "あなたの年齢")
    verbose := flag.Bool("v", false, "詳細出力")

    // コマンドライン引数を解析
    flag.Parse()

    // 解析された値を使用(注意:ポインタなのでデリファレンスが必要)
    if *verbose {
        fmt.Printf("[デバッグ] name=%s, age=%d\n", *name, *age)
    }
    fmt.Printf("こんにちは、%sさん! %d歳ですね。\n", *name, *age)

    // フラグ以外の引数を取得(位置引数)
    fmt.Printf("残りの引数: %v\n", flag.Args())
}
BASH
$ go run main.go -name=Alice -age=25 -v
[デバッグ] name=Alice, age=25
こんにちは、Aliceさん! 25歳ですね。
残りの引数: []

$ go run main.go --help
Usage of main:
  -age int
        あなたの年齢 (default 18)
  -name string
        あなたの名前 (default "World")
  -v    詳細出力
💡 ヒント: flagは2つの代入スタイルをサポートしています:-flag=value-flag value。ただし、-flag valueスタイルでは、-flagの直後に値が続き、他のフラグと組み合わせることはできません。

3. 変数バインディングの使用

GO
package main

import (
    "flag"
    "fmt"
)

func main() {
    // 変数バインディングを使用して、ポインタではなく変数を直接操作
    var host string
    var port int
    var debug bool

    flag.StringVar(&host, "host", "localhost", "サーバーアドレス")
    flag.IntVar(&port, "port", 8080, "ポート番号")
    flag.BoolVar(&debug, "debug", false, "デバッグモードを有効にする")

    flag.Parse()

    fmt.Printf("%s:%d に接続中 (debug: %v)\n", host, port, debug)
}
💡 ヒント: StringVar/IntVar/BoolVarは既存の変数にバインドされ、設定を複数の場所で共有する必要があるシナリオに適しています。対応するString/Int/Boolはポインタを返し、ローカルでの使用に適しています。

4. cobraの基本構造

GO
package main

import (
    "fmt"
    "os"

    "github.com/spf13/cobra"
)

func main() {
    // ルートコマンド
    rootCmd := &cobra.Command{
        Use:   "myapp",
        Short: "サンプルCLIアプリケーション",
        Long:  "これはcobraで構築されたサンプルCLIアプリケーションです。",
        Run: func(cmd *cobra.Command, args []string) {
            fmt.Println("myappへようこそ!")
        },
    }

    // サブコマンド
    helloCmd := &cobra.Command{
        Use:   "hello [name]",
        Short: "挨拶する",
        Args:  cobra.MinimumNArgs(1), // 最低1つの引数が必要
        Run: func(cmd *cobra.Command, args []string) {
            fmt.Printf("こんにちは、%sさん!\n", args[0])
        },
    }

    // ルートコマンドにサブコマンドを追加
    rootCmd.AddCommand(helloCmd)

    // 実行
    if err := rootCmd.Execute(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}
BASH
$ go run main.go
myappへようこそ!

$ go run main.go hello Go
こんにちは、Goさん!

$ go run main.go --help
サンプルCLIアプリケーション

Usage:
  myapp [command]

Available Commands:
  hello       挨拶する
  help        Help about any command

Flags:
  -h, --help   help for myapp
💡 ヒント: cobraは--help-hを自動的に処理するため、手動で実装する必要はありません。また、引数が間違っている場合に使用方法のヒントを自動生成します。

5. cobraフラグバインディング

GO
package main

import (
    "fmt"
    "os"
    "strings"

    "github.com/spf13/cobra"
)

func main() {
    var name string
    var count int

    rootCmd := &cobra.Command{
        Use:   "greeter",
        Short: "繰り返し挨拶ツール",
        Run: func(cmd *cobra.Command, args []string) {
            for i := 0; i < count; i++ {
                fmt.Printf("こんにちは、%sさん! (%d/%d)\n", name, i+1, count)
            }
        },
    }

    // 永続フラグ(すべてのサブコマンドに適用)
    rootCmd.PersistentFlags().StringVarP(&name, "name", "n", "World", "挨拶の対象")

    // ローカルフラグ(現在のコマンドにのみ適用)
    rootCmd.Flags().IntVarP(&count, "count", "c", 1, "繰り返し回数")

    if err := rootCmd.Execute(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}
BASH
$ go run main.go -n Alice -c 3
こんにちは、Aliceさん! (1/3)
こんにちは、Aliceさん! (2/3)
こんにちは、Aliceさん! (3/3)

6. インタラクティブ入力

GO
package main

import (
    "bufio"
    "fmt"
    "os"
    "strings"
)

func main() {
    reader := bufio.NewReader(os.Stdin)

    // 単一行の入力を読み取る
    fmt.Print("名前を入力してください: ")
    name, _ := reader.ReadString('\n')
    name = strings.TrimSpace(name) // 改行文字を削除

    // デフォルト値付きの入力を読み取る
    fmt.Print("年齢を入力してください(デフォルト18): ")
    ageInput, _ := reader.ReadString('\n')
    ageInput = strings.TrimSpace(ageInput)

    if ageInput == "" {
        ageInput = "18"
    }

    fmt.Printf("こんにちは、%sさん! %s歳ですね。\n", name, ageInput)
}
💡 ヒント: bufio.NewReaderfmt.Scanlnより信頼性があります。スペースを含む入力を正しく処理し、改行文字による早期切り捨てが発生しません。

7. パスワード入力(非表示文字)

GO
package main

import (
    "fmt"
    "os"
    "syscall"

    "golang.org/x/term"
)

func main() {
    fmt.Print("パスワードを入力してください: ")

    // パスワードを読み取る(文字はエコーされない)
    password, err := term.ReadPassword(int(syscall.Stdin))
    if err != nil {
        fmt.Fprintf(os.Stderr, "パスワードの読み取りに失敗しました: %v\n", err)
        return
    }
    fmt.Println() // 読み取り後の改行

    fmt.Printf("パスワードの長さ: %d\n", len(password))
}
💡 ヒント: golang.org/x/termはGoの公式拡張ライブラリで、パスワード読み取り、カーソル制御などのクロスプラットフォーム端末操作を提供します。


サンプルコード

例:基本的なコマンドライン電卓(難易度⭐)

GO
package main

import (
    "flag"
    "fmt"
    "os"
    "strconv"
)

func main() {
    // フラグを定義
    op := flag.String("op", "add", "演算タイプ: add, sub, mul, div")
    verbose := flag.Bool("v", false, "詳細情報を表示")

    flag.Parse()

    // 位置引数の数を確認
    args := flag.Args()
    if len(args) < 2 {
        fmt.Fprintln(os.Stderr, "エラー:少なくとも2つの数値を引数として指定してください")
        fmt.Fprintln(os.Stderr, "使い方: calc -op=add 10 20")
        flag.Usage()
        os.Exit(1)
    }

    // 数値を解析
    a, err := strconv.ParseFloat(args[0], 64)
    if err != nil {
        fmt.Fprintf(os.Stderr, "エラー:数値 %q を解析できません: %v\n", args[0], err)
        os.Exit(1)
    }
    b, err := strconv.ParseFloat(args[1], 64)
    if err != nil {
        fmt.Fprintf(os.Stderr, "エラー:数値 %q を解析できません: %v\n", args[1], err)
        os.Exit(1)
    }

    // 演算を実行
    var result float64
    var opName string

    switch *op {
    case "add":
        result = a + b
        opName = "加算"
    case "sub":
        result = a - b
        opName = "減算"
    case "mul":
        result = a * b
        opName = "乗算"
    case "div":
        if b == 0 {
            fmt.Fprintln(os.Stderr, "エラー:除数はゼロにできません")
            os.Exit(1)
        }
        result = a / b
        opName = "除算"
    default:
        fmt.Fprintf(os.Stderr, "エラー:サポートされていない演算タイプ %q\n", *op)
        os.Exit(1)
    }

    // 結果を出力
    if *verbose {
        fmt.Printf("演算: %s\n", opName)
        fmt.Printf("式: %g %s %g\n", a, map[string]string{
            "add": "+", "sub": "-", "mul": "*", "div": "/",
        }[*op], b)
    }
    fmt.Printf("結果: %g\n", result)
}
▶ 試してみよう
BASH
$ go run main.go -op=add 10 20
結果: 30

$ go run main.go -op=mul -v 3.5 4
演算: 乗算
式: 3.5 * 4
結果: 14

$ go run main.go -op=div 10 0
エラー:除数はゼロにできません

$ go run main.go 10
エラー:少なくとも2つの数値を引数として指定してください

例:cobraマルチサブコマンドツール — ファイルマネージャー(難易度⭐⭐)

GO
package main

import (
    "fmt"
    "os"
    "path/filepath"
    "strings"
    "time"

    "github.com/spf13/cobra"
)

var verbose bool

func main() {
    rootCmd := &cobra.Command{
        Use:   "filetool",
        Short: "シンプルなファイル管理ツール",
        Long:  "filetoolはファイルの閲覧と管理を行うCLIツールです。",
    }

    // 永続フラグ
    rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "詳細出力")

    // サブコマンドを追加
    rootCmd.AddCommand(infoCmd())
    rootCmd.AddCommand(listCmd())
    rootCmd.AddCommand(searchCmd())

    if err := rootCmd.Execute(); err != nil {
        os.Exit(1)
    }
}

// infoサブコマンド:ファイルの詳細を表示
func infoCmd() *cobra.Command {
    return &cobra.Command{
        Use:   "info <ファイルパス>",
        Short: "ファイルの詳細を表示",
        Args:  cobra.ExactArgs(1),
        RunE: func(cmd *cobra.Command, args []string) error {
            path := args[0]
            info, err := os.Stat(path)
            if err != nil {
                return fmt.Errorf("%q にアクセスできません: %w", path, err)
            }

            fmt.Printf("ファイル名:    %s\n", info.Name())
            fmt.Printf("サイズ:        %d バイト\n", info.Size())
            fmt.Printf("更新日時:      %s\n", info.ModTime().Format("2006-01-02 15:04:05"))
            fmt.Printf("ディレクトリ:  %v\n", info.IsDir())
            fmt.Printf("パーミッション: %s\n", info.Mode())

            if verbose {
                fmt.Printf("絶対パス:      ")
                abs, err := filepath.Abs(path)
                if err == nil {
                    fmt.Println(abs)
                }
                fmt.Printf("拡張子:        %s\n", filepath.Ext(path))
            }
            return nil
        },
    }
}

// listサブコマンド:ディレクトリの内容を一覧表示
func listCmd() *cobra.Command {
    var showHidden bool

    cmd := &cobra.Command{
        Use:   "list [ディレクトリパス]",
        Short: "ディレクトリの内容を一覧表示",
        Args:  cobra.MaximumNArgs(1),
        RunE: func(cmd *cobra.Command, args []string) error {
            dir := "."
            if len(args) > 0 {
                dir = args[0]
            }

            entries, err := os.ReadDir(dir)
            if err != nil {
                return fmt.Errorf("ディレクトリ %q を読み取れません: %w", dir, err)
            }

            fmt.Printf("ディレクトリ: %s\n", dir)
            fmt.Println(strings.Repeat("─", 50))

            count := 0
            for _, entry := range entries {
                // 隠れファイルをスキップ(-aが指定されていない場合)
                if !showHidden && strings.HasPrefix(entry.Name(), ".") {
                    continue
                }

                info, err := entry.Info()
                if err != nil {
                    continue
                }

                // ディレクトリに/サフィックスを追加
                name := entry.Name()
                if entry.IsDir() {
                    name += "/"
                }

                fmt.Printf("  %-30s %8d  %s\n",
                    name,
                    info.Size(),
                    info.ModTime().Format("2006-01-02 15:04"))
                count++
            }

            fmt.Printf("\n合計 %d 件\n", count)
            return nil
        },
    }

    cmd.Flags().BoolVarP(&showHidden, "all", "a", false, "隠れファイルを表示")
    return cmd
}

// searchサブコマンド:拡張子でファイルを検索
func searchCmd() *cobra.Command {
    var maxDepth int

    cmd := &cobra.Command{
        Use:   "search <拡張子>",
        Short: "拡張子でファイルを検索",
        Long:  "現在のディレクトリとサブディレクトリで指定された拡張子のファイルを検索します。例: filetool search .go",
        Args:  cobra.ExactArgs(1),
        RunE: func(cmd *cobra.Command, args []string) error {
            ext := args[0]
            if !strings.HasPrefix(ext, ".") {
                ext = "." + ext
            }

            root := "."
            found := 0

            err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
                if err != nil {
                    return nil // アクセスできないファイルをスキップ
                }

                // 深度を確認
                if maxDepth > 0 {
                    depth := strings.Count(filepath.Clean(path), string(os.PathSeparator)) -
                        strings.Count(filepath.Clean(root), string(os.PathSeparator))
                    if depth > maxDepth {
                        if info.IsDir() {
                            return filepath.SkipDir
                        }
                        return nil
                    }
                }

                if !info.IsDir() && filepath.Ext(path) == ext {
                    fmt.Printf("  %s (%d バイト, %s)\n",
                        path,
                        info.Size(),
                        info.ModTime().Format("2006-01-02"))
                    found++
                }
                return nil
            })

            if err != nil {
                return fmt.Errorf("検索エラー: %w", err)
            }

            fmt.Printf("\n%d 件の %s ファイルが見つかりました\n", found, ext)
            return nil
        },
    }

    cmd.Flags().IntVarP(&maxDepth, "depth", "d", 0, "最大検索深度(0は無制限)")
    return cmd
}
▶ 試してみよう
BASH
$ go run main.go info main.go
ファイル名:    main.go
サイズ:        2048 バイト
更新日時:      2025-06-27 14:30:00
ディレクトリ:  false
パーミッション: -rw-r--r--

$ go run main.go list -a
ディレクトリ: .
──────────────────────────────────────────────────
  .git/                              4096  2025-06-27 14:00
  main.go                            2048  2025-06-27 14:30
  go.mod                              128  2025-06-27 14:00

合計 3 件

$ go run main.go search .go -d 2
  ./main.go (2048 バイト, 2025-06-27)
  ./internal/handler.go (1024 バイト, 2025-06-26)

2 件の .go ファイルが見つかりました

例:完全なTodo CLIアプリケーション(難易度⭐⭐⭐)

GO
package main

import (
    "encoding/json"
    "fmt"
    "os"
    "path/filepath"
    "strconv"
    "strings"
    "time"

    "github.com/spf13/cobra"
)

// Todoアイテムの構造体
type Todo struct {
    ID        int       `json:"id"`
    Title     string    `json:"title"`
    Done      bool      `json:"done"`
    CreatedAt time.Time `json:"created_at"`
    DoneAt    *time.Time `json:"done_at,omitempty"`
}

// TodoListは永続化をサポート
type TodoList struct {
    Todos    []Todo `json:"todos"`
    NextID   int    `json:"next_id"`
    filePath string
}

// NewTodoListはTodoリストを作成または読み込む
func NewTodoList(filePath string) (*TodoList, error) {
    list := &TodoList{
        Todos:  []Todo{},
        NextID: 1,
        filePath: filePath,
    }

    // ファイルが存在する場合、データを読み込む
    if _, err := os.Stat(filePath); err == nil {
        data, err := os.ReadFile(filePath)
        if err != nil {
            return nil, fmt.Errorf("データファイルの読み取りに失敗しました: %w", err)
        }
        if len(data) > 0 {
            if err := json.Unmarshal(data, list); err != nil {
                return nil, fmt.Errorf("データファイルの解析に失敗しました: %w", err)
            }
        }
    }

    return list, nil
}

// Saveはファイルに保存する
func (tl *TodoList) Save() error {
    // ディレクトリが存在することを確認
    dir := filepath.Dir(tl.filePath)
    if err := os.MkdirAll(dir, 0755); err != nil {
        return fmt.Errorf("ディレクトリの作成に失敗しました: %w", err)
    }

    data, err := json.MarshalIndent(tl, "", "  ")
    if err != nil {
        return fmt.Errorf("データのシリアライズに失敗しました: %w", err)
    }

    return os.WriteFile(tl.filePath, data, 0644)
}

// Addは新しいTodoを追加する
func (tl *TodoList) Add(title string) Todo {
    todo := Todo{
        ID:        tl.NextID,
        Title:     title,
        Done:      false,
        CreatedAt: time.Now(),
    }
    tl.Todos = append(tl.Todos, todo)
    tl.NextID++
    return todo
}

// Completeは完了としてマークする
func (tl *TodoList) Complete(id int) error {
    for i := range tl.Todos {
        if tl.Todos[i].ID == id {
            if tl.Todos[i].Done {
                return fmt.Errorf("タスク #%d は既に完了しています", id)
            }
            tl.Todos[i].Done = true
            now := time.Now()
            tl.Todos[i].DoneAt = &now
            return nil
        }
    }
    return fmt.Errorf("タスク #%d が見つかりません", id)
}

// DeleteはTodoを削除する
func (tl *TodoList) Delete(id int) error {
    for i, todo := range tl.Todos {
        if todo.ID == id {
            tl.Todos = append(tl.Todos[:i], tl.Todos[i+1:]...)
            return nil
        }
    }
    return fmt.Errorf("タスク #%d が見つかりません", id)
}

// Filterはフィルタリングされたリストを返す
func (tl *TodoList) Filter(showDone bool) []Todo {
    var result []Todo
    for _, todo := range tl.Todos {
        if showDone || !todo.Done {
            result = append(result, todo)
        }
    }
    return result
}

// dataFilePathはデフォルトのデータファイルパスを返す
func dataFilePath() string {
    home, err := os.UserHomeDir()
    if err != nil {
        return ".todo.json"
    }
    return filepath.Join(home, ".todo.json")
}

func main() {
    dataFile := dataFilePath()

    rootCmd := &cobra.Command{
        Use:   "todo",
        Short: "コマンドラインTodoマネージャー",
        Long: `todoはシンプルなコマンドラインTodoマネージャーです。

データは ~/.todo.json に保存されます。`,
    }

    // addサブコマンド
    var addCmd = &cobra.Command{
        Use:   "add <タスクタイトル>",
        Short: "新しいタスクを追加",
        Args:  cobra.MinimumNArgs(1),
        RunE: func(cmd *cobra.Command, args []string) error {
            title := strings.Join(args, " ")
            list, err := NewTodoList(dataFile)
            if err != nil {
                return err
            }
            todo := list.Add(title)
            if err := list.Save(); err != nil {
                return err
            }
            fmt.Printf("✓ タスク #%d を追加しました: %s\n", todo.ID, todo.Title)
            return nil
        },
    }

    // doneサブコマンド
    var doneCmd = &cobra.Command{
        Use:   "done <タスクID>",
        Short: "タスクを完了としてマーク",
        Args:  cobra.ExactArgs(1),
        RunE: func(cmd *cobra.Command, args []string) error {
            id, err := strconv.Atoi(args[0])
            if err != nil {
                return fmt.Errorf("無効なタスクID: %q", args[0])
            }
            list, err := NewTodoList(dataFile)
            if err != nil {
                return err
            }
            if err := list.Complete(id); err != nil {
                return err
            }
            if err := list.Save(); err != nil {
                return err
            }
            fmt.Printf("✓ タスク #%d が完了しました!\n", id)
            return nil
        },
    }

    // listサブコマンド
    var showAll bool
    var listCmd = &cobra.Command{
        Use:   "list",
        Short: "タスクリストを表示",
        RunE: func(cmd *cobra.Command, args []string) error {
            list, err := NewTodoList(dataFile)
            if err != nil {
                return err
            }

            todos := list.Filter(showAll)
            if len(todos) == 0 {
                if showAll {
                    fmt.Println("タスクはまだありません。")
                } else {
                    fmt.Println("保留中のタスクはありません! -aですべてのタスクを表示できます。")
                }
                return nil
            }

            fmt.Printf("%-4s %-8s %-30s %s\n", "ID", "ステータス", "タイトル", "作成日時")
            fmt.Println(strings.Repeat("─", 60))

            for _, todo := range todos {
                status := "○"
                if todo.Done {
                    status = "✓"
                }
                fmt.Printf("%-4d %-8s %-30s %s\n",
                    todo.ID,
                    status,
                    truncate(todo.Title, 28),
                    todo.CreatedAt.Format("01-02 15:04"))
            }

            // 統計
            total := len(list.Todos)
            done := 0
            for _, t := range list.Todos {
                if t.Done {
                    done++
                }
            }
            fmt.Printf("\n合計 %d タスク、完了 %d、保留 %d\n", total, done, total-done)
            return nil
        },
    }
    listCmd.Flags().BoolVarP(&showAll, "all", "a", false, "すべてのタスクを表示(完了済みを含む)")

    // deleteサブコマンド
    var deleteCmd = &cobra.Command{
        Use:   "delete <タスクID>",
        Short: "タスクを削除",
        Args:  cobra.ExactArgs(1),
        RunE: func(cmd *cobra.Command, args []string) error {
            id, err := strconv.Atoi(args[0])
            if err != nil {
                return fmt.Errorf("無効なタスクID: %q", args[0])
            }
            list, err := NewTodoList(dataFile)
            if err != nil {
                return err
            }
            if err := list.Delete(id); err != nil {
                return err
            }
            if err := list.Save(); err != nil {
                return err
            }
            fmt.Printf("✓ タスク #%d を削除しました\n", id)
            return nil
        },
    }

    // clearサブコマンド
    var clearCmd = &cobra.Command{
        Use:   "clear",
        Short: "完了したタスクをすべてクリア",
        RunE: func(cmd *cobra.Command, args []string) error {
            list, err := NewTodoList(dataFile)
            if err != nil {
                return err
            }

            before := len(list.Todos)
            var remaining []Todo
            for _, todo := range list.Todos {
                if !todo.Done {
                    remaining = append(remaining, todo)
                }
            }
            list.Todos = remaining

            removed := before - len(list.Todos)
            if removed == 0 {
                fmt.Println("クリアする完了済みタスクはありません。")
                return nil
            }

            if err := list.Save(); err != nil {
                return err
            }
            fmt.Printf("✓ %d 件の完了済みタスクをクリアしました\n", removed)
            return nil
        },
    }

    rootCmd.AddCommand(addCmd, doneCmd, listCmd, deleteCmd, clearCmd)

    if err := rootCmd.Execute(); err != nil {
        os.Exit(1)
    }
}

// truncateは文字列を切り詰める
func truncate(s string, maxLen int) string {
    runes := []rune(s)
    if len(runes) <= maxLen {
        return s
    }
    return string(runes[:maxLen-2]) + ".."
}
▶ 試してみよう
BASH
$ todo add Go CLI開発を学ぶ
✓ タスク #1 を追加しました: Go CLI開発を学ぶ

$ todo add 演習を完了する
✓ タスク #2 を追加しました: 演習を完了する

$ todo add 技術ブログを書く
✓ タスク #3 を追加しました: 技術ブログを書く

$ todo list
ID   ステータス タイトル                       作成日時
────────────────────────────────────────────────────────────
1    ○        Go CLI開発を学ぶ               06-27 14:30
2    ○        演習を完了する                  06-27 14:31
3    ○        技術ブログを書く                06-27 14:32

合計 3 タスク、完了 0、保留 3

$ todo done 1
✓ タスク #1 が完了しました!

$ todo list -a
ID   ステータス タイトル                       作成日時
────────────────────────────────────────────────────────────
1    ✓        Go CLI開発を学ぶ               06-27 14:30
2    ○        演習を完了する                  06-27 14:31
3    ○        技術ブログを書く                06-27 14:32

合計 3 タスク、完了 1、保留 2

$ todo clear
✓ 1 件の完了済みタスクをクリアしました

実世界のシナリオ

シナリオ1:引数バリデーションとカスタムバリデーション

GO
package main

import (
    "fmt"
    "net"
    "os"
    "strconv"
    "strings"

    "github.com/spf13/cobra"
)

// PortValidatorはポート番号を検証する
func PortValidator(s string) error {
    port, err := strconv.Atoi(s)
    if err != nil {
        return fmt.Errorf("ポートは数値である必要があります。入力値: %q", s)
    }
    if port < 1 || port > 65535 {
        return fmt.Errorf("ポートは1〜65535の範囲内である必要があります。入力値: %d", port)
    }
    return nil
}

// EmailValidatorはシンプルなメール形式バリデーション
func EmailValidator(email string) error {
    if !strings.Contains(email, "@") {
        return fmt.Errorf("無効なメール形式です。@記号がありません: %q", email)
    }
    parts := strings.SplitN(email, "@", 2)
    if len(parts[0]) == 0 || len(parts[1]) == 0 {
        return fmt.Errorf("無効なメール形式です: %q", email)
    }
    if !strings.Contains(parts[1], ".") {
        return fmt.Errorf("無効なメールドメイン形式です: %q", email)
    }
    return nil
}

// HostPortValidatorはhost:port形式を検証する
func HostPortValidator(addr string) error {
    host, port, err := net.SplitHostPort(addr)
    if err != nil {
        return fmt.Errorf("形式はhost:portである必要があります。入力値: %q、エラー: %v", addr, err)
    }
    if host == "" {
        return fmt.Errorf("ホスト名は空にできません: %q", addr)
    }
    return PortValidator(port)
}

func main() {
    var email string
    var port int
    var serverAddr string

    rootCmd := &cobra.Command{
        Use:   "server",
        Short: "サーバーを起動",
        PreRunE: func(cmd *cobra.Command, args []string) error {
            // 実行前にすべての引数を検証
            if err := EmailValidator(email); err != nil {
                return fmt.Errorf("管理者メール: %w", err)
            }
            if err := HostPortValidator(serverAddr); err != nil {
                return fmt.Errorf("サーバーアドレス: %w", err)
            }
            return nil
        },
        Run: func(cmd *cobra.Command, args []string) {
            fmt.Printf("サーバーを起動中...\n")
            fmt.Printf("  アドレス: %s\n", serverAddr)
            fmt.Printf("  管理者: %s\n", email)
            fmt.Printf("  ポート: %d\n", port)
        },
    }

    rootCmd.Flags().StringVarP(&email, "email", "e", "admin@example.com", "管理者メール")
    rootCmd.Flags().IntVarP(&port, "port", "p", 8080, "ポート番号")
    rootCmd.Flags().StringVarP(&serverAddr, "addr", "a", "localhost:8080", "サーバーアドレス(host:port)")

    if err := rootCmd.Execute(); err != nil {
        fmt.Fprintf(os.Stderr, "エラー: %v\n", err)
        os.Exit(1)
    }
}
BASH
$ go run main.go --addr "invalid"
エラー: サーバーアドレス: 形式はhost:portである必要があります。入力値: "invalid"、エラー: address invalid: missing port in address

$ go run main.go --email "not-email"
エラー: 管理者メール: 無効なメール形式です。@記号がありません: "not-email"

$ go run main.go -a "0.0.0.0:9090" -e "admin@mysite.com"
サーバーを起動中...
  アドレス: 0.0.0.0:9090
  管理者: admin@mysite.com
  ポート: 8080

シナリオ2:プログレスバーとカラー出力

GO
package main

import (
    "fmt"
    "math"
    "os"
    "strings"
    "time"

    "github.com/spf13/cobra"
)

// ANSIカラーコード
const (
    ColorReset  = "\033[0m"
    ColorRed    = "\033[31m"
    ColorGreen  = "\033[32m"
    ColorYellow = "\033[33m"
    ColorBlue   = "\033[34m"
    ColorCyan   = "\033[36m"
    ColorBold   = "\033[1m"
)

// ProgressBarはプログレスバー
type ProgressBar struct {
    total   int
    current int
    width   int
    label   string
}

// NewProgressBarはプログレスバーを作成する
func NewProgressBar(total int, label string) *ProgressBar {
    return &ProgressBar{
        total: total,
        width: 40,
        label: label,
    }
}

// Updateは進捗を更新して表示する
func (p *ProgressBar) Update(current int) {
    p.current = current
    percent := float64(p.current) / float64(p.total)
    filled := int(math.Round(percent * float64(p.width)))

    bar := strings.Repeat("█", filled) + strings.Repeat("░", p.width-filled)

    // 進捗に応じて色を変更
    color := ColorYellow
    if percent >= 0.8 {
        color = ColorGreen
    } else if percent >= 0.5 {
        color = ColorCyan
    }

    fmt.Fprintf(os.Stderr, "\r%s %s[%s%s%s] %s%d/%d%s (%.0f%%)",
        p.label,
        ColorBold,
        color, bar, ColorReset,
        ColorBold, p.current, p.total, ColorReset,
        percent*100)

    if p.current >= p.total {
        fmt.Fprintf(os.Stderr, "\n")
    }
}

func main() {
    rootCmd := &cobra.Command{
        Use:   "downloader",
        Short: "シミュレートされたファイルダウンローダー(プログレスバーのデモ)",
        Run: func(cmd *cobra.Command, args []string) {
            files := []string{
                "go1.21.0.linux-amd64.tar.gz",
                "docs.tar.gz",
                "examples.zip",
            }

            totalSteps := 100

            for _, file := range files {
                fmt.Printf("\n%sダウンロード中: %s%s\n", ColorBlue, file, ColorReset)

                bar := NewProgressBar(totalSteps, "  進捗")

                for i := 0; i <= totalSteps; i++ {
                    bar.Update(i)
                    // ダウンロード遅延をシミュレート
                    time.Sleep(20 * time.Millisecond)
                }

                fmt.Printf("  %s✓ ダウンロード完了%s\n", ColorGreen, ColorReset)
            }

            fmt.Printf("\n%s%sすべてのダウンロードが完了しました!%s\n", ColorBold, ColorGreen, ColorReset)
        },
    }

    if err := rootCmd.Execute(); err != nil {
        os.Exit(1)
    }
}
BASH
$ go run main.go

ダウンロード中: go1.21.0.linux-amd64.tar.gz
  進捗 [████████████████████████████████████████] 100/100 (100%)
  ✓ ダウンロード完了

ダウンロード中: docs.tar.gz
  進捗 [████████████████████████████████████████] 100/100 (100%)
  ✓ ダウンロード完了

すべてのダウンロードが完了しました!

❓ よくある質問

質問1:flagパッケージは重複したフラグ名をどう処理しますか?

GO
// flagパッケージは重複したフラグ名の定義を許可しません。パニックが発生します
// ただし、異なるサブコマンドで同じ名前のフラグを定義できます(サブコマンドロジックを自分で実装している場合)

// cobraを使用する場合、各コマンドのFlagsは独立しており、競合しません
cmd1.Flags().StringVar(&name, "name", "", "command1の名前")
cmd2.Flags().StringVar(&name, "name", "", "command2の名前") // 完全にOK

質問2:cobraで永続的な設定ファイル(~/.config/myapp.yamlなど)をサポートするには?

GO
import (
    "github.com/spf13/cobra"
    "github.com/spf13/viper"
)

func initConfig() {
    viper.SetConfigName("config")  // 設定ファイル名(拡張子なし)
    viper.SetConfigType("yaml")
    viper.AddConfigPath("$HOME/.config/myapp")
    viper.AddConfigPath(".")

    // 環境変数のオーバーライド
    viper.AutomaticEnv()

    // 設定ファイルを読み込む(存在しないことを許可)
    if err := viper.ReadInConfig(); err == nil {
        fmt.Fprintln(os.Stderr, "設定ファイルを使用:", viper.ConfigFileUsed())
    }
}

func main() {
    rootCmd := &cobra.Command{
        Use: "myapp",
        PersistentPreRun: func(cmd *cobra.Command, args []string) {
            initConfig()
        },
    }
    // ...
}
💡 ヒント: viperはcobraの最良のパートナーで、設定ファイル、環境変数、コマンドラインフラグの統一的な読み取りを提供します。優先順位:コマンドラインフラグ > 環境変数 > 設定ファイル > デフォルト値。

質問3:os.Argsflag.Args()の違いは何ですか?

GO
// os.Argsはすべての生の引数を含み、プログラム名も含まれる
os.Args     // ["myapp", "-v", "hello", "world"]

// flag.Parse()の後:
// flag.Args()はフラグ以外の引数(位置引数)のみを含む
flag.Args() // ["hello", "world"]

// flagは-vを消費したため、Args()には現れない

質問4:サブコマンドのTab補完を処理するには?

BASH
# cobraには組み込みのシェル補完生成がある
$ todo completion bash > /etc/bash_completion.d/todo
$ todo completion zsh > "${fpath[1]}/_todo"
$ todo completion fish > ~/.config/fish/completions/todo.fish

# カスタムフラグには補完関数を登録できる
cmd.RegisterFlagCompletionFunc("format", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
    return []string{"json", "yaml", "table"}, cobra.ShellCompDirectiveNoFileComp
})

📖 まとめ

このレッスンではGo CLIツール開発のコアコンテンツを学びました:

トピック ツール ポイント
生の引数 os.Args 文字列スライス、[0]はプログラム名
フラグ解析 flagパッケージ 型付きフラグ、自動ヘルプ、位置引数
CLIフレームワーク cobra サブコマンド、永続フラグ、自動補完
引数バリデーション カスタム関数 PreRunE/Argsバリデーター
インタラクティブ入力 bufio/term 行入力読み取り、非表示パスワード入力

重要なポイント


📝 演習

演習1:環境変数ビューア

以下の機能をサポートするCLIツールenvtoolを作成してください:

BASH
# すべての環境変数を一覧表示
$ envtool list

# キーワードを含む環境変数を検索
$ envtool search PATH

# 特定の環境変数の値を取得
$ envtool get GOPATH

# 環境変数を設定(現在のプロセスのみ)
$ envtool set MY_VAR=hello

要件:cobraを使用して実装し、--format=json|tableフラグで出力形式を制御できるようにしてください。

演習2:パスワードジェネレーター

CLIパスワードジェネレーターpassgenを作成してください:

BASH
# デフォルトパスワードを生成(16文字、大文字・小文字・数字を含む)
$ passgen

# 長さと文字セットを指定
$ passgen -l 32 -s "abc123!@#"

# バッチ生成
$ passgen -n 5

# 曖昧な文字を除外(0/O、1/l/I)
$ passgen --no-ambiguous

要件:

演習3:バッチファイル処理ツール

バッチファイル操作をサポートするbatchツールを作成してください:

BASH
# バッチリネーム:プレフィックスを追加
$ batch rename --prefix "2025_" *.jpg

# バッチ大文字小文字変換
$ batch case --to lower *.TXT

# バッチ統計
$ batch stats ./docs

# バッチ検索と置換(シミュレーション)
$ batch replace --old "foo" --new "bar" *.go

要件:cobraサブコマンドを使用して実装し、各操作を独立したサブコマンドとしてください。


次のレッスン

次のレッスンではREST API開発を学びます。GoでRESTful APIサービスを構築する方法について、ルート設計、ミドルウェア、JSON処理、データベース統合などの実践的なスキルをカバーします。

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%