Linux Command Line Tools Development dari Nol

by Marcus Chen
Linux Command Line Tools Development dari Nol

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 code0 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:

  1. Ambil satu pain point nyata — bukan tool latihan, tapi sesuatu yang benar-benar kamu butuhkan minggu ini.
  2. Mulai dengan shell script — kalau bisa selesai dengan 50 baris bash, jangan over-engineer ke Go.
  3. 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.
  4. Pasang ke ~/.local/bin/ dan pakai setiap hari — feedback loop terbaik adalah tool yang kamu sendiri andalkan.
  5. Tambahkan --help yang 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.