Skip to content
/ targ Public

a t[arg]et runner. parse args, run build targets.

Notifications You must be signed in to change notification settings

toejough/targ

Repository files navigation

Targ

Targ logo

Build CLIs and run build targets with minimal configuration. Inspired by Mage, go-arg, and Cobra.

Quick Reference

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"

Installation

# Build tool (run targets)
go install github.com/toejough/targ/cmd/targ@latest

# Library (embed in your binary)
go get github.com/toejough/targ

Design Principles

No Surprises

Targ has three layers of configuration that could interact unexpectedly:

  1. Target definition - .Cache(), .Watch(), .Deps() on the target
  2. CLI flags - --cache, --watch, --times, etc.
  3. 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 works

This 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.

From Build Targets to Dedicated CLI

Targ makes it easy to start with simple build targets and evolve to a full CLI. The same code works in both modes.

Stage 1: String Commands

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")

Stage 2: Programmatic Flags

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/... --cover

Stage 3: Dedicated Binary

Ready 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 2
go build -o mytool .
./mytool build --verbose
./mytool test --cover

Execution Control

Builder 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

Tags

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"

Map Args

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=8080

Embedded Structs

Share 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.

Groups

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=./...

Function Signatures

Target functions support these signatures:

  • func()
  • func() error
  • func(ctx context.Context)
  • func(ctx context.Context) error
  • func(args T) where T is a struct
  • func(args T) error
  • func(ctx context.Context, args T)
  • func(ctx context.Context, args T) error

Command Names

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")

Dependencies

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)   // parallel

Deps-only targets run dependencies without their own function:

var all = targ.Targ().Name("all").Deps(build, test, lint)

Shell Helpers

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 output

For 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", "./...")

File Checks

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
}

Watch Mode

Manual Watch

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", "./...")
    })
}

Builder Watch

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.

Shell Completion

source <(your-binary --completion)         # Detects current shell
source <(your-binary --completion bash)    # Bash
source <(your-binary --completion zsh)     # Zsh
your-binary --completion fish | source     # Fish

Supports commands, subcommands, flags, and enum values.

Example Help Output

$ 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

Dynamic Tag Options

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.

Patterns

Conditional Build

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", "./...")
}

CI Pipeline

// 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)

Testing Commands

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)
    }
}

Variadic Positional Args

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.txt

Repeated Flags

Use 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"]

Ordered Repeated Flags

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

When to Use Targ

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.

Build Tool Flags

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

Quick Target Scaffolding

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 caching

Kebab-case names are converted to PascalCase (run-testsRunTests).

Remote Targets

Import targets from other Go modules with --sync:

targ --sync github.com/company/shared-targets

This 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).

Cache Management

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

About

a t[arg]et runner. parse args, run build targets.

Resources

Stars

Watchers

Forks

Packages

No packages published

Contributors 2

  •  
  •  

Languages