Build Go-based command-line tools following established patterns with Cobra CLI framework, Viper configuration, SQLite database, and automated GitHub Actions workflows for releases...
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:
Use this skill when:
Example user requests:
To scaffold a complete new project:
# With database support (default)
python scripts/scaffold_project.py my-cli-tool
# Without database support
python scripts/scaffold_project.py my-cli-tool --no-database
# With template support for generating output
python scripts/scaffold_project.py my-cli-tool --templates
# Combining options
python scripts/scaffold_project.py my-cli-tool --no-database --templates
Project Options:
Database Support (default: included)
--no-database to exclude if you don't need persistent storageTemplate Support (default: excluded)
--templates to include for tools that generate formatted outputWhat gets created:
Base structure (always):
main.go)cmd/root.go)cmd/version.go)internal/config/)Optional additions:
internal/database/) - if database enabledinternal/templates/, cmd/init.go) - if templates enabledNext steps after scaffolding:
go.mod with the actual module nameinternal/database/schema.sqlmake setup to install development toolsgo mod tidy to download dependenciesTo add a new command to an existing project:
python scripts/add_command.py fetch
This creates cmd/fetch.go with:
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
Projects use a three-tier configuration hierarchy:
my-tool.yaml): Base configuration in YAMLSee references/cobra-viper-integration.md for detailed patterns on:
The generated database layer includes:
internal/database/schema.sql): Embedded SQL for first-time setupschema_migrations table tracks applied versionsTo add a new migration:
internal/database/migrations.gogetMigrations() 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
);`,
}
}
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.
Use the init command when your CLI tool:
Available templates:
init.go.template - Complete init command implementationtemplates.go.template - Template loader with embedded defaultdefault.md.template - Example embedded markdown templateThe init command:
--force flag to overwrite existing files--template-file flag to specify custom template filenameGo's //go:embed directive allows embedding template files directly in the binary:
package templates
import (
_ "embed"
)
//go:embed default.md
var defaultTemplate string
func GetDefaultTemplate() (string, error) {
return defaultTemplate, nil
}
Benefits:
init to get a copyCommands that generate output should support both:
--template flag or config) - loads from fileExample pattern:
templatePath := viper.GetString("command.template")
var generator *Generator
if templatePath != "" {
generator, err = NewGeneratorFromFile(templatePath)
} else {
generator, err = NewGenerator() // uses embedded default
}
linkding-to-markdown - Fetches bookmarks and generates markdownmastodon-to-markdown - Exports Mastodon posts to markdownexport ContractScope: 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.
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.
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.
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.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.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 parses the canonical --since/--until flag values
// used by the orchestrator-facing export subcommand.
package timewindow
import (
"fmt"
"time"
)
// Parse interprets s as one of:
// - a Go duration string ("168h", "30m") ā returned as ref.Add(-d)
// (durations are interpreted as "ago" relative to ref);
// - an RFC3339 timestamp;
// - a YYYY-MM-DD date in the local timezone.
//
// When endOfDay is true, a YYYY-MM-DD date is advanced to end-of-day.
// RFC3339 and durations ignore endOfDay. Empty s returns an error.
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)
}
The following tools already implement the contract; lift the patterns directly:
mastodon-to-markdown ā stateless, extracts runFetchPipeline.linkding-to-markdown ā stateless, similar shape.github-to-markdown ā stateless, root command stays as the implicit fetch.pocketcasts-to-markdown ā archive tool, simplest implementation (delegates to existing sync + render).spotify-to-markdown ā archive tool, more invasive (adds PlaysBetween query, threads --since/--until through render).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.
All generated projects include these targets:
make setup: Install development tools (gofumpt, golangci-lint)make build: Build the binary with version informationmake run: Build and run the applicationmake lint: Run golangci-lintmake format: Format code with go fmt and gofumptmake test: Run tests with race detectionmake clean: Remove build artifactsThree workflows are included:
ci.yml)[noci]release.yml)v* (e.g., v1.0.0)rolling-release.yml)To customize:
internal/database/schema.sqlinternal/config/config.gointernal/ (see references/internal-organization.md)internal/ packagesadd_command.py scriptinternal/ packagesmake format && make lint && make testFor detailed patterns and guidelines, refer to:
references/cobra-viper-integration.md: Complete guide to configuration system
references/internal-organization.md: Internal package structure
references/template-patterns.md: Template-based output generation
All templates are in assets/templates/:
Core Files:
main.go: Minimal entry pointgo.mod.template: Pre-configured dependenciesMakefile.template: Standard build targetsgitignore.template: Go-specific ignoresconfig.yaml.example: Example configurationCommands:
root.go.template: Cobra/Viper integrationversion.go.template: Version commandconstants.go.template: Application constantscommand.go.template: New command templateinit.go.template: Init command for config/template generationInternal Packages:
config.go.template: Configuration structdatabase.go.template: Database layermigrations.go.template: Migration systemschema.sql.template: Initial schematemplates.go.template: Embedded template loaderdefault.md.template: Example embedded templateCI/CD:
ci.yml.template: CI workflowrelease.yml.template: Release workflowrolling-release.yml.template: Rolling release workflowinternal/ packagesGetConfig() rather than calling Viper directlyfmt.Errorf("context: %w", err)make format && make lintgo test -race ./..._ = for intentionally ignored errors (e.g., _ = viper.BindPFlag(...))defer func() { _ = tx.Rollback() }() instead of defer tx.Rollback() to avoid linter warningsAfter scaffolding, projects typically need:
github.com/yourusername/project in go.mod to actual pathgo get and run go mod tidyinternal/database/schema.sqlinternal/ for business logicviper.BindPFlag() calls now use _ = prefix to explicitly ignore errors, satisfying the errcheck linterdefer func() { _ = tx.Rollback() }() pattern-linkmode external -extldflags "-static" flags from Makefile to eliminate getaddrinfo warnings when using CGO with SQLiteThese changes ensure that projects scaffolded with this skill pass golangci-lint without warnings.
"gofumpt not found" or "golangci-lint not found"
make setup to install development tools"Failed to initialize schema"
"Missing migration for version N"
"getaddrinfo warning during build"
GitHub Actions failing on cross-compilation