skills/local/cali-go-standards/SKILL.md
Use this skill for any Go (Golang) backend task — writing services, APIs, CLI tools, concurrent code, or reviewing/refactoring existing Go code. Triggers on: any .go file, mention of goroutines, channels, mutexes, Go modules, or requests to build backend systems in Go. Also triggers when setting up linters, running tests, managing Go dependencies, or running the app locally during development.
npx skillsauth add renatocaliari/agent-sync-public-skills cali-go-standardsInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
_ in security-sensitive or production paths.cali-go-stack.Handle errors at the call site. Never use _ to discard errors in production code.
Always wrap with context so stack traces are readable:
// ✅ Do
if err := store.Save(ctx, user); err != nil {
return fmt.Errorf("saving user %d: %w", user.ID, err)
}
// ❌ Don't
store.Save(ctx, user)
Sentinel errors for expected conditions; fmt.Errorf("%w") for propagation:
var ErrNotFound = errors.New("not found")
if errors.Is(err, ErrNotFound) { ... }
Define interfaces where they are used, not where the type is defined. Keep interfaces small — 1 to 3 methods. The standard library is the model.
// ✅ Do — consumer defines what it needs
type UserLoader interface {
LoadUser(ctx context.Context, id int) (*User, error)
}
// ❌ Don't — giant interface at the producer
type UserRepository interface {
Load(...)
Save(...)
Delete(...)
List(...)
Search(...)
}
Rule: Prefer io.Reader, io.Writer, fmt.Stringer over custom interfaces when stdlib suffices.
context.Context is always the first parameterAll functions that perform I/O, call external services, or may be cancelled must accept
ctx context.Context as the first argument. No exceptions.
// ✅ Do
func (s *UserService) Load(ctx context.Context, id int) (*User, error)
// ❌ Don't
func (s *UserService) Load(id int) (*User, error)
Never store a context in a struct — pass it through the call chain.
slogUse log/slog (stdlib since Go 1.21). Always include structured key-value pairs.
Never use fmt.Println or bare log.Printf for application logs.
// ✅ Do
slog.InfoContext(ctx, "user loaded", "user_id", id, "duration_ms", elapsed.Milliseconds())
slog.ErrorContext(ctx, "save failed", "user_id", id, "error", err)
// ❌ Don't
fmt.Printf("loaded user %d\n", id)
log.Printf("error: %v", err)
No init() for dependencies. No package-level vars for services.
Constructors make dependencies explicit, testable, and traceable from main.
// ✅ Do
type UserService struct {
store UserStore
logger *slog.Logger
}
func NewUserService(store UserStore, logger *slog.Logger) *UserService {
return &UserService{store: store, logger: logger}
}
// ❌ Don't
var globalUserService = &UserService{store: defaultStore}
s *Server, h *Handler, not this or selfuserID, httpClient, urlPath — not userId, HttpClient, UrlPathuser.UserID → user.ID, auth.AuthToken → auth.Tokenany / interface{}Use generics (Go 1.18+) or concrete types. any hides intent from the compiler,
static analysis, and LLMs. Use it only at true boundaries (JSON decode, plugin systems).
// ✅ Do — generic constraint
func Map[T, U any](slice []T, fn func(T) U) []U { ... }
// ❌ Don't — unconstrained any in business logic
func Process(data any) any { ... }
init() side effectsinit() is reserved for registering drivers and codecs only.
Never put business logic, config loading, or network calls in init().
They make initialization order non-obvious and untestable.
All tests for logic with multiple cases must use table-driven format:
func TestValidate(t *testing.T) {
tests := []struct {
name string
input string
want bool
wantErr bool
}{
{"empty string", "", false, true},
{"valid email", "[email protected]", true, false},
{"missing @", "invalid", false, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Validate(tt.input)
if (err != nil) != tt.wantErr {
t.Fatalf("wantErr=%v, got %v", tt.wantErr, err)
}
if got != tt.want {
t.Errorf("want %v, got %v", tt.want, got)
}
})
}
}
Use this priority order — do not skip levels without justification:
| Scenario | Preferred solution |
|---|---|
| Transferring data between goroutines | channel |
| Shared struct fields | sync.Mutex or sync.RWMutex |
| Simple counter or boolean flag | sync/atomic generic types |
| Fan-out with error propagation | golang.org/x/sync/errgroup |
// coordinator owns the map; workers send updates via channel
type update struct{ key string; delta int }
func coordinator(updates <-chan update) map[string]int {
counts := make(map[string]int)
for u := range updates {
counts[u.key] += u.delta
}
return counts
}
type SafeCache struct {
mu sync.RWMutex
items map[string]string
}
func (c *SafeCache) Set(key, val string) {
c.mu.Lock()
defer c.mu.Unlock() // always defer immediately after locking
c.items[key] = val
}
func (c *SafeCache) Get(key string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
v, ok := c.items[key]
return v, ok
}
var requestCount atomic.Uint64 // generic, no casting needed
func handler() {
requestCount.Add(1)
}
import "golang.org/x/sync/errgroup"
g, ctx := errgroup.WithContext(context.Background())
g.Go(func() error { return fetchUser(ctx, id) })
g.Go(func() error { return fetchOrders(ctx, id) })
if err := g.Wait(); err != nil {
return fmt.Errorf("parallel fetch failed: %w", err)
}
Air is the standard live reload tool for local development. It watches .go and
.templ files and automatically runs pre-build hooks, recompiles, and restarts the
server on every save — no manual rebuild steps needed.
go install github.com/air-verse/air@latest
.air.toml — standard config (projects with Templ)Place this file at the project root:
[build]
pre_cmd = ["templ generate"]
cmd = "go build -ldflags='-w -X main.Version=dev' -o ./tmp/app ./cmd/web"
bin = "./tmp/app"
include_ext = ["go", "templ"]
exclude_dir = ["tmp", "vendor", "node_modules", "testdata"]
exclude_regex = ["_templ\\.go$"] # prevents rebuild loop from generated files
kill_delay = "500ms"
send_interrupt = true
[log]
time = true
[misc]
clean_on_exit = true
For projects without Templ, remove the pre_cmd line entirely.
air # run from the project root; keep the terminal open while developing
Air runs for the duration of a dev session. Stop it with Ctrl+C when done.
go build + ./app manually during a dev session..air.toml with Templ: include_ext must contain "templ",
exclude_regex must exclude "_templ\\.go$", and pre_cmd must be
["templ generate"].pgrep -x air
air before testing the change.tmp/ to .gitignore — Air writes the compiled binary there.echo "tmp/" >> .gitignore
Air builds silently — a failed build does not show an error unless you check. After starting or restarting Air, always verify:
# 1. Confirm the binary was built (no staleness)
ls -la ./tmp/app 2>/dev/null && echo "BUILT OK" || echo "BUILD FAILED"
# 2. Check the build error log for details if it failed
cat ./tmp/build-errors.log 2>/dev/null || echo "no errors log"
# 3. Confirm the server is listening on its port
lsof -i :<PORT> 2>/dev/null || echo "NOT LISTENING"
The most common Air failure: go build -o ./tmp/main . (Air's default when no
.air.toml exists) compiles the module root — which produces nothing when the
real main.go lives in cmd/web/main.go. The build exits code 0, no binary is
created, and Air silently never starts the server. Port stays empty, no error shown.
Fix: the project MUST have an .air.toml at root with:
[build]
cmd = "go build -o ./tmp/app ./cmd/web" # <-- point at the real entrypoint
bin = "./tmp/app"
include_ext = ["go", "tpl", "tmpl", "html", "templ"]
exclude_regex = ["_templ\\.go$"]
.air.toml exists and build.cmd points to the correct entrypointairlsof -i :<PORT> that the server is listeningtmp/build-errors.log for the actual compile errorNever use Air outside of local development. For deploying or restarting the app in staging or production, follow this priority order:
ko (recommended for all Go projects)ko is the 2026 standard for Go OCI image builds:
ko compiles the Go binary and wraps it in a minimal distroless imageGOOS/GOARCH without QEMU (5-10x faster than Docker multi-arch)//go:embed (embed.FS) — the binary must be self-contained# Build and push to registry (e.g. GitHub Container Registry)
KO_DOCKER_REPO=ghcr.io/org/repo \
VERSION=$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' || echo dev) \
COMMIT=$(git rev-parse --short HEAD) \
BUILDTIME=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
ko build \
--platform=linux/amd64,linux/arm64 \
--tags latest,"$VERSION" \
--bare ./cmd/web/
# Build locally without pushing (load into Docker)
ko build --platform=linux/amd64 --local --tags dev --bare ./cmd/web/
CI/CD pattern (GitHub Actions):
- uses: actions/setup-go@v5
- run: templ generate # if using Templ
- uses: ko-build/[email protected]
- env: { KO_DOCKER_REPO: ghcr.io/org/repo }
run: ko build --platform=linux/amd64,linux/arm64 --tags latest,${{ steps.version.outputs.version }} --bare ./cmd/web/
Required .ko.yaml:
defaultBaseImage: gcr.io/distroless/static-debian12:nonroot
builds:
- id: my-app
main: ./cmd/web
ldflags:
- -w
- -X main.Version={{.Env.VERSION}}
- -X main.CommitHash={{.Env.COMMIT}}
- -X main.BuildTime={{.Env.BUILDTIME}}
When NOT to use ko: The project uses CGo, needs OS packages at runtime,
or third-party binaries. In those cases, fall back to a multi-stage Dockerfile
with --mount=type=cache for Go module/build cache:
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go build -o /app/my-app ./cmd/web/
If no container infrastructure exists and ko is not an option, perform these steps:
# 1. Run templ generate if the project uses Templ
templ generate
# 2. Kill any process currently holding the port
lsof -ti:<PORT> | xargs kill -9 2>/dev/null || true
sleep 1
# 3. Build with version metadata
go build \
-ldflags="-w \
-X main.Version=$(git describe --tags --abbrev=0 2>/dev/null || echo dev) \
-X main.CommitHash=$(git rev-parse --short HEAD 2>/dev/null || echo unknown) \
-X main.BuildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
-o ./app ./cmd/web
# 4. Start detached
nohup ./app > server.log 2>&1 &
echo "PID: $!"
If the project does not expose main.Version, main.CommitHash, or main.BuildTime,
drop the corresponding -X flags.
| Tool | Context | Dockerfile needed | Multi-arch | Watches files | Long-running |
|---|---|---|---|---|---|
| air | Local dev only | ❌ | ❌ | ✅ | No — terminal session |
| ko | Containerized prod | ❌ | ✅ (native) | ❌ | Yes — orchestrated |
| go build + nohup | Bare-metal/VM | ❌ | ❌ | ❌ | Yes — nohup |
| Docker multi-stage | Legacy (needs CGo/OS pkgs) | ✅ | ❌ (slow QEMU) | ❌ | Yes — orchestrated |
defer f.Close() or defer resp.Body.Close() immediately after opening.go.sum — always commit it to version control; never use GONOSUMCHECK in production.init() side effects — see Idiomatic Go Rules above.any in business logic — see Idiomatic Go Rules above.go build during a dev session — use Air; see Development Workflow above.go test -race ./...
go build -race -o app ./cmd/app
govulncheck ./...
# Install if missing:
go install golang.org/x/vuln/cmd/govulncheck@latest
go install golang.org/x/tools/cmd/deadcode@latest
deadcode -test ./...
Use golangci-lint with the recommended linter set below. If golangci-lint is not installed, install it first:
# Install (latest version, no pinning)
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin
.golangci.ymllinters:
enable:
- errcheck # unchecked errors
- govet # suspicious constructs (shadow, printf misuse, etc.)
- staticcheck # comprehensive static analysis (SA*, S*, QF*)
- gosec # security anti-patterns (hardcoded creds, weak crypto, etc.)
- revive # idiomatic style (golint successor)
- gocritic # opinionated but high-signal checks
- ineffassign # assignments whose value is never used
- unused # unexported identifiers never referenced
- goimports # import ordering and formatting
- noctx # http requests without context.Context
linters-settings:
gosec:
severity: medium
revive:
rules:
- name: exported
- name: var-naming
- name: error-return
- name: context-as-argument # ctx must be first param
- name: context-keys-type # typed context keys only
golangci-lint run ./...
go mod why <package> before adding them.golang.org/x/... (official extended stdlib) over unknown third parties.go get, run govulncheck ./... to catch newly introduced CVEs.go.mod and go.sum in sync — CI must fail if they are dirty (go mod tidy check).# CI check: fails if go.mod/go.sum are not tidy
go mod tidy
git diff --exit-code go.mod go.sum
tools
Auto-initialize structured documentation for any project using lat.md (knowledge graph of markdown files with [[wiki links]], // @lat: code refs, and semantic search). Detects cali-product-workflow artifacts (spec-product.md, spec-tech.md, critiques) and uses them as seed material. Falls back to extracting business rules, architecture, and design decisions directly from the codebase. Use when a project lacks structured documentation or when lat.md/ is missing. After seeding, lat.md extension hooks keep documentation alive automatically.
testing
[Cali] Server security audit and hardening for private servers behind Tailscale. Use when: auditing server security, hardening SSH/firewall/Docker, checking for vulnerabilities, setting up fail2ban, reviewing port exposure, or responding to security alerts. Covers 6 layers: CloudFlare, UFW, Tailscale, SSH, Docker, Application. Triggers: "server security", "security audit", "harden server", "SSH hardening", "firewall rules", "UFW config", "fail2ban", "port security", "Docker security", "vulnerability check", "security review".
tools
Run supply chain security scans before installing packages or before releases. Triggers when: user installs a package (npm, pip, go get, brew), user asks to 'scan dependencies', 'check vulnerabilities', 'supply chain', 'security audit', 'run trivy', 'run socket', or before any release/deployment. Also triggers on mentions of: socket.dev, trivy, OSV-scanner, dotenvx, CVE, dependency audit. Covers all four tools with concrete commands.
tools
Create GitHub releases following project conventions. Triggers when: user says 'release', 'create release', 'push release', 'deploy to main', 'merge to main', user merges a PR to main, or when git push to main is detected. Also triggers on mentions of: gh release, semver, version bump, changelog, release-please. Covers: config-driven (read .release.yml and execute) and fallback (gh CLI) release flows, versioning rules, tag management, and the mandatory release-on-merge convention.