agentleFS
matthullmatthull/my-skills/skills/technical/go-cli/SKILL.md

go-cli

Skill0 starsChanged 55 days agoMITView on GitHub

Go CLI development patterns — testing strategy, argument parsing, error handling, concurrency, and quality gates. Load this skill when implementing or modifying Go CLI tools.

  • Installs packages

We split this skill into the rules it gives an agent

11 rules. About 0 are common, 7 are distinctive, and 4 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈7

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 4

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Every Go CLI project MUST have a make check target.

    line 14
  2. Distinctive:

    Test all logic that doesn't require external processes:

    line 38
  3. Distinctive:

    Test the compiled binary against real infrastructure.

    line 52
  4. Distinctive:

    Verify through the same interface users see: Check stdout/stderr, process lists, filesystem state

    line 86
  5. Distinctive:

    No hidden global state — constructor makes dependencies visible

    line 117
  6. Very specific:

    Test isolation is trivial: NewRunnerWithSocket("test-socket") vs mutating a global

    line 118
  7. Distinctive:

    No init-order bugs from globals

    line 120
  8. Very specific:

    Use buffered channels of size 1 instead of mutexes when you need lock timeout.

    line 133
  9. Very specific:

    Use os.Getpid() in resource names to avoid collisions

    line 234
  10. Distinctive:

    Gotcha: When a test kills the last session on an isolated server, the server shuts down.

    line 240
  11. Distinctive:

    Every integration/acceptance test starts with:

    line 265

≈ “Common” is an estimate: at least 1 in 5 of the 308,232 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Go CLI Development

Patterns for building reliable Go CLI tools that interact with external processes (tmux, git, docker, etc.). Distilled from production experience.


Quality Gate: make check

Every Go CLI project MUST have a make check target. This is the single command that must pass before shipping.

check: fmt vet lint test

Where:

  • fmt — gofmt -w . && goimports -w .
  • vet — go vet ./...
  • lint — golangci-lint run ./...
  • test — go test ./... -v (all tests including integration)

Also provide test-short for fast feedback: go test ./... -v -short (skips integration tests).

Rule: If make check doesn't pass, don't commit. This means lint must pass — golangci-lint errors (including errcheck on unchecked error returns) are not warnings, they are build failures. Fix them before moving on. Lint passing is part of the TDD loop: write test → make it pass → make lint pass → refactor.


Testing Strategy: Two-Layer Model

Unit tests alone are insufficient for Go CLI tools that interact with external processes. The bugs that escape unit tests live at the seam between argument parsing and execution — where environmental behavior (tmux prefix matching, flag parsing order) determines correctness.

Layer 1: Unit Tests (pure logic, -short mode)

Test all logic that doesn't require external processes:

  • Argument parsing: Export parseFooArgs() functions and test them directly
  • Config serialization: WriteConfig → ReadConfig round-trip
  • String processing: Sanitization, template generation, status parsing
  • Error classification: Sentinel error matching from stderr patterns

Run: go test ./... -short — seconds, every save

What unit tests catch: Logic bugs, regressions, edge cases in pure functions.

What unit tests miss: Environmental behavior, argument flow through command structure, external tool quirks.

Layer 2: Acceptance Tests (installed binary + real environment)

Test the compiled binary against real infrastructure. These are sequential scenario tests that exercise the full lifecycle:

func TestFullLifecycle(t *testing.T) {
    if testing.Short() { t.Skip("skipping acceptance test") }

    // Use temp dirs / env overrides — don't touch real state
    tmpHome := t.TempDir()
    t.Setenv("MY_HOME", tmpHome)

    // Helper runs the installed binary
    runCLI := func(args ...string) (string, error) {
        cmd := exec.Command("mybinary", args...)
        cmd.Env = append(os.Environ(), "MY_HOME="+tmpHome)
        out, err := cmd.CombinedOutput()
        return string(out), err
    }

    // Sequential phases — each builds on the previous
    t.Run("create resource", func(t *testing.T) { ... })
    t.Run("verify resource exists", func(t *testing.T) { ... })
    t.Run("modify resource", func(t *testing.T) { ... })
    t.Run("cleanup resource", func(t *testing.T) { ... })
    t.Run("error cases", func(t *testing.T) { ... })
}

Run: go test ./... -run TestFullLifecycle -timeout 120s — 20-30s, before commits

What acceptance tests catch: Flag parsing order bugs, external tool behavior (tmux fuzzy matching, process timing), command flow, real error messages, state management across commands.

Key patterns:

  • Track resources for cleanup: defer killing tmux windows, temp files created during test
  • Use env vars for isolation: $MY_HOME, $MY_CONFIG — never touch real user state
  • Verify through the same interface users see: Check stdout/stderr, process lists, filesystem state
  • Non-fatal timing checks: Process output capture is timing-sensitive — log warnings, don't hard-fail

When to write which

Writing...Layer
New arg parsing logicUnit test for parse function + acceptance test for the command
Pure data transformationUnit test only
New CLI commandBoth — unit for logic, acceptance for the full flow
Bug fixAcceptance test that reproduces the bug first, then fix
External tool interactionAcceptance test only (can't unit-test external behavior)

Bugs caught by each layer (from experience)

BugUnit?Acceptance?
Flags after positional args silently droppedNoYes
External tool does prefix matching instead of exactNoYes
Config round-trip loses fieldsYesAlso yes
Incorrect error message textPossiblyYes

Go Patterns

Struct over global functions

Wrap configuration in an explicit struct. All operations are methods on the struct.

Why:

  • No hidden global state — constructor makes dependencies visible
  • Test isolation is trivial: NewRunnerWithSocket("test-socket") vs mutating a global
  • Multiple instances can coexist
  • No init-order bugs from globals
type Runner struct {
    socket string
}
func NewRunner() *Runner { return &Runner{} }
func NewRunnerWithSocket(s string) *Runner { return &Runner{socket: s} }
func (r *Runner) DoThing(target string) error { /* uses r.socket */ }

Channel semaphore for per-target locking

Use buffered channels of size 1 instead of mutexes when you need lock timeout. select with time.After gives a deadline; sync.Mutex.Lock() blocks forever.

func AcquireLock(target string, timeout time.Duration) bool {
    sem := getTargetSem(target) // chan struct{} with buffer 1
    select {
    case sem <- struct{}{}: return true   // acquired
    case <-time.After(timeout): return false // gave up
    }
}

When to use: Locks guarding external process interaction where hangs are possible.

Sentinel errors + stderr classification

Classify external tool errors by parsing stderr into sentinel errors. Callers use errors.Is() for control flow.

var (
    ErrNoServer        = errors.New("no server running")
    ErrSessionNotFound = errors.New("session not found")
)
func (r *Runner) wrapError(err error, stderr string, args []string) error {
    if strings.Contains(stderr, "no server running") { return ErrNoServer }
    return fmt.Errorf("cmd %s: %s", args[0], stderr)
}

One file per concern

Split by primary operation: send.go, lock.go, idle.go, capture.go. Target 30-170 lines per file.


CLI Argument Parsing

Flags-after-positional gotcha

CRITICAL BUG PATTERN: Go's flag.Parse stops at the first non-flag argument. If your usage is cmd <positional> [--flag value], flags after the positional arg are silently ignored.

mycli create myname --project /tmp    # --project silently dropped!
mycli create --project /tmp myname    # works, but unnatural

Fix: splitArgs helper. Separate positional and flag arguments before parsing:

func splitArgs(args []string) (positional, flags []string) {
    for i := 0; i < len(args); i++ {
        if strings.HasPrefix(args[i], "-") {
            flags = append(flags, args[i])
            if !strings.Contains(args[i], "=") && i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") {
                flags = append(flags, args[i+1])
                i++
            }
        } else {
            positional = append(positional, args[i])
        }
    }
    return
}

func runCreate(args []string) int {
    positional, flagArgs := splitArgs(args)
    if len(positional) < 1 { /* error */ }
    name := positional[0]

    fs := flag.NewFlagSet("create", flag.ContinueOnError)
    fs.StringVar(&project, "project", "", "working directory")
    fs.Parse(flagArgs)
    // ...
}

Use splitArgs for every command that takes <positional> [--flags]. Unit-test splitArgs itself.

For commands where all args are flags (no positional), flag.Parse(args) directly is fine.

Exit code convention: 1 = runtime error, 2 = usage/argument error.


Test Infrastructure Patterns

TestMain for process isolation

TestMain creates an isolated environment for the entire test package. Non-negotiable for tests that interact with external processes.

var testSocket string

func TestMain(m *testing.M) {
    testSocket = fmt.Sprintf("myapp-test-%d", os.Getpid())
    code := m.Run()
    _ = exec.Command("tmux", "-L", testSocket, "kill-server").Run()
    os.Exit(code)
}
  • Use os.Getpid() in resource names to avoid collisions
  • Cleanup in TestMain — ensures cleanup even if tests panic
  • Individual tests also use t.Cleanup for their own resources

Server exit race

Gotcha: When a test kills the last session on an isolated server, the server shuts down. Next test gets "server exited unexpectedly".

Fix: Retry once after a short sleep on this specific error.

Shell readiness wait

Gotcha: Session creation returns before the shell is ready to receive input.

Fix: Poll for visible output before proceeding:

func waitForShellReady(t *testing.T, r *Runner, session string) {
    deadline := time.Now().Add(5 * time.Second)
    for time.Now().Before(deadline) {
        lines, _ := r.CapturePaneLines(session, 3)
        for _, line := range lines {
            if strings.TrimSpace(line) != "" { return }
        }
        time.Sleep(100 * time.Millisecond)
    }
}

Integration test tagging

Every integration/acceptance test starts with:

if testing.Short() {
    t.Skip("skipping integration test")
}

Two speeds: go test ./... -short (seconds) vs go test ./... (full suite, ~30s).


Environment Gotchas

Claude Code Bash tool does NOT source .zshenv

Go tools installed via go install to ~/go/bin won't be found. Add env.PATH to .claude/settings.local.json:

{
  "env": {
    "PATH": "$HOME/go/bin:$HOME/.local/bin:$PATH"
  }
}

External tools may not error on bad input

Some CLI tools succeed silently with invalid input (e.g., tmux display-message -t nonexistent returns "0" with exit 0). Always write explicit tests for "not found" cases. Don't assume tools will error when given bad input.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

Reviews can't be read right now.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.