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
- Very specific: line 14
Every Go CLI project MUST have a make check target.
- Distinctive: line 38
Test all logic that doesn't require external processes:
- Distinctive: line 52
Test the compiled binary against real infrastructure.
- Distinctive: line 86
Verify through the same interface users see: Check stdout/stderr, process lists, filesystem state
- Distinctive: line 117
No hidden global state — constructor makes dependencies visible
- Very specific: line 118
Test isolation is trivial: NewRunnerWithSocket("test-socket") vs mutating a global
- Distinctive: line 120
No init-order bugs from globals
- Very specific: line 133
Use buffered channels of size 1 instead of mutexes when you need lock timeout.
- Very specific: line 234
Use os.Getpid() in resource names to avoid collisions
- Distinctive: line 240
Gotcha: When a test kills the last session on an isolated server, the server shuts down.
- Distinctive: line 265
Every integration/acceptance test starts with:
≈ “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.
Files
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 testWhere:
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→ReadConfiground-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:
deferkilling 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 logic | Unit test for parse function + acceptance test for the command |
| Pure data transformation | Unit test only |
| New CLI command | Both — unit for logic, acceptance for the full flow |
| Bug fix | Acceptance test that reproduces the bug first, then fix |
| External tool interaction | Acceptance test only (can't unit-test external behavior) |
Bugs caught by each layer (from experience)
| Bug | Unit? | Acceptance? |
| Flags after positional args silently dropped | No | Yes |
| External tool does prefix matching instead of exact | No | Yes |
| Config round-trip loses fields | Yes | Also yes |
| Incorrect error message text | Possibly | Yes |
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 unnaturalFix: 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.Cleanupfor 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.
Other versions2 projects use a version of this.
Word for word · 0
None found.
Edited · 0
None found.
Wrote their own version · 2
- eduardo-sl/go-agent-skills★ 85same name
- wolzey/agent-factory★ 4same name
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
In this repository43 other files it gives its agents.
- skills/technical/supabase-postgres-best-practices/AGENTS.mdAGENTS.md
- skills/technical/supabase-postgres-best-practices/CLAUDE.mdCLAUDE.md
- direct-handoffSkill
- handoffSkill
- nt-commsSkill
- threadsSkill
- meal-planningSkill
- resume-workflowSkill
- bash-scriptsSkill
- code-reviewSkill
- davinci-resolveSkill
- disk-cleanupSkill
- fastmcp-pythonSkill
- markdown-to-pdfSkill
- pr-createSkill
- qa-planSkill
- rcaSkill
- react-native-best-practicesSkill
- ruby-railsSkill
- skill-creatorSkill
- spec-checkSkill
- supabase-migrationsSkill
- supabase-postgres-best-practicesSkill
- supabase-sqlSkill
- testingSkill
- ui-state-managementSkill
- waydroid-adbSkill
- calcinatioSkill
- citationsSkill
- exploreSkill
- manual-qaSkill
- nvim-copilotSkill
- orchestrateSkill
- orthogonal-emanationSkill
- product-intent-signal-extractionSkill
- project-management-signal-extractionSkill
- request-researchSkill
- researchSkill
- skill-discoverySkill
- slack-thread-triageSkill
- specSkill
- tmux-sidecarSkill
- youtube-transcribeSkill
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.

