Go CLI
Definisi singkat
Membangun CLI (command-line interface) di Go berarti mengubah os.Args — slice string mentah dari shell — menjadi struktur yang bisa dipakai: perintah, opsi, dan argumen. Go punya dukungan bawaan lewat package flag, tapi pola di sekitarnya (subcommand, positional args, validasi) baru bermakna kalau dipahami sebagai satu alur.
- command / subcommand: kata pertama setelah nama program, misal
git clone,go run. Di Go kita dispatch manual lewatos.Args[1]. - flag: opsi berawalan
-atau--, misal-port=8080,--verbose. Diurusflagpackage. - positional args: sisa argumen tanpa tanda minus, misal nama file atau target. Diakses lewat
flag.Args(). - argument parsing: proses membaca, mengurai, lalu memvalidasi input sebelum logika jalan.
Intinya: flag hanya mengurus flag; positional dan subcommand tetap urusan kita.
Kapan pakai
Pakai pola ini kalau kamu sedang membuat:
- tool baris perintah (migrator, server lokal, generator kode, CLI internal)
- wrapper di atas service (deploy script, worker runner)
- binary yang dipanggil dari shell, CI, atau cron
Kalau inputmu cuma satu argumen sederhana dan tidak butuh opsi, cukup baca os.Args langsung tanpa flag. Tapi begitu ada lebih dari satu opsi atau ada subcommand, flag + pola subcommand jauh lebih rapi daripada memecah os.Args manual.
Cara kerja: flag package dasar
flag mendaftarkan flag ke sebuah flag set, lalu Parse() mengisi nilainya dari argumen.
package main
import (
"flag"
"fmt"
)
func main() {
name := flag.String("name", "world", "nama yang disapa")
count := flag.Int("count", 1, "berapa kali")
verbose := flag.Bool("verbose", false, "cetak detail")
flag.Parse() // WAJIB: isi pointer di atas dari os.Args[1:]
for i := 0; i < *count; i++ {
if *verbose {
fmt.Printf("[verbose] menyapa %s\n", *name)
}
fmt.Println("Halo", *name)
}
}Aturan dasar:
flag.String/Int/Bool/...mengembalikan pointer ke nilai hasil parse.- nilai default sudah aktif sejak deklarasi; baru tertimpa setelah
flag.Parse(). flag.Parse()mem-parseos.Args[1:].- setelah
Parse(), sisa argumen yang bukan flag ada diflag.Args().
Cara pemanggilan yang didukung (semua setara):
app -name=Budi
app -name Budi
app --name Budi // -- dianggap sama dengan -
Penandaan flag dan tipe
Tipe umum yang langsung didukung: String, Int, Int64, Uint, Bool, Float64, Duration.
Untuk nilai majemuk (misal daftar dipisah koma) atau tipe custom, implementasikan interface flag.Value:
type listVar []string
func (l *listVar) String() string { return strings.Join(*l, ",") }
func (l *listVar) Set(v string) error {
*l = append(*l, strings.Split(v, ",")...)
return nil
}
func main() {
var targets listVar
flag.Var(&targets, "target", "bisa diulang: -target=a,b")
flag.Parse()
fmt.Println(targets)
}flag.Var dipakai saat tipe tidak punya helper bawaan. Set yang mengakumulasi cocok untuk flag berulang.
Positional args
Setelah flag.Parse(), apa pun yang tersisa (dan bukan flag) jadi positional.
func main() {
recursive := flag.Bool("r", false, "rekursif")
flag.Parse()
files := flag.Args() // []string positional
if len(files) == 0 {
fmt.Println("error: butuh minimal satu file")
flag.Usage()
os.Exit(2)
}
fmt.Printf("proses %d file (r=%v): %v\n", len(files), *recursive, files)
}Pemanggilan:
app -r file1.txt file2.txt
flag.Args() mengembalikan slice, flag.Arg(i) mengembalikan elemen ke-i, flag.NArg() jumlahnya.
Subcommand pattern
CLI seperti app serve -port=8080 butuh dispatch: baca os.Args[1] sebagai nama perintah, lalu berikan sisa argumen ke flag.FlagSet milik perintah itu. Jangan pakai flag global untuk subcommand — tiap subcommand punya flag set sendiri.
package main
import (
"flag"
"fmt"
"os"
)
func main() {
if len(os.Args) < 2 {
fmt.Println("usage: app <serve|migrate> [flags]")
os.Exit(1)
}
cmd := os.Args[1]
args := os.Args[2:] // argumen untuk subcommand
switch cmd {
case "serve":
serveCmd(args)
case "migrate":
migrateCmd(args)
default:
fmt.Printf("unknown command: %s\n", cmd)
os.Exit(1)
}
}
func serveCmd(args []string) {
fs := flag.NewFlagSet("serve", flag.ExitOnError)
port := fs.Int("port", 8080, "port untuk listen")
_ = fs.Parse(args)
fmt.Printf("serving on :%d\n", *port)
}
func migrateCmd(args []string) {
fs := flag.NewFlagSet("migrate", flag.ExitOnError)
dry := fs.Bool("dry-run", false, "jangan tulis apa-apa")
_ = fs.Parse(args)
fmt.Printf("migrate dry-run=%v\n", *dry)
}Poin penting:
flag.NewFlagSet(name, handling)membuat set flag independen.handlingmenentukan reaksi terhadap error:ExitOnError(keluar),ContinueOnError(kembalikan error),PanicOnError.- parse argumen subcommand dengan
fs.Parse(args), bukanflag.Parse(). - help bawaan (
-h) akan mencetak usage milik FlagSet tersebut.
Alur parse → validate → run
Urutannya selalu begini: parse dulu, validate, baru run. Jangan jalankan efek samping (menulis file, konek DB) sebelum validasi lulus, supaya flag yang salah cuma mencetak pesan dan keluar bersih.
Validasi input
Setelah Parse(), flag sudah memastikan tipe benar (misal -count=abc gagal karena bukan int). Yang tersisa adalah validasi semantik yang flag tidak tahu:
- flag wajib tapi kosong (
-name="") - kombinasi flag yang saling bertentangan (
-dry-runtapi-force) - jumlah positional di luar rentang
- nilai di luar domain (port < 1 atau > 65535)
func serveCmd(args []string) {
fs := flag.NewFlagSet("serve", flag.ExitOnError)
port := fs.Int("port", 8080, "port untuk listen")
_ = fs.Parse(args)
if *port < 1 || *port > 65535 {
fmt.Println("error: port harus 1-65535")
os.Exit(2)
}
fmt.Printf("serving on :%d\n", *port)
}Kesalahan umum
Berikut jebakan yang paling sering muncul saat parsing CLI di Go, plus cara menghindarinya.
1. Lupa memanggil flag.Parse()
Nilai flag tetap default walau user lewatkan opsi.
dir := flag.String("dir", ".", "direktori")
// lupa flag.Parse()
fmt.Println(*dir) // selalu "."Fix: panggil flag.Parse() sekali, setelah semua flag dideklarasikan.
2. Membaca nilai flag sebelum Parse()
Karena flag.String mengembalikan pointer, membaca nilainya sebelum parse memberimu default, bukan input user.
name := flag.String("name", "default", "nama")
got := *name // SALAH: dibaca sebelum Parse
flag.Parse()
// got tetap "default" walau user lewat -name=actualFix: simpan pointer-nya, baca *name setelah flag.Parse().
3. Flag diletakkan setelah positional argumen
flag berhenti mem-parse begitu menemukan argumen pertama yang bukan flag. Jadi flag di belakang positional tidak diparse.
app file.txt -verbose # -verbose dianggap positional, bukan flag
Fix: minta pengguna menaruh flag di depan, atau validasi bahwa sisa flag.Args() tidak mengandung token berawalan -.
4. Mengira -v false mematikan bool flag
Untuk bool flag, bentuk -v menyalakan (true). Bentuk -v false tidak mematikan: false malah jadi argumen positional, dan -v tetap true.
app -v false # v = true, positional = [false]
app -v=false # v = false ✅
Fix: pakai -v=false (bentuk sama-dengan). Atau untuk mati hidup, lebih jelas pakai dua flag enable-x / disable-x atau string "true"/"false".
5. - vs -- sebagai terminator
-flagdan--flagdianggap sama oleh packageflag. Jangan kira--punya makna khusus di setiap posisi.--(dua tanda minus sendirian) adalah terminator: semua setelahnya jadi positional, meski kelihatan seperti flag.
app -- -weird-file.txt # -weird-file.txt jadi positional, aman
- satu tanda
-sendiri dianggap argumen biasa (positional), bukan flag.
6. os.Args[0] adalah nama program
os.Args[0] berisi path eksekusi, bukan argumen pertama. Argumen asli mulai dari os.Args[1]. Saat membuat FlagSet subcommand, parse os.Args[2:], bukan os.Args[1:].
7. Menangani help bawaan -h/--help
flag punya help otomatis: -h atau --help mencetak usage lalu keluar (di Go modern exit 0, bukan error). Kalau tampilan default kurang jelas, override flag.Usage:
flag.Usage = func() {
fmt.Fprintln(os.Stderr, "usage: app -name=N [flags]")
flag.PrintDefaults()
}Untuk FlagSet sendiri: fs.Usage = func() { ... }.
8. Flag global dipakai untuk subcommand
Kalau kamu mendaftarkan flag ke set global lalu juga pakai subcommand, flag subcommand bentrok atau ikut ter-parse di level salah.
Fix: tiap subcommand punya flag.NewFlagSet sendiri dan parse argumennya sendiri.
9. Collision nama flag pendek
-v untuk "verbose" dan -v untuk "version" di subcommand berbeda tidak masalah kalau masing-masing pakai FlagSet sendiri, tapi di satu set flag, nama pendek harus unik. Dokumentasikan mapping -x → arti agar tidak tabrakan saat tool membesar.
10. Tidak menangani error Parse saat ContinueOnError
Kalau pakai ContinueOnError, Parse mengembalikan error (misal flag tidak dikenal, tipe salah) dan tidak keluar sendiri. Lupa mengecek error berarti program jalan dengan nilai salah.
if err := fs.Parse(args); err != nil {
os.Exit(2)
}Contoh lengkap: CLI mini dengan subcommand
package main
import (
"flag"
"fmt"
"os"
"strconv"
)
func main() {
if len(os.Args) < 2 {
fmt.Println("usage: app <greet|sum> [flags] args...")
os.Exit(1)
}
switch os.Args[1] {
case "greet":
greetCmd(os.Args[2:])
case "sum":
sumCmd(os.Args[2:])
default:
fmt.Printf("unknown command: %s\n", os.Args[1])
os.Exit(1)
}
}
func greetCmd(args []string) {
fs := flag.NewFlagSet("greet", flag.ExitOnError)
name := fs.String("name", "world", "nama")
upper := fs.Bool("upper", false, "huruf besar")
if err := fs.Parse(args); err != nil {
return
}
rest := fs.Args()
if len(rest) > 0 {
*name = rest[0] // izinkan positional menimpa -name
}
msg := "Halo " + *name
if *upper {
msg = fmt.Sprintf("%s", toUpper(msg))
}
fmt.Println(msg)
}
func sumCmd(args []string) {
fs := flag.NewFlagSet("sum", flag.ExitOnError)
verbose := fs.Bool("verbose", false, "cetak langkah")
if err := fs.Parse(args); err != nil {
return
}
nums := fs.Args()
total := 0
for _, n := range nums {
v, err := strconv.Atoi(n)
if err != nil {
fmt.Printf("bukan angka: %s\n", n)
os.Exit(2)
}
if *verbose {
fmt.Printf("+ %d\n", v)
}
total += v
}
fmt.Println("total:", total)
}
func toUpper(s string) string {
b := []byte(s)
for i, c := range b {
if c >= 'a' && c <= 'z' {
b[i] = c - 32
}
}
return string(b)
}Pemanggilan:
app greet -name=Budi
app greet -upper Budi
app sum -verbose 1 2 3
Catatan implementasi
- deklarasikan semua flag, lalu
Parse(), lalu baru baca nilainya. - untuk tool ber-subcommand, buat
FlagSetper command dan parseos.Args[2:]. - pisahkan fase parse → validate → run; jangan jalankan side effect sebelum validasi lolos.
- validasi semantik (rentang, wajib, kombinasi) tetap tanggung jawabmu;
flaghanya validasi tipe. - override
flag.Usage/fs.Usagesupaya pesan error ramah pengguna. - pakai
ContinueOnError+ cek error bila ingin kontrol keluar sendiri;ExitOnErrorcocok untuk tool sederhana. - tes CLI dengan memanggil fungsi handler langsung dan menyuntikkan slice argumen, bukan bergantung pada
os.Argsglobal.
Kesalahan umum (ringkas)
- lupa
flag.Parse()→ nilai selalu default - baca
*flagsebelumParse()→ dapat default, bukan input - flag di belakang positional tidak diparse
-v falsetidak mematikan bool; pakai-v=false- salah ambil
os.Args[0]sebagai argumen pertama - pakai flag global untuk subcommand → bentrok/parse salah
- abaikan error
ParsesaatContinueOnError - mengira
-dan--punya makna berbeda (Go anggap sama, kecuali--sebagai terminator)
Referensi terkait
- Go Structs, Interfaces, Pointers, dan Methods — dasar tipe dan method yang dipakai untuk membungkus handler CLI
- Go Error Handling, Packages, Testing, dan Project Structure — pola project dan penanganan error saat CLI gagal
- Go Testing dan HTTP Testing — cara menguji handler CLI dengan argumen yang disuntikkan
- Go JSON Encoding/Decoding, Validation Style, dan Time Handling — validasi payload yang sering dipanggil dari CLI