Build CLIs and run build targets with minimal configuration. Inspired by Mage, go-arg, and Cobra.
| Want to... | Do this |
|---|---|
| Run build targets | //go:build targ files + targ <command> |
| Define a target | var Build = targ.Targ(build) |
| Add flags/args | Function with struct parameter + targ:"..." tags |
| Shell command target | var Tidy = targ.Targ("go mod tidy") |
| Run shell commands | targ.Run("go", "build") or targ.RunContext(ctx, ...) |
| Skip unchanged work | targ.Newer(inputs, outputs) or targ.Checksum(...) |
| Watch for changes | targ.Watch(ctx, patterns, opts, callback) |
| Run deps first | .Deps(build, test) on target |
| Scaffold a target | targ --create build or targ --create tidy "go mod tidy" |
# Build tool (run targets)
go install github.com/toejough/targ/cmd/targ@latest
# Library (embed in your binary)
go get github.com/toejough/targTarg has three layers of configuration that could interact unexpectedly:
- Target definition -
.Cache(),.Watch(),.Deps()on the target - CLI flags -
--cache,--watch,--times, etc. - Global defaults - from the build tool itself
To prevent confusion, targ errors when configurations conflict rather than silently picking one:
# Target has .Cache("**/*.go") defined
$ targ build --cache="*.go"
Error: --cache conflicts with target's cache configuration
# To allow CLI override, use targ.Disabled in the target:
var Build = targ.Targ(build).Cache(targ.Disabled) // now --cache worksThis applies to --watch, --cache, and --deps. If you want CLI control, explicitly opt-in with targ.Disabled. If you want code control, don't use CLI flags for that setting.
Targ makes it easy to start with simple build targets and evolve to a full CLI. The same code works in both modes.
Scaffold targets from the command line:
targ --create test "go test -race $package"
targ --create lint "golangci-lint run --fix $path"This creates a targ file with string command targets. Variables like $package become CLI flags:
targ test --package=./...
targ test -p ./cmd/... # short flags auto-generated
targ lint --path=./internal/...The generated file looks like:
//go:build targ
package dev
import "github.com/toejough/targ"
func init() {
targ.Register(
targ.Targ("go test -race $package").Name("test"),
targ.Targ("golangci-lint run --fix $path").Name("lint"),
)
}Commands run via the system shell, so pipes and shell features work:
targ.Targ("go test -coverprofile=coverage.out $package && go tool cover -html=coverage.out")Need conditional logic or computed values? Use a function with a struct parameter:
//go:build targ
// ↑ Build tag: only compiled when running `targ` command, ignored by `go build`
package dev
import "github.com/toejough/targ"
// Register targets at init time so targ can discover them
func init() {
targ.Register(
targ.Targ(build).Description("Compile the project"),
targ.Targ(test).Description("Run tests"),
)
}
// Struct fields become CLI arguments. Tags control behavior:
// - positional: ordered argument (not a --flag)
// - flag: named --flag (default for struct fields)
// - short=X: single-letter alias (-o instead of --output)
// - default=X: value when not provided
// - desc=X: help text shown in --help
type BuildArgs struct {
Output string `targ:"flag,short=o,default=myapp,desc=Output binary name"`
Verbose bool `targ:"flag,short=v,desc=Verbose output"`
}
// Function receives parsed args. Use values for conditional logic.
func build(args BuildArgs) error {
cmdArgs := []string{"build", "-o", args.Output}
if args.Verbose {
cmdArgs = append(cmdArgs, "-v")
}
return targ.Run("go", append(cmdArgs, "./...")...)
}
type TestArgs struct {
Package string `targ:"positional,default=./...,desc=Package pattern to test"`
Cover bool `targ:"flag,desc=Enable coverage"`
}
func test(args TestArgs) error {
cmdArgs := []string{"test"}
if args.Cover {
cmdArgs = append(cmdArgs, "-cover")
}
return targ.Run("go", append(cmdArgs, args.Package)...)
}targ build --output=myapp --verbose
targ test ./cmd/... --coverReady to ship? Remove the build tag and switch to main:
// No build tag, package main - this is a regular Go binary now
package main
import "github.com/toejough/targ"
// main() instead of init()
func main() {
targ.Main( // replaces targ.Register
targ.Targ(build).Description("Compile the project"),
targ.Targ(test).Description("Run tests"),
)
}
// ... same function definitions as Stage 2go build -o mytool .
./mytool build --verbose
./mytool test --coverBuilder methods control how targets execute - caching, retries, timeouts, and file watching:
var Build = targ.Targ(build).
Name("build"). // CLI name (default: function name in kebab-case)
Description("Build the app"). // Help text
Deps(Generate, Compile). // Run dependencies first (serial by default)
Cache("**/*.go", "go.mod"). // Skip if files unchanged
Watch("**/*.go"). // Re-run on file changes
Timeout(5 * time.Minute). // Execution timeout
Times(3). // Run multiple times
Retry(). // Continue on failure
Backoff(time.Second, 2.0) // Exponential backoff between retries| Method | Description |
|---|---|
.Name(s) |
Override CLI command name |
.Description(s) |
Help text |
.Deps(targets..., mode) |
Dependencies (serial default, pass targ.DepModeParallel for parallel) |
.Cache(patterns...) |
Skip if files unchanged |
.CacheDir(dir) |
Cache checksum directory |
.Watch(patterns...) |
Re-run on file changes |
.Timeout(d) |
Execution timeout |
.Times(n) |
Number of iterations |
.Retry() |
Continue despite failures |
.Backoff(initial, factor) |
Exponential backoff |
.While(fn) |
Run while predicate is true |
Configure struct fields with targ:"..." tags:
| Tag | Description |
|---|---|
required |
Field must be provided |
positional |
Map positional args to this field |
flag |
Explicit flag (default for non-positional) |
name=X |
Custom flag/positional name |
short=X |
Short flag alias (e.g., short=f for -f) |
desc=... |
Description for help text |
enum=a|b|c |
Allowed values (enables completion) |
default=X |
Default value |
env=VAR |
Default from environment variable |
Combine with commas: targ:"positional,required,enum=dev|prod"
Use map[K]V fields for key=value syntax:
type DeployArgs struct {
Labels map[string]string `targ:"flag,desc=Key-value labels"`
Ports map[string]int `targ:"flag,desc=Service ports"`
}
func deploy(args DeployArgs) error {
for k, v := range args.Labels {
fmt.Printf("Label: %s=%s\n", k, v)
}
return nil
}targ deploy --labels env=prod --labels app=web --ports http=8080Share common flags across targets by embedding structs:
type CommonFlags struct {
Verbose bool `targ:"flag,short=v"`
Output string `targ:"flag,short=o,default=stdout"`
}
type BuildArgs struct {
CommonFlags // Embedded - flags inherited
Package string `targ:"positional"`
}
type TestArgs struct {
CommonFlags // Same flags available
Cover bool `targ:"flag"`
}Both targets get -v/--verbose and -o/--output flags.
Use targ.Group to organize targets into nested hierarchies:
targ.Register(
targ.Group("dev",
targ.Targ("go mod tidy").Name("tidy"),
targ.Targ(test).Cache("**/*.go").Timeout(5*time.Minute),
targ.Group("lint",
targ.Targ("golangci-lint run --fast $path").Name("fast"),
targ.Targ("golangci-lint run $path"), // name defaults to "golangci-lint"
),
),
)targ dev tidy
targ dev test
targ dev lint fast --path=./...
targ dev lint golangci-lint --path=./...Target functions support these signatures:
func()func() errorfunc(ctx context.Context)func(ctx context.Context) errorfunc(args T)where T is a structfunc(args T) errorfunc(ctx context.Context, args T)func(ctx context.Context, args T) error
Names are derived from function names, converted to kebab-case:
| Definition | Command |
|---|---|
func BuildAll() |
build-all |
func RunTests() |
run-tests |
Override with .Name():
targ.Targ(build).Name("compile")Use .Deps() to declare dependencies that run before a target:
targ.Targ(test).Deps(build) // serial (default)
targ.Targ(ci).Deps(test, lint, targ.DepModeParallel) // parallelDeps-only targets run dependencies without their own function:
var all = targ.Targ().Name("all").Deps(build, test, lint)Run commands with targ.Run and friends:
err := targ.Run("go", "build", "./...") // inherit stdout/stderr
err := targ.RunV("go", "test", "./...") // print command first
out, err := targ.Output("go", "env", "GOMOD") // capture outputFor cancellable commands (e.g., in watch mode), use context variants. When cancelled, the entire process tree is killed:
err := targ.RunContext(ctx, "go", "test", "./...")
err := targ.RunContextV(ctx, "golangci-lint", "run")
out, err := targ.OutputContext(ctx, "go", "list", "./...")Skip work when files haven't changed:
needs, err := targ.Newer([]string{"**/*.go"}, []string{"bin/app"})
if !needs {
return nil // outputs are up to date
}Content-based checking (when modtimes aren't reliable):
changed, err := targ.Checksum([]string{"**/*.go"}, ".cache/build.sum")
if !changed {
return nil
}React to file changes:
func watch(ctx context.Context) error {
return targ.Watch(ctx, []string{"**/*.go"}, targ.WatchOptions{}, func(_ targ.ChangeSet) error {
return targ.RunContext(ctx, "go", "test", "./...")
})
}Use .Watch() for declarative watch mode:
targ.Targ(test).Watch("**/*.go", "**/*_test.go")When run with watch patterns, the target re-runs automatically on file changes.
source <(your-binary --completion) # Detects current shell
source <(your-binary --completion bash) # Bash
source <(your-binary --completion zsh) # Zsh
your-binary --completion fish | source # FishSupports commands, subcommands, flags, and enum values.
$ targ build --help
Compile the project
Source: dev/targets.go:42
Usage: build [flags]
Flags:
-o, --output Output binary name (default: myapp)
-v, --verbose Verbose output
-h, --help Show this help
Execution:
Deps: generate, compile (serial)
Cache: **/*.go, go.mod
Override tag options at runtime by implementing TagOptions on your args struct:
type DeployArgs struct {
Env string `targ:"positional,enum=dev|prod"`
}
func (d DeployArgs) TagOptions(field string, opts targ.TagOptions) (targ.TagOptions, error) {
if field == "Env" {
opts.Enum = strings.Join(loadEnvsFromConfig(), "|")
}
return opts, nil
}
deploy := targ.Targ(func(args DeployArgs) error {
// deploy to args.Env
return nil
})Useful for loading enum values from config, conditional required fields, or environment-specific defaults.
func build() error {
needs, _ := targ.Newer([]string{"**/*.go"}, []string{"bin/app"})
if !needs {
fmt.Println("up to date")
return nil
}
return targ.Run("go", "build", "-o", "bin/app", "./...")
}// Define CI as a deps-only target that runs everything
var CI = targ.Targ().Name("ci").Deps(Generate, Build, Lint, Test)
// Or with parallel execution for independent targets:
var CI = targ.Targ().Name("ci").Deps(Generate, Build, Lint, Test, targ.DepModeParallel)func TestDeploy(t *testing.T) {
deploy := targ.Targ(func(args DeployArgs) error { /* ... */ return nil })
result, err := targ.Execute([]string{"app", "deploy", "prod", "--force"}, deploy)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(result.Output, "Deploying to prod") {
t.Errorf("unexpected output: %s", result.Output)
}
}type CatArgs struct {
Files []string `targ:"positional,required"`
}
func init() {
targ.Register(targ.Targ(func(args CatArgs) error {
for _, f := range args.Files {
// process each file
}
return nil
}).Name("cat"))
}targ cat file1.txt file2.txt file3.txtUse slice types to accept multiple values for the same flag:
type BuildArgs struct {
Tags []string `targ:"flag,short=t,desc=Build tags"`
}targ build -t integration -t linux # Tags: ["integration", "linux"]When flag order matters (e.g., include/exclude filters), use []targ.Interleaved[T]:
type FilterArgs struct {
Include []targ.Interleaved[string] `targ:"flag,short=i"`
Exclude []targ.Interleaved[string] `targ:"flag,short=e"`
}
func init() {
targ.Register(targ.Targ(func(args FilterArgs) error {
type rule struct {
include bool
pattern string
pos int
}
var rules []rule
for _, inc := range args.Include {
rules = append(rules, rule{true, inc.Value, inc.Position})
}
for _, exc := range args.Exclude {
rules = append(rules, rule{false, exc.Value, exc.Position})
}
sort.Slice(rules, func(i, j int) bool {
return rules[i].pos < rules[j].pos
})
// rules now in original command-line order
return nil
}).Name("filter"))
}targ filter -i "*.go" -e "vendor/*" -i "*.md"
# Processes in order: include *.go, exclude vendor/*, include *.md| Need | Tool |
|---|---|
| Build targets + CLI parsing | Targ |
| Simple build targets only | Targ or Mage |
| Simple struct-to-flags mapping | go-arg |
| Complex CLI with plugins/middleware | Cobra |
Targ's sweet spot: Build automation that can evolve into a full CLI, or CLI parsing with minimal boilerplate.
| Flag | Description |
|---|---|
--no-cache |
Force rebuild of the build tool binary |
--keep |
Keep generated bootstrap file for inspection |
--create NAME [CMD] |
Create a new target (function or shell) |
--completion [bash|zsh|fish] |
Print shell completion script |
--sync PACKAGE |
Import targets from a remote Go module |
--to-func NAME |
Convert string target to function |
--to-string NAME |
Convert function target to string command |
--source PATH |
Specify targ file location |
Use --create to add targets:
targ --create build # creates function target
targ --create tidy "go mod tidy" # creates shell command target
targ --create lint --deps=fmt,tidy # with dependencies
targ --create test --cache="**/*.go" # with cachingKebab-case names are converted to PascalCase (run-tests → RunTests).
Import targets from other Go modules with --sync:
targ --sync github.com/company/shared-targetsThis adds a blank import and a DeregisterFrom call to your targ file:
//go:build targ
package dev
import (
"github.com/toejough/targ"
_ "github.com/company/shared-targets"
)
func init() {
_ = targ.DeregisterFrom("github.com/company/shared-targets")
}The blank import triggers the remote package's init(), which registers its targets.
The DeregisterFrom call removes them by default, preventing name conflicts.
To use remote targets, edit the generated code:
import (
"github.com/toejough/targ"
targets "github.com/company/shared-targets"
)
func init() {
// Option A: Use all targets (remove DeregisterFrom)
// All targets from shared-targets are available
// Option B: Selective (keep DeregisterFrom, re-register what you want)
_ = targ.DeregisterFrom("github.com/company/shared-targets")
targ.Register(targets.Lint, targets.Test)
// Option C: Rename to avoid conflicts
_ = targ.DeregisterFrom("github.com/company/shared-targets")
targ.Register(targets.Test.Name("integration-test"))
}Re-running --sync updates the module version (like go get -u).
Targ caches compiled binaries in ~/.cache/targ/. The cache is invalidated when source files or go.mod/go.sum change.
targ --no-cache <command> # force rebuild
rm -rf ~/.cache/targ/ # clear all cached binaries