💡

GoでCLIツールを作成してみる

に公開

はじめに

別にツールじゃなくていいけど、なんかファイル操作とか触ってみないと覚えないし、遠回りしてGoでCLIツール作成してみます

想定読者

何となくGo触ったことある人
Goのファイル操作何となくわかるけど、そんなに触ってない人
GoでCLIツールの実装をしようと思っている人

概要

  • 課題

    • yyyymmdd.mdでタスクを管理しているが、その日に終わらなかったタスクを毎回次の日のファイルにコピペしなければならないのが面倒
  • 最終成果物

    • - [ ]になっている行だけ取得して次の日のmdに追加
    • サブコマンドでタスクを追加
  • 大まかな流れ
    1: ファイルの中身を取得してみる
    2: - [ ]だけ抽出して翌日のmdに追加する
    3: サブコマンドでタスクの追加とタグ付けをする

ファイルの中身を取得する

package main

import (
	"fmt"
	"log"
	"os"
)

func main() {
	// ファイルの中身を一括で読み込む
	content, err := os.ReadFile("20250121.md")
	if err != nil {
		log.Fatalf("ファイルを読み取れませんでした: %v", err)
	}

	// ファイル内容を表示
	fmt.Println("ファイル内容:")
	fmt.Println(string(content))
}
$ go run main.go 
ファイル内容:
- [ ] hogehoge
- [ ] hogehoge

ファイルの中身を別のファイルに書き出す

  • 今日のmdファイルから翌日にmdファイルを出力する
  • ファイル操作によってファイルの読み込み、書き込みなどをする
  • 完了済みのタスクが表示されないようにする
package main

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

func main() {
	// 現在自国を取得
	now := time.Now()
	//今日の日付を取得するフォーマット
	nowStr := now.Format("20060102")

	// ファイルの中身を読み込む
	content, err := os.ReadFile(nowStr + ".md")
	if err != nil {
		log.Fatalf("ファイルを読み取れませんでした: %v", err)
	}

	// nowStrから1日追加
	tomorrow := now.AddDate(0, 0, 1)
	tomorrowStr := tomorrow.Format("20060102")
	tomorrow_md := fmt.Sprintf("%s.md", tomorrowStr)

	//ファイルが存在する場合は、作成不要にしておく
	if _, err := os.Stat(tomorrow_md); err == nil {
		fmt.Println("すでに作成されているので作成しません")
	} else {
		// ファイルが存在しない場合、新規作成する
		file, err := os.Create(tomorrow_md)
		if err != nil {
			log.Fatalf("エラーが発生しました: %v", err)
		}
		defer file.Close()
		fmt.Println("新しいファイルを作成しました")
	}

	// 未完了タスクを抽出
	var tasks []string
	scanner := bufio.NewScanner(strings.NewReader(string(content)))
	for scanner.Scan() {
		line := scanner.Text()
		// 未完了タスク行(`- [ ]` を含む)を抽出
		if strings.Contains(line, "- [ ]") {
			// 未完了のタスクを slice に追加(インデントを保持)
			tasks = append(tasks, line)
		}
	}

	output := strings.Join(tasks, "\n")

	// 出力の例(ファイルに書き込む場合)
	err = os.WriteFile(tomorrow_md, []byte(output), 0644)
	if err != nil {
		log.Fatalf("エラーが発生しました: %v", err)
	}

	fmt.Println(tasks)

	if err := scanner.Err(); err != nil {
		log.Fatalf("スキャン中にエラー:%v", err)
	}
	// 未完了タスクがない場合の処理
	if len(tasks) == 0 {
		fmt.Println("未完了タスクはありません。")
		return
	}

	// 未完了のタスクだけを書き込む
	file, err := os.OpenFile(tomorrow_md, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644)
	if err != nil {
		log.Fatalf("ファイルを開く中にエラー: %v", err)
	}
	defer file.Close()

	fmt.Println("未完了のタスクを書き込みました")

	fmt.Println("今日の内容を更新しました!")
}

  • 入力ファイルの例
    • 完了済みのタスクを用意します
    • インデントも下げて親タスク、子タスクの関係にします
- [ ] xxx
    - [x] xxxx
- [x] xxx
- [ ] yyyy
  • 出力ファイルの例
    • ちゃんと完了済みのタスクのみ出力されています
- [ ] xxx
- [ ] yyyy

サブコマンドの実装

  • サブコマンドを実装するには、フォルダ構造は大きく変更されます。
  • 実装したいことは、大きく変更ないのでサブコマンド使用するならよしなにやってください

初期設定

  • 以下のコマンドでcobraでCLIを実行するために準備する
$ go install github.com/spf13/cobra-cli@latest

$ cobra-cli init --license MIT --viper=false

$  go run main.go
A longer description that spans multiple lines and likely contains
examples and usage of using your application. For example:

Cobra is a CLI library for Go that empowers applications.
This application is a tool to generate the needed files
to quickly create a Cobra application.
  • main.goの中身
package main

import "todo_support/cmd"

func main() {
	cmd.Execute()
}

  • generate.goの中身
package cmd

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

	"github.com/spf13/cobra"
)

const tasksDir = "tasks" // タスクファイルを保存するディレクトリ

// generateCmd represents the generate command
var generateCmd = &cobra.Command{
	Use:   "generate",
	Short: "翌日のTODOファイルを生成します",
	Long:  "今日のTODOファイルから未完了のタスクを抽出し、翌日のTODOファイルを新規作成します。",
	Run: func(cmd *cobra.Command, args []string) {
		// 現在自国を取得
		now := time.Now()
		// 今日の日付を取得するフォーマット
		nowStr := now.Format("20060102")

		// ファイルの中身を読み込む
		content, err := os.ReadFile(filepath.Join(tasksDir, nowStr+".md"))
		if err != nil {
			log.Fatalf("ファイルを読み取れませんでした: %v", err)
		}

		// nowStrから1日追加
		tomorrow := now.AddDate(0, 0, 1)
		tomorrowStr := tomorrow.Format("20060102")
		tomorrow_md := filepath.Join(tasksDir, fmt.Sprintf("%s.md", tomorrowStr))

		// ファイルが存在する場合は、作成不要にしておく
		if _, err := os.Stat(tomorrow_md); err == nil {
			fmt.Println("すでに作成されているので作成しません")
		} else {
			// ファイルが存在しない場合、新規作成する
			file, err := os.Create(tomorrow_md)
			if err != nil {
				log.Fatalf("エラーが発生しました: %v", err)
			}
			defer file.Close()
			fmt.Println("新しいファイルを作成しました")
		}

		// 未完了タスクを抽出
		var tasks []string
		scanner := bufio.NewScanner(strings.NewReader(string(content)))
		for scanner.Scan() {
			line := scanner.Text()
			// 未完了タスク行(`- [ ]` を含む)を抽出
			if strings.Contains(line, "- [ ]") {
				// 未完了のタスクを slice に追加(インデントを保持)
				tasks = append(tasks, line)
			}
		}

		output := strings.Join(tasks, "\n")

		// 出力の例(ファイルに書き込む場合)
		err = os.WriteFile(tomorrow_md, []byte(output), 0644)
		if err != nil {
			log.Fatalf("エラーが発生しました: %v", err)
		}

		fmt.Println(tasks)

		if err := scanner.Err(); err != nil {
			log.Fatalf("スキャン中にエラー:%v", err)
		}
		// 未完了タスクがない場合の処理
		if len(tasks) == 0 {
			fmt.Println("未完了タスクはありません。")
			return
		}

		// 未完了のタスクだけを書き込む
		file, err := os.OpenFile(tomorrow_md, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644)
		if err != nil {
			log.Fatalf("ファイルを開く中にエラー: %v", err)
		}
		defer file.Close()

		fmt.Println("未完了のタスクを書き込みました")

		fmt.Println("今日の内容を更新しました!")
	},
}

func init() {
	rootCmd.AddCommand(generateCmd)
}

  • add.goの中身
    • add.goでは、タスクの内容を追加するためのコマンドを実行します
    • --tommorowのサブコマンドを実装して明日のタスクに追加することができます
    • --tagのサブコマンドでタスクの最初にタグを追加することができます
package cmd

import (
	"fmt"
	"os"
	"time"

	"github.com/spf13/cobra"
)

// addCmd represents the add command
var addCmd = &cobra.Command{
	Use:   "add [タスク内容]",
	Short: "タスクを追加します",
	Long: `このコマンドは今日または翌日のタスクに新しいタスクを追加します。
使用例: 
add '新しいタスク内容' --tag [タグの名前]`,
	Run: func(cmd *cobra.Command, args []string) {
		if len(args) < 1 {
			fmt.Println("addの引数にタスク内容を指定してください")
			return
		}

		// タスク内容を取得
		task := args[0]

		// フラグの値を取得
		tomorrow, _ := cmd.Flags().GetBool("tomorrow")
		tag, _ := cmd.Flags().GetString("tag")

		// 今日または翌日のタスクファイルを決定
		date := time.Now()
		if tomorrow {
			date = date.AddDate(0, 0, 1) // 翌日
		}
		filePath := fmt.Sprintf("tasks/%s.md", date.Format("20060102"))

		// タスク行を作成
		taskLine := ""
		if tag != "" {
			taskLine = fmt.Sprintf("\n- [ ] [%s] %s", tag, task)
		} else {
			taskLine = fmt.Sprintf("\n- [ ] %s", task)
		}

		// タスクファイルに書き込み
		f, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
		if err != nil {
			fmt.Println("ファイルを開けませんでした:", err)
			return
		}
		defer f.Close()

		if _, err := f.WriteString(taskLine); err != nil {
			fmt.Println("タスクを追加できませんでした:", err)
		} else {
			fmt.Printf("タスク '%s' を %s に追加しました\n", taskLine, filePath)
		}
	},
}

func init() {
	// フラグの登録を最初に実行
	addCmd.Flags().BoolP("tomorrow", "t", false, "Add task to tomorrow's TODO file")
	addCmd.Flags().StringP("tag", "g", "", "タスクにタグを付与します")

	// ルートコマンドに追加
	rootCmd.AddCommand(addCmd)
}

$ go build -o todo_cli

# addだけだと今日の日付のファイルにタスクが追加される
$ ./todo_cli add "今日のタスク"

# -t(--tomorrow)をサブコマンドとして実行すると明日の日付のファイルにタスクが追加される
$ ./todo_cli add -t "明日のタスク"

# -a(--tag)をサブコマンドとして実行すると追加するタスクにタグが追加される
$ ./todo_cli add "タグの追加" --tag urgent
# - [ ] [urget] タグの追加 

まとめ

ファイル操作覚える必要ないけど、使い機会なかったのでよかった
tagの追加などは優先度などを判断するのに使用できそうなので、優先度高いタスクをパッと追加できるのはいいことだなという気がした

Discussion