Writing My First CLI Tool in Go
I reinforced my newly learned Go skills by building a real CLI tool — here's what the language feels like in practice and how far the standard library can take you.
When I decided to learn Go back in April, I told myself: “I’m going to build something small but real.” Theoretical learning — reading documentation and playing around in the playground — hits a wall pretty quickly. To truly understand a language, it has to solve an actual problem.
The tool I chose is simple but something I genuinely needed: a CLI that scans project directories and lists any project that has a .env.example file but is missing a .env file. I have dozens of project folders on my development machine, and every now and then I open one up wondering “why isn’t this working?” only to realize the .env file is absent. I needed this little tool.
Structure and package organization
Project organization in Go is different from PHP. The main package produces an executable binary; other packages are libraries. For a small CLI a single file would suffice, but I preferred to separate the concerns.
env-checker/
main.go
scanner/
scanner.go
The scanner package holds the directory-scanning logic, while main.go handles argument parsing and output.
Directory scanning
Go’s standard library handles tree traversal with filepath.Walk. No external packages needed.
package scanner
import (
"os"
"path/filepath"
)
type Result struct {
Path string
HasExample bool
HasEnv bool
}
func Scan(root string) ([]Result, error) {
var results []Result
entries, err := os.ReadDir(root)
if err != nil {
return nil, err
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
dir := filepath.Join(root, entry.Name())
hasExample := fileExists(filepath.Join(dir, ".env.example"))
hasEnv := fileExists(filepath.Join(dir, ".env"))
if hasExample {
results = append(results, Result{
Path: dir,
HasExample: true,
HasEnv: hasEnv,
})
}
}
return results, nil
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
Coming from PHP, the append function and nil slices feel a bit different at first. In Go, an empty slice is nil; append returns a new slice on every call rather than mutating in place.
main.go and output
package main
import (
"fmt"
"os"
"env-checker/scanner"
)
func main() {
root := "."
if len(os.Args) > 1 {
root = os.Args[1]
}
results, err := scanner.Scan(root)
if err != nil {
fmt.Fprintf(os.Stderr, "Hata: %v\n", err)
os.Exit(1)
}
missing := 0
for _, r := range results {
if !r.HasEnv {
fmt.Printf("EKSIK: %s\n", r.Path)
missing++
}
}
if missing == 0 {
fmt.Println("Tüm projelerde .env mevcut.")
} else {
fmt.Printf("\n%d projede .env eksik.\n", missing)
}
}
Reading arguments with os.Args, writing to stderr with fmt.Fprintf(os.Stderr, ...) — all of it is in the standard library, zero dependencies.
Building and distributing
One of the things that impressed me most: go build produces a single executable binary. When you want to share a CLI tool written in PHP, you either package it with Composer or require a PHP-equipped environment on the target machine. A Go binary runs with no dependencies whatsoever.
go build -o env-checker .
# Drop the resulting binary in /usr/local/bin and call it from anywhere
Cross-compilation for Linux and macOS is also built in:
GOOS=linux GOARCH=amd64 go build -o env-checker-linux .
This is a genuine advantage of Go for CLI tooling. Distributing, sharing, and running on different systems is effortless. You don’t have to ask “do you have Go installed?” when you want to hand someone a tool.
What this experience taught me about Go
err is everywhere. It took some getting used to, but I get it now: when error flow is explicit, it becomes much harder to ignore what can go wrong. Forget to catch an exception in PHP? It can slip by silently. In Go, if you don’t check err, the linter warns you and colleagues catch it in code review.
The standard library is genuinely rich. I didn’t use a single external dependency for this small tool. File operations, string formatting, argument parsing — all built in.
Structural typing is different. Interfaces in Go are not explicitly “implemented.” If a struct has the required methods, it automatically satisfies the interface. This isn’t intuitive for someone coming from PHP, but it provides real flexibility. scanner.Result isn’t bound to any interface, yet the same data could power different output formats down the road — without changing the struct.
I won’t pretend it was frictionless. In the early days, the thing that tripped me up most was the absence of PHP’s associative arrays; in Go, every data structure needs to be defined upfront as a struct. That felt slow at first, but after a few days I realized it isn’t a constraint — it’s discipline. You’re forced to think about the shape of your data from the start. The vagueness I used to defer in PHP with “I’ll add another field later” — Go made me close that door from the very beginning.
It’s a small tool, but it was enough to internalize the feel of Go. Once again I was reminded that learning a new language through a real need is orders of magnitude more effective than reading documentation. Next up: getting my head around the language’s concurrency model.
Comments
Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.