Masalahnya: Tool yang Ada Nggak Pernah Pas
Kamu pasti pernah di situasi ini: lagi debug production, butuh tool yang bisa parse log dengan format spesifik, tapi grep terlalu primitif dan tool yang ada di internet butuh 47 dependency. Atau kamu nulis shell script yang sama berulang kali di setiap project karena nggak ada yang mau packaging dengan benar.
Itu tanda kamu butuh bisa bikin sendiri. Linux command line tools development bukan sorcery — ini skill yang bisa dipelajari dalam satu weekend dan langsung produktif.
Artikel ini bakal kasih kamu fondasi nyata: anatomy sebuah CLI tool, cara handle input/output dengan benar, error handling yang nggak memalukan, dan dua contoh tool lengkap yang bisa langsung kamu modifikasi.
Anatomy CLI Tool yang Benar
Sebelum nulis satu baris kode, pahami dulu kontrak sosial antara CLI tool kamu dengan shell:
Exit code — 0 artinya sukses, non-zero artinya gagal. Ini bukan opsional. Kalau tool kamu selalu return 0 meskipun error, pipeline orang lain bakal rusak diam-diam.
stdin / stdout / stderr — Output normal ke stdout, error message ke stderr. Ini yang bikin tool kamu bisa di-pipe dengan benar.
Argumen dan flag — Ikuti konvensi POSIX. Short flag pakai -v, long flag pakai --verbose. Jangan bikin konvensi sendiri yang bikin bingung.
Kalau kamu langgar salah satu dari tiga ini, tool kamu bakal susah dipakai di pipeline dan orang (termasuk kamu sendiri 3 bulan lagi) bakal frustrasi.
Mulai dengan Shell Script: Fondasi yang Sering Diremehkan
Banyak developer langsung loncat ke Go atau Rust untuk CLI tool. Padahal untuk tool sederhana, shell script yang ditulis dengan benar sudah cukup kuat.
Contoh: tool untuk cek apakah semua required environment variable sudah di-set sebelum app jalan.
#!/usr/bin/env bash
# checkenv — validasi required env vars
# Usage: checkenv VAR1 VAR2 VAR3
set -euo pipefail
if [[ $# -eq 0 ]]; then
echo "Usage: $(basename "$0") VAR1 [VAR2 ...]" >&2
exit 1
fi
missing=0
for var in "$@"; do
if [[ -z "${!var:-}" ]]; then
echo "ERROR: env var '$var' is not set" >&2
missing=$((missing + 1))
else
echo "OK: $var"
fi
done
if [[ $missing -gt 0 ]]; then
echo "\n$missing variable(s) missing. Aborting." >&2
exit 1
fi
exit 0
Simpen sebagai checkenv, chmod +x checkenv, taruh di ~/.local/bin/. Sekarang kamu bisa pakai di Makefile:
run:
checkenv DATABASE_URL REDIS_URL APP_SECRET
go run ./cmd/server
Gotcha yang gue hit: set -u bakal bikin script crash kalau kamu akses variabel yang belum di-set. Makanya gue pakai ${!var:-} bukan ${!var} — syntax expansion ini kasih empty string sebagai default kalau variabel kosong, jadi set -u nggak nge-trigger.
Naik Level: CLI Tool dengan Go
Shell script mulai kewalahan kalau kamu butuh:
- Parse output yang kompleks
- HTTP request
- Concurrent operation
- Binary yang bisa didistribusikan tanpa dependency
Go adalah pilihan pragmatis untuk Linux command line tools development. Binary-nya static, cross-compile mudah, dan standard library-nya kuat.
Kita bikin tool sederhana: logstat — baca log file dari stdin, hitung berapa banyak line per HTTP status code.
// main.go
package main
import (
"bufio"
"flag"
"fmt"
"os"
"regexp"
"sort"
"strconv"
)
var (
// Match pola umum: ... "GET /path HTTP/1.1" 200 ...
statusRe = regexp.MustCompile(`"\S+ \S+ HTTP/\d\.\d" (\d{3})`)
)
func main() {
verbose := flag.Bool("verbose", false, "tampilkan semua status codes")
flag.Parse()
counts := make(map[int]int)
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
line := scanner.Text()
matches := statusRe.FindStringSubmatch(line)
if matches == nil {
continue
}
code, err := strconv.Atoi(matches[1])
if err != nil {
continue
}
counts[code]++
}
if err := scanner.Err(); err != nil {
fmt.Fprintf(os.Stderr, "error reading stdin: %v\n", err)
os.Exit(1)
}
// Sort by status code
codes := make([]int, 0, len(counts))
for code := range counts {
codes = append(codes, code)
}
sort.Ints(codes)
for _, code := range codes {
if !*verbose && (code < 400) {
continue
}
fmt.Printf("%d\t%d\n", code, counts[code])
}
}
Build dan test:
go build -o logstat ./main.go
# Test dengan log nginx
cat /var/log/nginx/access.log | ./logstat
# Lihat semua status code
cat /var/log/nginx/access.log | ./logstat --verbose
# Pipeline dengan tail untuk monitoring live
tail -f /var/log/nginx/access.log | ./logstat
Gotcha: bufio.Scanner punya default max token size 64KB per line. Kalau log kamu ada yang super panjang (misalnya ada yang log entire request body), scanner bakal error. Fix-nya:
scanner := bufio.NewScanner(os.Stdin)
scanner.Buffer(make([]byte, 1024*1024), 1024*1024) // 1MB max line
Struktur Project untuk CLI Tool yang Serius
Kalau tool kamu mulai kompleks, jangan taruh semua di main.go. Ini struktur yang gue pakai:
mytool/
├── cmd/
│ └── mytool/
│ └── main.go # Entry point, parse flags, panggil cmd
├── internal/
│ ├── parser/
│ │ └── parser.go # Logic parsing
│ └── output/
│ └── formatter.go # Format output
├── go.mod
├── go.sum
├── Makefile
└── README.md
Makefile yang useful:
BINARY_NAME=mytool
BUILD_DIR=./bin
VERSION=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
.PHONY: build install clean
build:
go build \
-ldflags "-X main.version=$(VERSION)" \
-o $(BUILD_DIR)/$(BINARY_NAME) \
./cmd/$(BINARY_NAME)
install: build
cp $(BUILD_DIR)/$(BINARY_NAME) ~/.local/bin/
clean:
rm -rf $(BUILD_DIR)
Dengan -ldflags, kamu bisa inject version string dari git tag langsung ke binary tanpa file config tambahan.
Error Handling yang Nggak Memalukan
Ini yang sering bikin tool buatan sendiri terasa amatir: error message yang nggak informatif.
Jangan:
if err != nil {
log.Fatal(err)
}
Ini akan print sesuatu kayak 2024/01/15 10:23:45 open config.json: no such file or directory — timestamp nggak berguna dan nggak ada context.
Lakukan ini:
if err != nil {
fmt.Fprintf(os.Stderr, "mytool: failed to open config: %v\n", err)
os.Exit(1)
}
Format yang bagus: toolname: what you were doing: underlying error.
Kalau tool kamu punya beberapa subcommand, tambahkan subcommand ke prefix:
mytool parse: failed to open config: no such file or directory
User langsung tahu di mana masalahnya tanpa harus baca source code.
Testing CLI Tool
Banyak yang skip testing untuk CLI tool karena "susah". Padahal dengan Go, ini straightforward.
Buat file main_test.go:
package main
import (
"strings"
"testing"
)
func TestStatusParsing(t *testing.T) {
tests := []struct {
name string
line string
wantCode int
wantMatch bool
}{
{
name: "nginx 200",
line: `192.168.1.1 - - [01/Jan/2024] "GET /api/users HTTP/1.1" 200 1234`,
wantCode: 200,
wantMatch: true,
},
{
name: "nginx 404",
line: `10.0.0.1 - - [01/Jan/2024] "POST /missing HTTP/1.1" 404 89`,
wantCode: 404,
wantMatch: true,
},
{
name: "non-log line",
line: "this is not a log line",
wantMatch: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
matches := statusRe.FindStringSubmatch(tt.line)
if tt.wantMatch && matches == nil {
t.Errorf("expected match for line: %s", tt.line)
return
}
if !tt.wantMatch && matches != nil {
t.Errorf("expected no match for line: %s", tt.line)
return
}
if tt.wantMatch {
got := strings.TrimSpace(matches[1])
if got != fmt.Sprintf("%d", tt.wantCode) {
t.Errorf("got status %s, want %d", got, tt.wantCode)
}
}
})
}
}
Jalankan dengan go test ./.... Refactor regex kamu dengan confidence.
Distribusi: Supaya Orang Lain Bisa Pakai
Kalau tool kamu sudah berguna, distribusikan dengan benar. Untuk internal tim, cara paling simpel:
# Cross-compile untuk Linux AMD64
GOOS=linux GOARCH=amd64 go build -o dist/mytool-linux-amd64 ./cmd/mytool
# Untuk Mac Silicon (kalau tim kamu campur)
GOOS=darwin GOARCH=arm64 go build -o dist/mytool-darwin-arm64 ./cmd/mytool
Taruh di GitHub Releases, atau kalau internal, di shared storage yang bisa diakses tim. Tambahkan install script sederhana:
#!/usr/bin/env bash
# install.sh
set -euo pipefail
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
ARCH=$(uname -m)
case $ARCH in
x86_64) ARCH="amd64" ;;
arm64|aarch64) ARCH="arm64" ;;
*) echo "Unsupported arch: $ARCH" >&2; exit 1 ;;
esac
BINARY="mytool-${OS}-${ARCH}"
URL="https://github.com/yourname/mytool/releases/latest/download/${BINARY}"
curl -fsSL "$URL" -o ~/.local/bin/mytool
chmod +x ~/.local/bin/mytool
echo "Installed: $(mytool --version)"
Yang Gue Lakukan Setelah Ini
Kalau kamu mau mulai Linux command line tools development sekarang, urutan yang masuk akal:
- Ambil satu pain point nyata — bukan tool latihan, tapi sesuatu yang benar-benar kamu butuhkan minggu ini.
- Mulai dengan shell script — kalau bisa selesai dengan 50 baris bash, jangan over-engineer ke Go.
- Pindah ke Go kalau kamu butuh distribusi binary, performa, atau logic yang kompleks. Untuk referensi lebih lanjut tentang performa dan optimisasi, this guide on database performance tuning mistakes bisa memberikan insight tentang prinsip-prinsip yang sama dalam konteks sistem yang lebih besar.
- Pasang ke
~/.local/bin/dan pakai setiap hari — feedback loop terbaik adalah tool yang kamu sendiri andalkan. - Tambahkan
--helpyang benar sebelum share ke orang lain. Ini tanda bahwa tool kamu serius.
Gue sendiri maintain sekitar 8 tool internal yang lahir dari frustrasi yang sama — log parser, env checker, database snapshot helper, dan lainnya. Semua mulai dari script 20 baris yang kemudian tumbuh sesuai kebutuhan nyata. Untuk monitoring dan observability dari tool-tool ini, Grafana vs Datadog for server monitoring bisa membantu kamu memilih stack yang tepat untuk tracking performa.
Mulai dari yang kecil. Ship yang jalan. Iterate.