CLI開発 - 解答例

概要

本解答例では、Cobraライブラリを使用した本格的なCLIツール「filetool」を実装します。このツールは、以下の機能を提供します:

  • list: ファイル一覧の表示(再帰、JSON出力対応)
  • search: ファイル検索(正規表現、再帰対応)
  • stats: ディレクトリ統計情報
  • watch: ファイル監視(リアルタイム更新)
  • compress: 複数ファイルの圧縮
  • プロジェクト構成

    filetool/
    ├── main.go                 # エントリーポイント
    ├── cmd/
    │   ├── root.go            # ルートコマンド
    │   ├── list.go            # listコマンド
    │   ├── search.go          # searchコマンド
    │   ├── stats.go           # statsコマンド
    │   ├── watch.go           # watchコマンド
    │   └── compress.go        # compressコマンド
    ├── internal/
    │   ├── config/
    │   │   └── config.go      # 設定管理
    │   ├── scanner/
    │   │   └── scanner.go     # ファイルスキャン
    │   └── formatter/
    │       └── formatter.go   # 出力フォーマット
    ├── config.yaml            # 設定ファイル
    └── go.mod
    

    依存関係のインストール

    go mod init github.com/yourname/filetool
    go get github.com/spf13/cobra@latest
    go get github.com/spf13/viper@latest
    go get github.com/fatih/color@latest
    go get github.com/schollz/progressbar/v3@latest
    go get github.com/fsnotify/fsnotify@latest
    go get github.com/manifoldco/promptui@latest
    

    main.go - エントリーポイント

    package main
    
    import (
    	"github.com/yourname/filetool/cmd"
    )
    
    // バージョン情報(ビルド時に埋め込まれる)
    var (
    	version = "dev"
    	commit  = "none"
    	date    = "unknown"
    )
    
    func main() {
    	cmd.Execute(version, commit, date)
    }
    

    cmd/root.go - ルートコマンド

    package cmd
    
    import (
    	"fmt"
    	"os"
    
    	"github.com/spf13/cobra"
    	"github.com/spf13/viper"
    )
    
    var (
    	cfgFile string
    	verbose bool
    )
    
    // ルートコマンド
    var rootCmd = &cobra.Command{
    	Use:   "filetool",
    	Short: "A powerful file management CLI tool",
    	Long: `filetool is a comprehensive file management tool that provides
    various operations on files and directories including listing,
    searching, statistics, watching, and compression.
    
    Built with Go and Cobra for maximum performance and usability.`,
    	Example: `  # List files in current directory
      filetool list
    
      # Search for Go files recursively
      filetool search -r "\.go$"
    
      # Show directory statistics
      filetool stats --human-readable
    
      # Watch directory for changes
      filetool watch /path/to/dir`,
    }
    
    // Execute は全てのサブコマンドを追加し、フラグを適切に設定します
    func Execute(version, commit, date string) {
    	// バージョン情報を設定
    	rootCmd.Version = fmt.Sprintf("%s (commit: %s, built at: %s)", version, commit, date)
    
    	if err := rootCmd.Execute(); err != nil {
    		os.Exit(1)
    	}
    }
    
    func init() {
    	cobra.OnInitialize(initConfig)
    
    	// グローバルフラグ
    	rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "",
    		"config file (default is $HOME/.filetool.yaml)")
    	rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false,
    		"verbose output")
    
    	// サブコマンドを追加
    	rootCmd.AddCommand(listCmd)
    	rootCmd.AddCommand(searchCmd)
    	rootCmd.AddCommand(statsCmd)
    	rootCmd.AddCommand(watchCmd)
    	rootCmd.AddCommand(compressCmd)
    }
    
    // initConfig は設定ファイルと環境変数を読み込みます
    func initConfig() {
    	if cfgFile != "" {
    		// フラグで指定された設定ファイルを使用
    		viper.SetConfigFile(cfgFile)
    	} else {
    		// ホームディレクトリを取得
    		home, err := os.UserHomeDir()
    		if err != nil {
    			fmt.Fprintln(os.Stderr, err)
    			os.Exit(1)
    		}
    
    		// ホームディレクトリとカレントディレクトリから設定を探す
    		viper.AddConfigPath(home)
    		viper.AddConfigPath(".")
    		viper.SetConfigType("yaml")
    		viper.SetConfigName(".filetool")
    	}
    
    	// 環境変数を読み込む (FILETOOL_で始まる)
    	viper.SetEnvPrefix("FILETOOL")
    	viper.AutomaticEnv()
    
    	// 設定ファイルを読み込む
    	if err := viper.ReadInConfig(); err == nil && verbose {
    		fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed())
    	}
    }
    

    cmd/list.go - ファイル一覧表示

    package cmd
    
    import (
    	"encoding/json"
    	"fmt"
    	"io/fs"
    	"os"
    	"path/filepath"
    	"sort"
    	"strings"
    	"time"
    
    	"github.com/fatih/color"
    	"github.com/spf13/cobra"
    )
    
    type FileInfo struct {
    	Name     string    `json:"name"`
    	Path     string    `json:"path"`
    	Size     int64     `json:"size"`
    	Mode     string    `json:"mode"`
    	ModTime  time.Time `json:"mod_time"`
    	IsDir    bool      `json:"is_dir"`
    }
    
    var (
    	listRecursive bool
    	listOutput    string
    	listAll       bool
    	listLong      bool
    	listSortBy    string
    )
    
    var listCmd = &cobra.Command{
    	Use:   "list [path]",
    	Short: "List files in directory",
    	Long: `List files and directories with various output formats and options.
    
    Supports recursive listing, different output formats (text/json),
    hidden files display, and sorting options.`,
    	Example: `  # List current directory
      filetool list
    
      # List recursively with details
      filetool list -r -l
    
      # List with JSON output
      filetool list -o json
    
      # List all files including hidden
      filetool list -a
    
      # Sort by size
      filetool list --sort size`,
    	Args: cobra.MaximumNArgs(1),
    	RunE: runList,
    }
    
    func init() {
    	listCmd.Flags().BoolVarP(&listRecursive, "recursive", "r", false,
    		"list directories recursively")
    	listCmd.Flags().StringVarP(&listOutput, "output", "o", "text",
    		"output format (text|json)")
    	listCmd.Flags().BoolVarP(&listAll, "all", "a", false,
    		"show hidden files")
    	listCmd.Flags().BoolVarP(&listLong, "long", "l", false,
    		"use long listing format")
    	listCmd.Flags().StringVar(&listSortBy, "sort", "name",
    		"sort by (name|size|time)")
    }
    
    func runList(cmd *cobra.Command, args []string) error {
    	path := "."
    	if len(args) > 0 {
    		path = args[0]
    	}
    
    	// パスの存在確認
    	info, err := os.Stat(path)
    	if err != nil {
    		return fmt.Errorf("cannot access '%s': %w", path, err)
    	}
    
    	if !info.IsDir() {
    		return fmt.Errorf("'%s' is not a directory", path)
    	}
    
    	var files []FileInfo
    
    	if listRecursive {
    		err = filepath.WalkDir(path, func(p string, d fs.DirEntry, err error) error {
    			if err != nil {
    				return err
    			}
    
    			// 隠しファイルをスキップ
    			if !listAll && strings.HasPrefix(d.Name(), ".") && p != path {
    				if d.IsDir() {
    					return filepath.SkipDir
    				}
    				return nil
    			}
    
    			info, err := d.Info()
    			if err != nil {
    				return nil // エラーを無視して継続
    			}
    
    			files = append(files, FileInfo{
    				Name:    d.Name(),
    				Path:    p,
    				Size:    info.Size(),
    				Mode:    info.Mode().String(),
    				ModTime: info.ModTime(),
    				IsDir:   d.IsDir(),
    			})
    
    			return nil
    		})
    	} else {
    		entries, err := os.ReadDir(path)
    		if err != nil {
    			return fmt.Errorf("cannot read directory '%s': %w", path, err)
    		}
    
    		for _, entry := range entries {
    			// 隠しファイルをスキップ
    			if !listAll && strings.HasPrefix(entry.Name(), ".") {
    				continue
    			}
    
    			info, err := entry.Info()
    			if err != nil {
    				continue // エラーを無視して継続
    			}
    
    			fullPath := filepath.Join(path, entry.Name())
    			files = append(files, FileInfo{
    				Name:    entry.Name(),
    				Path:    fullPath,
    				Size:    info.Size(),
    				Mode:    info.Mode().String(),
    				ModTime: info.ModTime(),
    				IsDir:   entry.IsDir(),
    			})
    		}
    	}
    
    	if err != nil {
    		return err
    	}
    
    	// ソート
    	sortFiles(files, listSortBy)
    
    	// 出力
    	switch listOutput {
    	case "json":
    		return outputJSON(files)
    	case "text":
    		return outputText(files, listLong)
    	default:
    		return fmt.Errorf("unknown output format: %s", listOutput)
    	}
    }
    
    func sortFiles(files []FileInfo, sortBy string) {
    	sort.Slice(files, func(i, j int) bool {
    		switch sortBy {
    		case "size":
    			return files[i].Size > files[j].Size
    		case "time":
    			return files[i].ModTime.After(files[j].ModTime)
    		default: // name
    			return files[i].Name < files[j].Name
    		}
    	})
    }
    
    func outputJSON(files []FileInfo) error {
    	encoder := json.NewEncoder(os.Stdout)
    	encoder.SetIndent("", "  ")
    	return encoder.Encode(files)
    }
    
    func outputText(files []FileInfo, long bool) error {
    	// カラー設定
    	dirColor := color.New(color.FgBlue, color.Bold)
    	fileColor := color.New(color.FgWhite)
    	sizeColor := color.New(color.FgYellow)
    	timeColor := color.New(color.FgCyan)
    
    	for _, file := range files {
    		if long {
    			// 詳細表示
    			fmt.Printf("%s  %8s  %s  ",
    				file.Mode,
    				formatSize(file.Size),
    				file.ModTime.Format("2006-01-02 15:04:05"),
    			)
    		}
    
    		// ファイル名の表示
    		if file.IsDir {
    			dirColor.Printf("%s/\n", file.Name)
    		} else {
    			fileColor.Println(file.Name)
    		}
    	}
    
    	if verbose {
    		fmt.Fprintf(os.Stderr, "\nTotal: %d items\n", len(files))
    	}
    
    	return nil
    }
    
    func formatSize(size int64) string {
    	const unit = 1024
    	if size < unit {
    		return fmt.Sprintf("%d B", size)
    	}
    	div, exp := int64(unit), 0
    	for n := size / unit; n >= unit; n /= unit {
    		div *= unit
    		exp++
    	}
    	return fmt.Sprintf("%.1f %cB", float64(size)/float64(div), "KMGTPE"[exp])
    }
    

    cmd/search.go - ファイル検索

    package cmd
    
    import (
    	"fmt"
    	"io/fs"
    	"os"
    	"path/filepath"
    	"regexp"
    	"strings"
    
    	"github.com/fatih/color"
    	"github.com/schollz/progressbar/v3"
    	"github.com/spf13/cobra"
    )
    
    var (
    	searchRecursive   bool
    	searchIgnoreCase  bool
    	searchInvert      bool
    	searchType        string
    	searchMaxDepth    int
    	searchShowProgress bool
    )
    
    var searchCmd = &cobra.Command{
    	Use:   "search <pattern> [path]",
    	Short: "Search files by pattern",
    	Long: `Search for files matching a regular expression pattern.
    
    Supports recursive search, case-insensitive matching,
    inverted matching, and file type filtering.`,
    	Example: `  # Search for Go files
      filetool search "\.go$"
    
      # Case-insensitive search
      filetool search -i "readme"
    
      # Search only directories
      filetool search -r --type d "test"
    
      # Inverted match (exclude pattern)
      filetool search -v "\.git"
    
      # Search with max depth
      filetool search -r --max-depth 3 "main"`,
    	Args: cobra.RangeArgs(1, 2),
    	RunE: runSearch,
    }
    
    func init() {
    	searchCmd.Flags().BoolVarP(&searchRecursive, "recursive", "r", false,
    		"search recursively")
    	searchCmd.Flags().BoolVarP(&searchIgnoreCase, "ignore-case", "i", false,
    		"case-insensitive search")
    	searchCmd.Flags().BoolVarP(&searchInvert, "invert-match", "v", false,
    		"invert match (exclude pattern)")
    	searchCmd.Flags().StringVar(&searchType, "type", "",
    		"file type (f=file, d=directory)")
    	searchCmd.Flags().IntVar(&searchMaxDepth, "max-depth", -1,
    		"maximum search depth (-1 for unlimited)")
    	searchCmd.Flags().BoolVar(&searchShowProgress, "progress", false,
    		"show progress bar")
    }
    
    func runSearch(cmd *cobra.Command, args []string) error {
    	pattern := args[0]
    	path := "."
    	if len(args) > 1 {
    		path = args[1]
    	}
    
    	// 正規表現のコンパイル
    	var re *regexp.Regexp
    	var err error
    	if searchIgnoreCase {
    		re, err = regexp.Compile("(?i)" + pattern)
    	} else {
    		re, err = regexp.Compile(pattern)
    	}
    	if err != nil {
    		return fmt.Errorf("invalid pattern '%s': %w", pattern, err)
    	}
    
    	// パスの存在確認
    	if _, err := os.Stat(path); err != nil {
    		return fmt.Errorf("cannot access '%s': %w", path, err)
    	}
    
    	var matches []string
    	var totalScanned int
    
    	// プログレスバーの初期化(オプション)
    	var bar *progressbar.ProgressBar
    	if searchShowProgress {
    		bar = progressbar.NewOptions(-1,
    			progressbar.OptionSetDescription("Searching..."),
    			progressbar.OptionSetWriter(os.Stderr),
    			progressbar.OptionShowCount(),
    			progressbar.OptionSpinnerType(14),
    		)
    	}
    
    	// ファイルの検索
    	err = filepath.WalkDir(path, func(p string, d fs.DirEntry, err error) error {
    		if err != nil {
    			return nil // エラーを無視して継続
    		}
    
    		totalScanned++
    		if bar != nil {
    			bar.Add(1)
    		}
    
    		// 深さ制限のチェック
    		if searchMaxDepth >= 0 {
    			depth := strings.Count(strings.TrimPrefix(p, path), string(os.PathSeparator))
    			if depth > searchMaxDepth {
    				if d.IsDir() {
    					return filepath.SkipDir
    				}
    				return nil
    			}
    		}
    
    		// タイプフィルタ
    		switch searchType {
    		case "f":
    			if d.IsDir() {
    				return nil
    			}
    		case "d":
    			if !d.IsDir() {
    				return nil
    			}
    		}
    
    		// パターンマッチ
    		matched := re.MatchString(d.Name())
    		if searchInvert {
    			matched = !matched
    		}
    
    		if matched {
    			matches = append(matches, p)
    		}
    
    		return nil
    	})
    
    	if bar != nil {
    		bar.Finish()
    		fmt.Fprintln(os.Stderr)
    	}
    
    	if err != nil {
    		return err
    	}
    
    	// 結果の表示
    	highlightColor := color.New(color.FgGreen, color.Bold)
    	for _, match := range matches {
    		// マッチした部分をハイライト
    		highlighted := re.ReplaceAllStringFunc(match, func(s string) string {
    			return highlightColor.Sprint(s)
    		})
    		fmt.Println(highlighted)
    	}
    
    	if verbose {
    		fmt.Fprintf(os.Stderr, "\nFound %d matches (scanned %d items)\n",
    			len(matches), totalScanned)
    	}
    
    	return nil
    }
    

    cmd/stats.go - ディレクトリ統計

    package cmd
    
    import (
    	"fmt"
    	"io/fs"
    	"os"
    	"path/filepath"
    	"sort"
    	"sync"
    	"sync/atomic"
    
    	"github.com/fatih/color"
    	"github.com/schollz/progressbar/v3"
    	"github.com/spf13/cobra"
    )
    
    var (
    	statsHuman      bool
    	statsExtensions bool
    	statsConcurrent bool
    )
    
    type Stats struct {
    	TotalFiles int64
    	TotalDirs  int64
    	TotalSize  int64
    	ExtStats   map[string]*ExtStat
    	mu         sync.Mutex
    }
    
    type ExtStat struct {
    	Count int64
    	Size  int64
    }
    
    var statsCmd = &cobra.Command{
    	Use:   "stats [path]",
    	Short: "Show directory statistics",
    	Long: `Calculate and display directory statistics including
    file count, directory count, total size, and statistics by file extension.
    
    Supports concurrent processing for better performance on large directories.`,
    	Example: `  # Show basic statistics
      filetool stats
    
      # Show with human-readable sizes
      filetool stats -H
    
      # Show extension breakdown
      filetool stats --extensions
    
      # Use concurrent processing
      filetool stats --concurrent`,
    	Args: cobra.MaximumNArgs(1),
    	RunE: runStats,
    }
    
    func init() {
    	statsCmd.Flags().BoolVarP(&statsHuman, "human-readable", "H", false,
    		"print sizes in human readable format")
    	statsCmd.Flags().BoolVar(&statsExtensions, "extensions", false,
    		"show statistics by file extension")
    	statsCmd.Flags().BoolVar(&statsConcurrent, "concurrent", false,
    		"use concurrent processing")
    }
    
    func runStats(cmd *cobra.Command, args []string) error {
    	path := "."
    	if len(args) > 0 {
    		path = args[0]
    	}
    
    	// パスの存在確認
    	info, err := os.Stat(path)
    	if err != nil {
    		return fmt.Errorf("cannot access '%s': %w", path, err)
    	}
    
    	if !info.IsDir() {
    		return fmt.Errorf("'%s' is not a directory", path)
    	}
    
    	stats := &Stats{
    		ExtStats: make(map[string]*ExtStat),
    	}
    
    	// プログレスバー
    	bar := progressbar.NewOptions(-1,
    		progressbar.OptionSetDescription("Analyzing..."),
    		progressbar.OptionSetWriter(os.Stderr),
    		progressbar.OptionShowCount(),
    		progressbar.OptionSpinnerType(14),
    	)
    
    	if statsConcurrent {
    		err = collectStatsConcurrent(path, stats, bar)
    	} else {
    		err = collectStatsSequential(path, stats, bar)
    	}
    
    	bar.Finish()
    	fmt.Fprintln(os.Stderr)
    
    	if err != nil {
    		return err
    	}
    
    	// 結果の表示
    	displayStats(stats)
    
    	return nil
    }
    
    func collectStatsSequential(path string, stats *Stats, bar *progressbar.ProgressBar) error {
    	return filepath.WalkDir(path, func(p string, d fs.DirEntry, err error) error {
    		if err != nil {
    			return nil
    		}
    
    		bar.Add(1)
    
    		if d.IsDir() {
    			atomic.AddInt64(&stats.TotalDirs, 1)
    		} else {
    			info, err := d.Info()
    			if err != nil {
    				return nil
    			}
    
    			atomic.AddInt64(&stats.TotalFiles, 1)
    			atomic.AddInt64(&stats.TotalSize, info.Size())
    
    			// 拡張子別統計
    			if statsExtensions {
    				ext := filepath.Ext(d.Name())
    				if ext == "" {
    					ext = "(no extension)"
    				}
    
    				stats.mu.Lock()
    				if _, ok := stats.ExtStats[ext]; !ok {
    					stats.ExtStats[ext] = &ExtStat{}
    				}
    				stats.ExtStats[ext].Count++
    				stats.ExtStats[ext].Size += info.Size()
    				stats.mu.Unlock()
    			}
    		}
    
    		return nil
    	})
    }
    
    func collectStatsConcurrent(path string, stats *Stats, bar *progressbar.ProgressBar) error {
    	// セマフォで同時実行数を制限
    	sem := make(chan struct{}, 10)
    	var wg sync.WaitGroup
    
    	err := filepath.WalkDir(path, func(p string, d fs.DirEntry, err error) error {
    		if err != nil {
    			return nil
    		}
    
    		bar.Add(1)
    
    		if d.IsDir() {
    			atomic.AddInt64(&stats.TotalDirs, 1)
    			return nil
    		}
    
    		wg.Add(1)
    		sem <- struct{}{}
    
    		go func(entry fs.DirEntry) {
    			defer wg.Done()
    			defer func() { <-sem }()
    
    			info, err := entry.Info()
    			if err != nil {
    				return
    			}
    
    			atomic.AddInt64(&stats.TotalFiles, 1)
    			atomic.AddInt64(&stats.TotalSize, info.Size())
    
    			if statsExtensions {
    				ext := filepath.Ext(entry.Name())
    				if ext == "" {
    					ext = "(no extension)"
    				}
    
    				stats.mu.Lock()
    				if _, ok := stats.ExtStats[ext]; !ok {
    					stats.ExtStats[ext] = &ExtStat{}
    				}
    				stats.ExtStats[ext].Count++
    				stats.ExtStats[ext].Size += info.Size()
    				stats.mu.Unlock()
    			}
    		}(d)
    
    		return nil
    	})
    
    	wg.Wait()
    	return err
    }
    
    func displayStats(stats *Stats) {
    	headerColor := color.New(color.FgCyan, color.Bold)
    	labelColor := color.New(color.FgWhite)
    	valueColor := color.New(color.FgGreen, color.Bold)
    
    	headerColor.Println("\n=== Directory Statistics ===")
    
    	// 基本統計
    	fmt.Print(labelColor.Sprint("Files:       "))
    	valueColor.Printf("%d\n", stats.TotalFiles)
    
    	fmt.Print(labelColor.Sprint("Directories: "))
    	valueColor.Printf("%d\n", stats.TotalDirs)
    
    	fmt.Print(labelColor.Sprint("Total size:  "))
    	if statsHuman {
    		valueColor.Printf("%s\n", formatSize(stats.TotalSize))
    	} else {
    		valueColor.Printf("%d bytes\n", stats.TotalSize)
    	}
    
    	// 拡張子別統計
    	if statsExtensions && len(stats.ExtStats) > 0 {
    		headerColor.Println("\n=== Statistics by Extension ===")
    
    		// ソート用のスライス
    		type extEntry struct {
    			ext   string
    			count int64
    			size  int64
    		}
    		var entries []extEntry
    		for ext, stat := range stats.ExtStats {
    			entries = append(entries, extEntry{ext, stat.Count, stat.Size})
    		}
    
    		// サイズでソート
    		sort.Slice(entries, func(i, j int) bool {
    			return entries[i].size > entries[j].size
    		})
    
    		// 上位10件を表示
    		maxDisplay := 10
    		if len(entries) < maxDisplay {
    			maxDisplay = len(entries)
    		}
    
    		for i := 0; i < maxDisplay; i++ {
    			e := entries[i]
    			fmt.Printf("  %-20s  %6d files  ", e.ext, e.count)
    			if statsHuman {
    				fmt.Printf("%s\n", formatSize(e.size))
    			} else {
    				fmt.Printf("%d bytes\n", e.size)
    			}
    		}
    
    		if len(entries) > maxDisplay {
    			fmt.Printf("  ... and %d more extensions\n", len(entries)-maxDisplay)
    		}
    	}
    }
    

    cmd/watch.go - ファイル監視

    package cmd
    
    import (
    	"fmt"
    	"os"
    	"path/filepath"
    	"time"
    
    	"github.com/fatih/color"
    	"github.com/fsnotify/fsnotify"
    	"github.com/spf13/cobra"
    )
    
    var (
    	watchRecursive bool
    	watchEvents    []string
    )
    
    var watchCmd = &cobra.Command{
    	Use:   "watch <path>",
    	Short: "Watch directory for changes",
    	Long: `Monitor a directory for file system changes in real-time.
    
    Displays events for file creation, modification, deletion, and renaming.
    Supports recursive watching and event filtering.`,
    	Example: `  # Watch current directory
      filetool watch .
    
      # Watch recursively
      filetool watch -r /path/to/dir
    
      # Watch only create and delete events
      filetool watch --events create,remove /path/to/dir`,
    	Args: cobra.ExactArgs(1),
    	RunE: runWatch,
    }
    
    func init() {
    	watchCmd.Flags().BoolVarP(&watchRecursive, "recursive", "r", false,
    		"watch recursively")
    	watchCmd.Flags().StringSliceVar(&watchEvents, "events", []string{},
    		"filter events (create,write,remove,rename,chmod)")
    }
    
    func runWatch(cmd *cobra.Command, args []string) error {
    	path := args[0]
    
    	// パスの存在確認
    	info, err := os.Stat(path)
    	if err != nil {
    		return fmt.Errorf("cannot access '%s': %w", path, err)
    	}
    
    	if !info.IsDir() {
    		return fmt.Errorf("'%s' is not a directory", path)
    	}
    
    	// ウォッチャーの作成
    	watcher, err := fsnotify.NewWatcher()
    	if err != nil {
    		return fmt.Errorf("failed to create watcher: %w", err)
    	}
    	defer watcher.Close()
    
    	// イベントフィルタのマップ
    	eventFilter := make(map[string]bool)
    	for _, e := range watchEvents {
    		eventFilter[e] = true
    	}
    
    	// パスを追加
    	if watchRecursive {
    		err = filepath.Walk(path, func(p string, info os.FileInfo, err error) error {
    			if err != nil {
    				return nil
    			}
    			if info.IsDir() {
    				return watcher.Add(p)
    			}
    			return nil
    		})
    	} else {
    		err = watcher.Add(path)
    	}
    
    	if err != nil {
    		return fmt.Errorf("failed to add path to watcher: %w", err)
    	}
    
    	// カラー設定
    	createColor := color.New(color.FgGreen)
    	modifyColor := color.New(color.FgYellow)
    	deleteColor := color.New(color.FgRed)
    	renameColor := color.New(color.FgCyan)
    	chmodColor := color.New(color.FgMagenta)
    
    	fmt.Printf("Watching %s for changes (press Ctrl+C to stop)...\n\n", path)
    
    	// イベントループ
    	for {
    		select {
    		case event, ok := <-watcher.Events:
    			if !ok {
    				return nil
    			}
    
    			// イベントフィルタリング
    			if len(eventFilter) > 0 {
    				shouldDisplay := false
    				if event.Op&fsnotify.Create != 0 && eventFilter["create"] {
    					shouldDisplay = true
    				}
    				if event.Op&fsnotify.Write != 0 && eventFilter["write"] {
    					shouldDisplay = true
    				}
    				if event.Op&fsnotify.Remove != 0 && eventFilter["remove"] {
    					shouldDisplay = true
    				}
    				if event.Op&fsnotify.Rename != 0 && eventFilter["rename"] {
    					shouldDisplay = true
    				}
    				if event.Op&fsnotify.Chmod != 0 && eventFilter["chmod"] {
    					shouldDisplay = true
    				}
    				if !shouldDisplay {
    					continue
    				}
    			}
    
    			// タイムスタンプ
    			timestamp := time.Now().Format("15:04:05")
    
    			// イベントタイプに応じた色付け
    			switch {
    			case event.Op&fsnotify.Create != 0:
    				createColor.Printf("[%s] CREATE: %s\n", timestamp, event.Name)
    			case event.Op&fsnotify.Write != 0:
    				modifyColor.Printf("[%s] MODIFY: %s\n", timestamp, event.Name)
    			case event.Op&fsnotify.Remove != 0:
    				deleteColor.Printf("[%s] DELETE: %s\n", timestamp, event.Name)
    			case event.Op&fsnotify.Rename != 0:
    				renameColor.Printf("[%s] RENAME: %s\n", timestamp, event.Name)
    			case event.Op&fsnotify.Chmod != 0:
    				chmodColor.Printf("[%s] CHMOD:  %s\n", timestamp, event.Name)
    			}
    
    			// 再帰監視で新しいディレクトリが作成された場合
    			if watchRecursive && event.Op&fsnotify.Create != 0 {
    				info, err := os.Stat(event.Name)
    				if err == nil && info.IsDir() {
    					watcher.Add(event.Name)
    					if verbose {
    						fmt.Fprintf(os.Stderr, "Added directory to watch: %s\n", event.Name)
    					}
    				}
    			}
    
    		case err, ok := <-watcher.Errors:
    			if !ok {
    				return nil
    			}
    			fmt.Fprintf(os.Stderr, "Error: %v\n", err)
    		}
    	}
    }
    

    cmd/compress.go - ファイル圧縮

    package cmd
    
    import (
    	"archive/zip"
    	"fmt"
    	"io"
    	"os"
    	"path/filepath"
    	"strings"
    
    	"github.com/manifoldco/promptui"
    	"github.com/schollz/progressbar/v3"
    	"github.com/spf13/cobra"
    )
    
    var (
    	compressOutput      string
    	compressRecursive   bool
    	compressInteractive bool
    )
    
    var compressCmd = &cobra.Command{
    	Use:   "compress <files...>",
    	Short: "Compress files into a zip archive",
    	Long: `Compress one or more files or directories into a zip archive.
    
    Supports recursive compression of directories and interactive
    file selection mode.`,
    	Example: `  # Compress single file
      filetool compress file.txt
    
      # Compress multiple files
      filetool compress file1.txt file2.txt
    
      # Compress directory recursively
      filetool compress -r mydir
    
      # Specify output file
      filetool compress -o archive.zip files/
    
      # Interactive mode
      filetool compress -i`,
    	RunE: runCompress,
    }
    
    func init() {
    	compressCmd.Flags().StringVarP(&compressOutput, "output", "o", "archive.zip",
    		"output archive file")
    	compressCmd.Flags().BoolVarP(&compressRecursive, "recursive", "r", false,
    		"compress directories recursively")
    	compressCmd.Flags().BoolVarP(&compressInteractive, "interactive", "i", false,
    		"interactive file selection")
    }
    
    func runCompress(cmd *cobra.Command, args []string) error {
    	var filesToCompress []string
    
    	if compressInteractive {
    		// インタラクティブモード
    		selected, err := selectFilesInteractive()
    		if err != nil {
    			return err
    		}
    		filesToCompress = selected
    	} else {
    		if len(args) == 0 {
    			return fmt.Errorf("no files specified")
    		}
    		filesToCompress = args
    	}
    
    	if len(filesToCompress) == 0 {
    		fmt.Println("No files selected")
    		return nil
    	}
    
    	// 出力ファイルの作成
    	outFile, err := os.Create(compressOutput)
    	if err != nil {
    		return fmt.Errorf("failed to create output file: %w", err)
    	}
    	defer outFile.Close()
    
    	zipWriter := zip.NewWriter(outFile)
    	defer zipWriter.Close()
    
    	// 処理するファイルの総数をカウント
    	totalFiles := 0
    	for _, file := range filesToCompress {
    		count, err := countFiles(file, compressRecursive)
    		if err != nil {
    			return err
    		}
    		totalFiles += count
    	}
    
    	// プログレスバー
    	bar := progressbar.NewOptions(totalFiles,
    		progressbar.OptionSetDescription("Compressing"),
    		progressbar.OptionSetWriter(os.Stderr),
    		progressbar.OptionShowCount(),
    		progressbar.OptionShowBytes(true),
    		progressbar.OptionSetPredictTime(true),
    	)
    
    	// ファイルを圧縮
    	for _, file := range filesToCompress {
    		if err := addToZip(zipWriter, file, "", compressRecursive, bar); err != nil {
    			return err
    		}
    	}
    
    	bar.Finish()
    	fmt.Fprintf(os.Stderr, "\n")
    
    	info, _ := outFile.Stat()
    	fmt.Printf("Successfully created %s (%s)\n",
    		compressOutput, formatSize(info.Size()))
    
    	return nil
    }
    
    func selectFilesInteractive() ([]string, error) {
    	// カレントディレクトリのファイル一覧を取得
    	entries, err := os.ReadDir(".")
    	if err != nil {
    		return nil, err
    	}
    
    	var items []string
    	for _, entry := range entries {
    		if strings.HasPrefix(entry.Name(), ".") {
    			continue
    		}
    		name := entry.Name()
    		if entry.IsDir() {
    			name += "/"
    		}
    		items = append(items, name)
    	}
    
    	// プロンプトUI
    	prompt := promptui.Select{
    		Label: "Select files to compress (use arrow keys, press Enter when done)",
    		Items: append(items, "[Done]"),
    		Size:  10,
    	}
    
    	var selected []string
    	for {
    		_, result, err := prompt.Run()
    		if err != nil {
    			return nil, err
    		}
    
    		if result == "[Done]" {
    			break
    		}
    
    		selected = append(selected, strings.TrimSuffix(result, "/"))
    	}
    
    	return selected, nil
    }
    
    func countFiles(path string, recursive bool) (int, error) {
    	info, err := os.Stat(path)
    	if err != nil {
    		return 0, err
    	}
    
    	if !info.IsDir() {
    		return 1, nil
    	}
    
    	if !recursive {
    		return 1, nil
    	}
    
    	count := 0
    	err = filepath.Walk(path, func(p string, info os.FileInfo, err error) error {
    		if err != nil {
    			return nil
    		}
    		if !info.IsDir() {
    			count++
    		}
    		return nil
    	})
    
    	return count, err
    }
    
    func addToZip(zipWriter *zip.Writer, path, baseDir string, recursive bool, bar *progressbar.ProgressBar) error {
    	info, err := os.Stat(path)
    	if err != nil {
    		return err
    	}
    
    	if !info.IsDir() {
    		return addFileToZip(zipWriter, path, baseDir, bar)
    	}
    
    	if !recursive {
    		return nil
    	}
    
    	// ディレクトリを再帰的に処理
    	return filepath.Walk(path, func(p string, info os.FileInfo, err error) error {
    		if err != nil {
    			return nil
    		}
    
    		if info.IsDir() {
    			return nil
    		}
    
    		relPath, err := filepath.Rel(filepath.Dir(path), p)
    		if err != nil {
    			return err
    		}
    
    		return addFileToZip(zipWriter, p, filepath.Join(baseDir, filepath.Dir(relPath)), bar)
    	})
    }
    
    func addFileToZip(zipWriter *zip.Writer, path, baseDir string, bar *progressbar.ProgressBar) error {
    	file, err := os.Open(path)
    	if err != nil {
    		return err
    	}
    	defer file.Close()
    
    	info, err := file.Stat()
    	if err != nil {
    		return err
    	}
    
    	header, err := zip.FileInfoHeader(info)
    	if err != nil {
    		return err
    	}
    
    	// ZIP内のパスを設定
    	if baseDir != "" {
    		header.Name = filepath.Join(baseDir, filepath.Base(path))
    	} else {
    		header.Name = filepath.Base(path)
    	}
    
    	// 圧縮方法を設定
    	header.Method = zip.Deflate
    
    	writer, err := zipWriter.CreateHeader(header)
    	if err != nil {
    		return err
    	}
    
    	_, err = io.Copy(writer, file)
    	bar.Add(1)
    	return err
    }
    

    config.yaml - 設定ファイル例

    # filetool configuration file
    
    # デフォルトの出力形式
    output:
      format: text  # text | json
      color: true   # カラー出力を有効化
    
    # list コマンドのデフォルト設定
    list:
      show_hidden: false
      long_format: false
      sort_by: name  # name | size | time
    
    # search コマンドのデフォルト設定
    search:
      ignore_case: false
      max_depth: -1
      show_progress: false
    
    # stats コマンドのデフォルト設定
    stats:
      human_readable: true
      show_extensions: true
      concurrent: true
    
    # watch コマンドのデフォルト設定
    watch:
      recursive: true
      events:
        - create
        - write
        - remove
    
    # compress コマンドのデフォルト設定
    compress:
      default_output: archive.zip
      compression_level: 9  # 0-9
    
    # 除外パターン
    exclude:
      - ".git"
      - ".DS_Store"
      - "node_modules"
      - "*.swp"
    

    テストコード例

    // cmd/list_test.go
    package cmd
    
    import (
    	"bytes"
    	"os"
    	"path/filepath"
    	"testing"
    
    	"github.com/stretchr/testify/assert"
    )
    
    func TestListCommand(t *testing.T) {
    	// テスト用の一時ディレクトリを作成
    	tmpDir, err := os.MkdirTemp("", "filetool-test")
    	assert.NoError(t, err)
    	defer os.RemoveAll(tmpDir)
    
    	// テストファイルを作成
    	testFiles := []string{"file1.txt", "file2.go", "file3.md"}
    	for _, f := range testFiles {
    		path := filepath.Join(tmpDir, f)
    		err := os.WriteFile(path, []byte("test"), 0644)
    		assert.NoError(t, err)
    	}
    
    	// コマンド実行
    	buf := new(bytes.Buffer)
    	rootCmd.SetOut(buf)
    	rootCmd.SetArgs([]string{"list", tmpDir})
    
    	err = rootCmd.Execute()
    	assert.NoError(t, err)
    
    	output := buf.String()
    	for _, f := range testFiles {
    		assert.Contains(t, output, f)
    	}
    }
    
    func TestListCommandJSON(t *testing.T) {
    	tmpDir, err := os.MkdirTemp("", "filetool-test")
    	assert.NoError(t, err)
    	defer os.RemoveAll(tmpDir)
    
    	// テストファイルを作成
    	testFile := filepath.Join(tmpDir, "test.txt")
    	err = os.WriteFile(testFile, []byte("test"), 0644)
    	assert.NoError(t, err)
    
    	// JSON出力でコマンド実行
    	buf := new(bytes.Buffer)
    	rootCmd.SetOut(buf)
    	rootCmd.SetArgs([]string{"list", "-o", "json", tmpDir})
    
    	err = rootCmd.Execute()
    	assert.NoError(t, err)
    
    	output := buf.String()
    	assert.Contains(t, output, "\"name\":")
    	assert.Contains(t, output, "test.txt")
    }
    

    ビルドとインストール

    # 開発ビルド
    go build -o filetool
    
    # リリースビルド(バージョン情報埋め込み)
    VERSION=$(git describe --tags --always)
    COMMIT=$(git rev-parse --short HEAD)
    DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
    
    go build -ldflags "\
      -X main.version=${VERSION} \
      -X main.commit=${COMMIT} \
      -X main.date=${DATE}" \
      -o filetool
    
    # インストール
    go install
    
    # クロスコンパイル
    GOOS=linux GOARCH=amd64 go build -o filetool-linux
    GOOS=windows GOARCH=amd64 go build -o filetool.exe
    GOOS=darwin GOARCH=arm64 go build -o filetool-darwin-arm64
    

    使用例

    # ファイル一覧
    filetool list
    filetool list -r -l /path/to/dir
    filetool list -o json > files.json
    
    # ファイル検索
    filetool search "\.go$"
    filetool search -i -r "readme" /path/to/project
    
    # 統計情報
    filetool stats
    filetool stats -H --extensions /path/to/dir
    
    # ファイル監視
    filetool watch .
    filetool watch -r --events create,write /path/to/dir
    
    # ファイル圧縮
    filetool compress file1 file2 file3
    filetool compress -r -o backup.zip /path/to/dir
    filetool compress -i
    
    # バージョン確認
    filetool version
    
    # ヘルプ
    filetool --help
    filetool list --help
    

    まとめ

    この解答例では、以下の実装ポイントを学びました:

  • Cobraによるサブコマンド構造: 拡張性の高いコマンド体系
  • Viperによる設定管理: フラグ、環境変数、設定ファイルの統合
  • 並行処理: goroutineとチャネルによる高速化
  • プログレスバー: ユーザーフィードバックの向上
  • カラー出力: 視認性の向上
  • インタラクティブ入力: promptuiによる対話的UI
  • エラーハンドリング: 適切なエラーメッセージと終了コード
  • テスト: テーブル駆動テストによる品質保証

これらの技術は、実務レベルのCLIツール開発に直接応用できます。