cobraで超シンプルなCLIを作ってCLIの作り方の基本を学ぶ

# はじめに
CLIの作成に関して個人的にブラックボックスすぎたので、表面的なとこに触れておいていざちゃんと作ろうとした時の心理的ハードルをなるべく無くしておきたいと思い、試しにMarkdownで記事を書くときのちょっとしたサポートを目的としたCLIを作ってみることにしました。
実際に作ったのは、`ルートコマンド サブコマンド ファイルパス`みたいな感じのコマンドで指定したファイルの末尾に何かしらテキストを挿入するだけの超シンプルなCLIです。
例えばこんな感じです。
```zsh
# コマンド
msc link ファイルパス

# 結果（指定ファイルの末尾（改行含む）
[タイトル](URL/ "")
```
これからGoでCLIを作ってみたいけど、そもそもCLIってどうやって作るものなのか把握しておきたい方のご参考になれば幸いです。

# 結果的に作ったもの
```
msc link [<ファイルパス>]             タイトル付きリンクのテンプレ生成

msc details [<ファイルパス>]          詳細折りたたみのテンプレ生成

msc snip [<言語>] [<ファイルパス>]    コードスニペットを追加

msc table [<ファイルパス>]            テーブルのテンプレ生成

msc all [<ファイルパス>]              全テンプレ生成

msc version                           バージョン表示

msc help                              このヘルプを表示
```

#### Githubリポジトリ

https://github.com/minminseo/md-sup-cli?tab=readme-ov-file

# 実装例
### ルートコマンド

```go
package cmd

import (
	"os"

	"github.com/spf13/cobra"
)

var rootCmd = &cobra.Command{
	Use:   "msc <command> [options]",
	Short: "Markdown補助CLI（md-sup-cli）",
	Long: `md-sup-cliはMarkdownの少しだけ面倒な記法のテンプレをファイル末尾に追記するCLIです。
`,
}

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

### サブコマンド（一部）
```go
package cmd

import (
	"fmt"
	"os"

	"github.com/spf13/cobra"
)

var detailsCmd = &cobra.Command{
	Use:   "details <file>",
	Short: "折りたたみ詳細をファイル末尾に追記",
	Args:  cobra.ExactArgs(1),
	RunE: func(cmd *cobra.Command, args []string) error {
		path := args[0]
		snippet := `<details><summary>折りたたみタイトル</summary>

</details>
`
		f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0644)
		if err != nil {
			return err
		}
		defer f.Close()
		fmt.Fprint(f, "\n\n"+snippet)
		return nil
	},
}

func init() {
	rootCmd.AddCommand(detailsCmd)
}
```

# cobraで雛形生成
Goにはcobraという簡単にCLIが作れる便利なライブラリがあるようです。
実際に使ってみます。
```
go install github.com/spf13/cobra-cli@latest
```

以下のようにして雛形が生成できます。
```
cobra-cli init
```
生成された雛形は以下のようになっていると思います。

```
.
├── LICENSE
├── cmd
│   └── root.go
├── go.mod
├── go.sum
└── main.go
```

`main.go`では`cmd/root.go`のCLIエントリポイントである`Excute()`関数を実行しています。
cmd/root.goにはコマンドの作成ガイドと、実際にそのコマンドをルートコマンドに設定しCLIを起動する`Excute()`があります。cmd配下で自分の作りたいコマンドを作成していくことになります。

# 各部分メモ
### &cobra.Command {}
コマンドを作るための構造体です。主要そうなものだと`Use、Short、Long、Run、RunE、Args`など様々なCLIコマンドの設定ができるフィールドが実装されています。

---
### RunとRunE
コマンド実行時の処理を設定できます。`RunE`は返り値に`error`を返せます。エラー返したいなら`RunE`、標準出力だけなどエラー返すほどでもないなら`Run`という感じだと認識していますが、基本`RunE`を使ったほうが良さそうな雰囲気を感じました。
ここまでで登場したものを扱って実装すると以下のようになります。
```go
var versionCmd = &cobra.Command{
	Use:   "version",
	Short: "バージョンを表示",
	Run: func(cmd *cobra.Command, args []string) {
		fmt.Println(cliVersion)
	},
}
```
これは単純にCLIのバージョンを標準出力するだけのものです。このように`Use`（実際のCLIのコマンド名を定義）や`Short`など、その他自分の求めている機能が表現できるフィールドを書き、上記のように書いていきます。

---
### CLIエントリポイント
下記のコードはCLI自体の起動のためのエントリポイントです。ここで`rootCmd`を指定して`rootcmd`をこのCLIのルートコマンドに指定します。
```go
func Execute() {
	err := rootCmd.Execute()
	if err != nil {
		os.Exit(1)
	}
}
```

init関数内で以下のようにすることでサブコマンドを設定できます（例：CLIでよく見る
`msc snip`みたいなのができるようになる）。
```go
ルートコマンド変数.AddCommand(サブコマンド変数)
```

---
### ファイルを開き実際に書き込む
ファイルを書き込み専用で開き、書き込みたい内容をファイルの末尾に追加できるようにします。`path`は書き込みたいファイルパスです。
`0644`は所有者は読み書きOK、他は読取りだけOKというパーミッション設定です。
```go
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
  return err
}
defer f.Close()
```

fは書き込み先のファイルオブジェクトで、fmtパッケージの`Fprint`で指定した書き込み先に文字列を出力できます。`Fprint`は内部実装で`Write()`を呼び出しています。
```go
fmt.Fprint(f, 書き込みたい内容)
```

---
### デフォルトのコマンドを上書き（-h, --help）
以下のようにすることで、conbraで内部実装されている自動でヘルプを生成する関数が自動生成する内容を上書きできます。
```go
func init() {
	// SetHelpFuncで-hと--helpを上書き
	rootCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
		fmt.Print(helpText)
	})

	rootCmd.AddCommand(helpCmd)
}
```

# GoReleaserも使ってみる
ついでにGoReleaserを使ってビルドして生成したバイナリを配布するのもやってみました。
GoReleaserは主に以下のことをしてくれます。
- 各OS用にそれぞれビルド
- ビルドしたバイナリをOS毎に圧縮
- GitHub Releasesへのアップロード
など


まずGoReleaserの設定をします。
```yml:.goreleaser.yml
project_name: msc

release:
  github:
    owner: minminseo
    name: md-sup-cli

builds:
  - main: ./main.go
    binary: msc
    goos: # ここでOS指定
      - linux
      - darwin
      - windows
    goarch:
      - amd64
      - arm64

archives:
  - format: tar.gz
```

次にGitHubActionsのワークフローを設定します。
流れとしては、新しいタグがPush（例：`git tag v1.0.1`→`git push origin v1.0.1`）されたらワークフローを起動→Ubuntu起動→ソースコードダウンロード→Goインストール→GoReleaserインストール→`.goreleaser.yml`をもとにバイナリをビルドとアップロードという感じになります。
```yml:.github/workflows/release.yml
name: Release

on:
  push:
    tags:
      - "v*.*.*"

jobs:
  release:
    runs-on: ubuntu-latest

    permissions:
      contents: write

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-go@v4
        with:
          go-version: "1.24"

      - uses: goreleaser/goreleaser-action@v5
        with:
          version: latest
          args: release --rm-dist
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
```


# さいごに
まだ特定のファイルに文字を書き込むだけの簡単なものしかできませんが、使い方に慣れてくればもっと便利で面白そうなものが作れそうだなと思いました。
[こちらの記事](https://findy-code.io/engineer-lab/techtensei-songmu/ "")によると、[gohugoio/hugo](https://github.com/gohugoio/hugo/ "")や[x-motemen/ghq](https://github.com/x-motemen/ghq/ "")などがGoそのものの学習においても良いそうなのでこの当たりで学んでみたいなと思います。