| name | go-cli-builder |
| description | Build Go-based command-line tools following established patterns with Cobra CLI framework, Viper configuration, SQLite database, and automated GitHub Actions workflows for releases. Use when creating new Go CLI projects or adding features to existing ones that follow the Cobra/Viper/SQLite stack. |
Go CLI Builder
Overview
This skill provides templates, scripts, and patterns for building production-ready Go command-line tools. It follows established patterns from projects like feedspool-go, feed-to-mastodon, and linkding-to-opml.
The skill generates projects with:
- Cobra for CLI framework
- Viper for configuration management (YAML files with CLI overrides)
- SQLite with a naive migration system
- Logrus for structured logging
- Makefile for common tasks (lint, format, test, build)
- GitHub Actions workflows for CI, tagged releases, and rolling releases
- Strict code formatting with gofumpt and linting with golangci-lint
When to Use This Skill
Use this skill when:
- Creating a new Go CLI tool from scratch
- Adding commands to an existing Go CLI project that follows these patterns
- Needing reference material about Cobra/Viper integration
- Setting up GitHub Actions workflows for multi-platform Go releases
Example user requests:
- "Create a new Go CLI tool called feed-analyzer"
- "Scaffold a Go project for processing log files"
- "Add a new 'export' command to my Go CLI project"
- "Help me set up GitHub Actions for releasing my Go tool"
Quick Start
Creating a New Project
To scaffold a complete new project:
python scripts/scaffold_project.py my-cli-tool
python scripts/scaffold_project.py my-cli-tool --no-database
python scripts/scaffold_project.py my-cli-tool --templates
python scripts/scaffold_project.py my-cli-tool --no-database --templates
Project Options:
What gets created:
Base structure (always):
- Entry point (
main.go)
- Root command with Cobra/Viper integration (
cmd/root.go)
- Version command (
cmd/version.go)
- Configuration system (
internal/config/)
- Makefile with standard targets
- GitHub Actions workflows (CI, release, rolling-release)
Optional additions:
- Database layer with migrations (
internal/database/) - if database enabled
- Template system (
internal/templates/, cmd/init.go) - if templates enabled
Next steps after scaffolding:
- Update
go.mod with the actual module name
- Customize the example config file
- If using database: Define initial schema in
internal/database/schema.sql
- Run
make setup to install development tools
- Run
go mod tidy to download dependencies
Adding Commands to Existing Projects
To add a new command to an existing project:
python scripts/add_command.py fetch
This creates cmd/fetch.go with:
- Command boilerplate
- Access to logger and config
- Flag binding examples
- TODO comments for implementation
Project Structure
Generated projects follow this structure:
my-cli-tool/
├── main.go # Entry point
├── go.mod # Dependencies
├── Makefile # Build automation
├── my-cli-tool.yaml.example # Example configuration
├── cmd/ # Command definitions
│ ├── root.go # Root command + Cobra/Viper setup
│ ├── version.go # Version command
│ ├── constants.go # Application constants
│ └── [command].go # Individual commands
├── internal/
│ ├── config/
│ │ └── config.go # Configuration struct
│ ├── database/
│ │ ├── database.go # Connection + initialization
│ │ ├── migrations.go # Migration system
│ │ └── schema.sql # Initial schema (embedded)
│ └── templates/ # Optional: For tools that generate output
│ ├── templates.go # Embedded template loader
│ └── default.md # Default template (embedded)
└── .github/workflows/
├── ci.yml # PR linting and testing
├── release.yml # Tagged releases
└── rolling-release.yml # Main branch rolling releases
Configuration System
Projects use a three-tier configuration hierarchy:
- Config file (
my-tool.yaml): Base configuration in YAML
- Environment variables: Automatic via Viper
- CLI flags: Override everything
See references/cobra-viper-integration.md for detailed patterns on:
- Binding flags to Viper keys
- Adding new configuration options
- Command-specific vs. global configuration
- Environment variable mapping
Database Layer
The generated database layer includes:
- Initial schema (
internal/database/schema.sql): Embedded SQL for first-time setup
- Migration tracking:
schema_migrations table tracks applied versions
- Migration execution: Automatic on database initialization
- Idempotent operations: Safe to run multiple times
To add a new migration:
- Edit
internal/database/migrations.go
- Add to the
getMigrations() map with the next version number:
func getMigrations() map[int]string {
return map[int]string{
2: `CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);`,
}
}
- Migrations run automatically on next database initialization
Init Command Pattern
For tools that generate output files (markdown, OPML, etc.), the init command pattern provides a great user experience by generating both configuration and customizable templates.
When to Use Init Command
Use the init command when your CLI tool:
- Generates formatted output (markdown, HTML, XML, etc.)
- Benefits from user-customizable templates
- Has configuration that users need to set up before first use
Init Command Components
Available templates:
init.go.template - Complete init command implementation
templates.go.template - Template loader with embedded default
default.md.template - Example embedded markdown template
The init command:
- Creates a YAML configuration file with all options documented
- Creates a customizable template file (using embedded default)
- Supports
--force flag to overwrite existing files
- Supports
--template-file flag to specify custom template filename
- Provides helpful next steps after initialization
Embedded Templates
Go's //go:embed directive allows embedding template files directly in the binary:
package templates
import (
_ "embed"
)
var defaultTemplate string
func GetDefaultTemplate() (string, error) {
return defaultTemplate, nil
}
Benefits:
- Single binary distribution (no external template files needed)
- Users can still customize by running
init to get a copy
- Template always available as fallback
Integration with Other Commands
Commands that generate output should support both:
- Built-in template (default) - uses embedded template
- Custom template (via
--template flag or config) - loads from file
Example pattern:
templatePath := viper.GetString("command.template")
var generator *Generator
if templatePath != "" {
generator, err = NewGeneratorFromFile(templatePath)
} else {
generator, err = NewGenerator()
}
Example Projects Using This Pattern
linkding-to-markdown - Fetches bookmarks and generates markdown
mastodon-to-markdown - Exports Mastodon posts to markdown
Orchestrator-friendly export Contract
Scope: this contract applies only to tools whose purpose is to fetch
data from a source and transform it into a Markdown document. Other Go
CLIs the skill might be used to build (servers, web apps, generic
utilities) are not in scope.
Tools that conform to the contract can be orchestrated by
me-to-markdown, which
runs many such tools in parallel over a single time window and
concatenates their output into one combined Markdown document.
The contract
Every fetch-and-export tool exposes an export subcommand the orchestrator
calls:
{tool} export --since <date|duration> [--until <date>] [-o <file>]
--since — required. Accepts a Go duration string (168h,
30m), an RFC3339 timestamp, or a YYYY-MM-DD date. Durations are
interpreted as "ago" relative to now.
--until — optional. Accepts YYYY-MM-DD (end-of-day inclusive in
local time) or RFC3339. Defaults to "now" when omitted.
-o / --output — output file path. Empty (or -) means stdout.
That's the full surface. Filter, sort, formatting, and other
tool-specific options stay in the config file rather than the
export flag set — the orchestrator-facing contract is deliberately
minimal.
Posture: purely additive
export is added alongside any existing fetch, sync, render,
run, or root command — never as a replacement. Existing user-visible
behavior on those commands stays unchanged. This keeps the normalization
PR low-risk and lets downstream consumers (other tools, scripts, agent
skills) keep calling whatever they already call.
Composition pattern
- Stateless tools (fetch directly from an API, render in one pass):
extract the post-flag-parsing pipeline into a function, then both the
legacy
fetch and the new export call it. Example:
mastodon-to-markdown's cmd/fetch.go exposes a runFetchPipeline
that takes a *TimeRange and an output path; both subcommands build
their own time range and delegate.
- Archive tools (local SQLite,
sync separate from render):
export composes sync + windowed render. Set viper keys for any
values that render reads, then call syncCmd.RunE then
renderCmd.RunE. Example: pocketcasts-to-markdown's
cmd/export.go overrides render.since / render.until /
render.output and delegates.
Time window parser
Drop in this exact file at internal/timewindow/parse.go. It's
duplicated per-tool by design — the contract is small, stable, and not
worth a cross-repo Go module dependency.
package timewindow
import (
"fmt"
"time"
)
func Parse(s string, ref time.Time, endOfDay bool) (time.Time, error) {
if s == "" {
return time.Time{}, fmt.Errorf("empty time value")
}
if d, err := time.ParseDuration(s); err == nil {
return ref.Add(-d), nil
}
if t, err := time.Parse(time.RFC3339, s); err == nil {
return t, nil
}
if t, err := time.ParseInLocation("2006-01-02", s, time.Local); err == nil {
if endOfDay {
t = t.Add(24*time.Hour - time.Nanosecond)
}
return t, nil
}
return time.Time{}, fmt.Errorf("invalid time %q (expected YYYY-MM-DD, RFC3339, or Go duration)", s)
}
Reference implementations
The following tools already implement the contract; lift the patterns
directly:
State / database location
For archive tools, default the local SQLite path to
$XDG_STATE_HOME/{tool-name}/state.db, falling back to
~/.local/state/{tool-name}/state.db. --database stays as an escape
hatch. The orchestrator never overrides this — each tool owns its own
state directory.
func xdgStateDir() string {
if v := os.Getenv("XDG_STATE_HOME"); v != "" {
return filepath.Join(v, "tool-name")
}
home, err := os.UserHomeDir()
if err != nil {
return "."
}
return filepath.Join(home, ".local", "state", "tool-name")
}
func defaultDatabasePath() string {
return filepath.Join(xdgStateDir(), "state.db")
}
Set this as the viper default for the database key.
Makefile Targets
All generated projects include these targets:
make setup: Install development tools (gofumpt, golangci-lint)
make build: Build the binary with version information
make run: Build and run the application
make lint: Run golangci-lint
make format: Format code with go fmt and gofumpt
make test: Run tests with race detection
make clean: Remove build artifacts
GitHub Actions Workflows
Three workflows are included:
1. CI (ci.yml)
- Triggers: Pull requests to main, manual workflow calls
- Actions: Lint with golangci-lint, test with race detection
- Skip: Commits starting with
[noci]
2. Release (release.yml)
- Triggers: Tags matching
v* (e.g., v1.0.0)
- Platforms: Linux (amd64, arm64), macOS (amd64, arm64), Windows (amd64)
- Outputs: Compressed binaries, checksums, GitHub release
- Docker: Optional (commented out by default)
3. Rolling Release (rolling-release.yml)
- Triggers: Pushes to main branch
- Actions: Same as Release but creates a "latest" prerelease
- Purpose: Testing builds from the latest commit
To customize:
- Update Docker Hub username in workflows if using Docker
- Adjust Go version if needed (default: 1.21)
- Modify build matrix to add/remove platforms
Typical Workflow
Starting a New Project
- Use this skill to scaffold the project
- Customize the initial schema in
internal/database/schema.sql
- Update configuration struct in
internal/config/config.go
- Add domain-specific packages in
internal/ (see references/internal-organization.md)
- Add commands using the add_command script
- Implement command logic, calling into
internal/ packages
Adding a Feature
- Determine if it needs a new command or extends existing one
- If new command: use
add_command.py script
- Add any required configuration to config struct and root flags
- Implement logic in
internal/ packages
- Update command to call the internal logic
- Add tests
- Run
make format && make lint && make test
Reference Documentation
For detailed patterns and guidelines, refer to:
-
references/cobra-viper-integration.md: Complete guide to configuration system
- Flag binding patterns
- Adding new configuration options
- Environment variable mapping
- Best practices
-
references/internal-organization.md: Internal package structure
- Package organization principles
- Dependency rules
- Common patterns (Option pattern, error wrapping)
- When to create new packages
-
references/template-patterns.md: Template-based output generation
- When and how to use embedded templates
- Init command implementation
- Generator/renderer patterns
- Template functions and testing
- User workflow and best practices
Templates Available
All templates are in assets/templates/:
Core Files:
main.go: Minimal entry point
go.mod.template: Pre-configured dependencies
Makefile.template: Standard build targets
gitignore.template: Go-specific ignores
config.yaml.example: Example configuration
Commands:
root.go.template: Cobra/Viper integration
version.go.template: Version command
constants.go.template: Application constants
command.go.template: New command template
init.go.template: Init command for config/template generation
Internal Packages:
config.go.template: Configuration struct
database.go.template: Database layer
migrations.go.template: Migration system
schema.sql.template: Initial schema
templates.go.template: Embedded template loader
default.md.template: Example embedded template
CI/CD:
ci.yml.template: CI workflow
release.yml.template: Release workflow
rolling-release.yml.template: Rolling release workflow
Best Practices
- Keep commands thin: Business logic belongs in
internal/ packages
- Use the config struct: Access configuration through
GetConfig() rather than calling Viper directly
- Wrap errors: Always add context with
fmt.Errorf("context: %w", err)
- Format before committing: Run
make format && make lint
- Test with race detection:
go test -race ./...
- Version your releases: Use semantic versioning tags (v1.0.0, v1.1.0, etc.)
- Document in .yaml.example: Keep example config updated
- Handle errors explicitly: Use
_ = for intentionally ignored errors (e.g., _ = viper.BindPFlag(...))
- Defer cleanup safely: Use
defer func() { _ = tx.Rollback() }() instead of defer tx.Rollback() to avoid linter warnings
Common Customizations
After scaffolding, projects typically need:
- Module name update: Change
github.com/yourusername/project in go.mod to actual path
- Additional dependencies: Add with
go get and run go mod tidy
- Custom schema: Define tables in
internal/database/schema.sql
- Domain packages: Create packages in
internal/ for business logic
- Command implementations: Fill in the TODOs in command files
- Docker configuration: Uncomment Docker sections in workflows if needed
Recent Improvements
Linter Compliance (2025-11-11)
- Fixed viper.BindPFlag warnings: All
viper.BindPFlag() calls now use _ = prefix to explicitly ignore errors, satisfying the errcheck linter
- Fixed defer Rollback warnings: Database transaction cleanup now uses
defer func() { _ = tx.Rollback() }() pattern
- Removed static linking: Removed
-linkmode external -extldflags "-static" flags from Makefile to eliminate getaddrinfo warnings when using CGO with SQLite
- Updated templates: All templates now generate linter-clean code out of the box
These changes ensure that projects scaffolded with this skill pass golangci-lint without warnings.
Troubleshooting
"gofumpt not found" or "golangci-lint not found"
- Run
make setup to install development tools
"Failed to initialize schema"
- Check database file path and permissions
- Ensure directory exists or is creatable
"Missing migration for version N"
- Migrations must be sequential; add any missing versions
"getaddrinfo warning during build"
- This warning has been resolved in recent versions by removing static linking flags
- If you see this in an older project, remove the static linking lines from your Makefile (see Recent Improvements section)
GitHub Actions failing on cross-compilation
- Ensure CGO is enabled for SQLite
- Linux ARM64 builds require cross-compilation tools (handled in workflow)