sho

package module
v0.0.0-...-0059ddc Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Mar 23, 2026 License: EUPL-1.2 Imports: 10 Imported by: 0

README

将 (Shō)

A simple, extensible task runner for automating workflows with Go.

Features

  • Simple task registration
  • Multiple task types, commands, shell or Go functions
  • Selective execution, run tasks by name, by tag, or mix and match
  • Support for interactive commands, proxies stdin/stdout/stderr for interactive prompts (like sudo passwords)
  • Type-safe, IDE support, tools - it's Go all the way down
  • Run tasks sequentially or in parallel
  • Binary caching, compiled task binaries are cached so repeated runs skip the Go toolchain entirely
  • User global tasks in addition to per-project tasks
  • Auto-resolution, sho walks up to the VCS root to find the nearest tasks/ directory

Quick Start

Install the sho CLI tool:

go install codeberg.org/zerodeps/sho/cmd/sho@latest

Scaffold a tasks directory in your project:

sho --init

This creates a tasks/ directory with its own go.mod that imports the sho library, plus a starter tasks.go template. Edit tasks/tasks.go to define your tasks, then run them:

sho           # list all tasks
sho hello     # run the "hello" task
sho -a        # run all tasks

The sho CLI provides quality-of-life tooling for working with the task runner, e.g. it auto-resolves the nearest tasks/ directory, compiles it on first use, and caches the binary so subsequent runs are instant:

sho [flags] [task|tag...]     Run tasks in the nearest tasks directory
sho -g [flags] [task|tag...]  Run tasks in the global tasks directory
sho --init                    Scaffold a tasks directory
sho --clean [-g]              Remove cached binary for the resolved tasks directory
sho --purge                   Remove all cached task binaries

sho --init scaffolds a tasks/ directory in the current project:

  • Always writes tasks/go.mod
  • Downloads the latest tasks/main.go entry-point and a starter tasks/tasks.go template (skipped if one already exists)

To reset a template file, delete it and re-run sho --init

Tasks directory resolution

When you run sho, it finds the tasks directory to use as follows:

  1. If -g/--global is set, use the user global directory (~/.config/sho/tasks/ on Linux, platform-specific elsewhere).
  2. Otherwise, walk up from the current directory to the nearest VCS root (.git, .hg, .svn, etc.) and find the nearest tasks/ subdirectory.
  3. If no VCS root is found, check for tasks/ in the current directory only.
  4. Fall back to the global user directory if no local directory is found.

Usage

Registering Tasks

Tasks are defined in one or more .go files and registered in each file's init() function:

// tasks.go
package main

func init() {
    Register(
        // Shell command task (uses sh -l -c, supports shell syntax)
        Task{
            Name:        "apt-update",
            Description: "Update APT packages",
            Command:     ShellCommand{"sudo apt update && sudo apt upgrade -y"},
            Tags:        []string{"system"},
        },
        // Go function task
        Task{
            Name:        "custom",
            Description: "Custom task",
            Command: FuncCommand{func(ctx context.Context) error {
                // Your custom Go code here
                return nil
            }},
        },
    )
}

Task, ExecCommand, ShellCommand, FuncCommand, and Register are provided as aliases in the generated main.go so task files need no extra imports.

See the examples folder.

Running Tasks
sho [flags] [name...]

Each positional name is resolved using the following logic:

  1. If the name matches a registered task, that task is run.
  2. Otherwise, if it matches a registered tag, all tasks carrying that tag are run.
  3. If it matches neither, an error is returned.

Use flags to skip the fallback and enforce a specific interpretation:

Flag Description
-l, --list List all tasks and tags
-a, --all Run all tasks in alphabetical order
--task Force name to be interpreted as a task (repeatable)
-t, --tag Force name to be interpreted as a tag (repeatable)
-p, --parallel Run tasks in parallel
# Smart resolution: runs "build" task, then expands "ci" tag
sho build ci

# Force task-only: error if "ci" is not a task name
sho --task ci

# Force tag-only: error if "build" is not a tag name
sho --tag build
Task Types
Exec Commands

ExecCommand runs an external program directly, without a shell. Arguments are passed verbatim — no shell expansion, globbing, or variable substitution. This is the safest choice when you do not need shell features and cannot trust input.

Register(Task{
    Name:        "hello",
    Description: "Say hello",
    Command:     ExecCommand{"echo", "Hello, World!"},
})
Shell Commands

ShellCommand passes a single string to sh -c, giving you full shell syntax: &&, ||, pipes, redirections, variable expansion, and globbing. Environment variables from the calling process (including $PATH) are available to the shell.

Register(Task{
    Name:        "update",
    Description: "Update packages",
    Command:     ShellCommand{"apt update && apt upgrade -y"},
})

Security note: because the string is passed directly to the shell, interpolating untrusted user input creates a shell-injection risk. Use ExecCommand when you need to pass user-supplied values as arguments.

Go Functions

FuncCommand wraps any Go function as a task. The function receives a context.Context for cancellation awareness.

Register(Task{
    Name:        "complex",
    Description: "Complex task",
    Command: FuncCommand{func(ctx context.Context) error {
        // Complex logic here
        fmt.Println("Doing something complex")
        return nil
    }},
})
Environment Filtering

By default, tasks inherit the full environment of the calling process. The Env field on a Task restricts which variables are passed to spawned subprocesses: only the listed variable names are looked up from the current process and forwarded.

Register(Task{
    Name:        "deploy",
    Description: "Deploy to production",
    Command:     ShellCommand{"./deploy.sh"},
    Env:         []string{"PATH", "HOME", "CI_TOKEN"},
})
  • Env: nil (or omitted) — subprocess inherits the full environment (default).
  • Env: []string{"PATH", "HOME"} — subprocess receives only PATH and HOME.
  • Env: []string{"NONE"} — by convention, passes an empty environment (NONE is not a real variable, so nothing is forwarded).

This mechanism is provided to restrict commands from accessing sensitive variables, like API secrets. There will be no support for setting environment variables values directly in task files to guarantee their safety for storing in VCS and share.

Note: Env filtering applies to ExecCommand and ShellCommand. A FuncCommand runs in-process and has access to os.Environ() regardless.

How It Works

  1. Resolutionsho locates the nearest tasks/ directory by walking up to the VCS root, falling back to the global directory.
  2. Build & cache — on first run, sho compiles the tasks directory into a binary and caches it. Subsequent runs skip the Go toolchain entirely.
  3. Initialization — all init() functions in the tasks binary run before main(), registering tasks with the default runner.
  4. Filteringmain() resolves names to tasks: positional args match task names first, then tag names. --task enforces task-only lookup; --tag/-t enforces tag-only lookup.
  5. Execution — the task selection is passed to a Runner to run sequentially or in parallel.
  6. Error handling — failed tasks are captured and reported; execution continues with remaining tasks.

License

EUPL v1.2

Documentation

Overview

Package sho provides a simple, extensible task runner for automating workflows with Go.

Tasks are defined using the Command interface and registered via init() functions in a tasks/ subdirectory. The runner supports both shell commands and Go functions, with full interactive terminal support for commands like sudo.

Index

Examples

Constants

This section is empty.

Variables

View Source
var (
	ErrNotFound = errors.New("not found")
	ErrUnknown  = errors.New("unknown task or tag")
)
View Source
var (
	ErrNoTasksProvided    = errors.New("no tasks provided")
	ErrTaskNameEmpty      = errors.New("task name cannot be empty")
	ErrTaskNameWhitespace = errors.New("task name cannot contain whitespace")
	ErrTaskCommandNil     = errors.New("task command cannot be nil")
	ErrTagNameEmpty       = errors.New("tag name cannot be empty")
	ErrTagNameWhitespace  = errors.New("tag name cannot contain whitespace")
	ErrTaskDuplicate      = errors.New("task already registered")
)
View Source
var ErrEmptyCommand = errors.New("empty command")

ErrEmptyCommand is returned by Command.Run when the command slice is empty.

Functions

func PrintHelp

func PrintHelp(fset *flag.FlagSet)

PrintHelp writes the formatted flag list from fset to out.

Each flag is printed with a single-dash prefix for one-letter names and a double-dash prefix for longer names. Flags with no default value show a "value" placeholder.

Usage:

sho.PrintHelp(fset, fset.Output())

func Register

func Register(tasks ...Task)

Register registers a task with the default runner.

Panics if registration fails (e.g., duplicate task name, invalid name). This is intended for use in init() functions where errors cannot be returned.

Usage:

func init() {
	Register(Task{
		Name:        "deploy",
		Description: "Deploy to production",
		Command:     ExecCommand{"./deploy.sh"},
		Tags:        []string{"production"},
	})
}

func Run

func Run()

Run is the CLI entry point for a shō task runner.

Call this from the main function of your tasks directory:

func main() { sho.Run() }

Types

type Command

type Command interface {
	Run(ctx context.Context) error
}

Command is the interface that wraps the Run method.

Run executes the command and returns any error that occurred. The ctx carries cancellation signals and, when the enclosing Task specifies an Env whitelist, a filtered environment for subprocesses. Implementations should connect to os.Stdin, os.Stdout, and os.Stderr to support interactive commands.

type ExecCommand

type ExecCommand []string

ExecCommand runs an external program directly, without a shell.

The first element is the executable and remaining elements are arguments. Arguments are passed verbatim; no shell expansion, globbing, or variable substitution occurs. Use this when you do not need shell features and want to avoid shell injection.

Example:

cmd := ExecCommand{"echo", "Hello, World!"}
Example
cmd := ExecCommand{"echo", "Hello, World!"}
cmd.Run(context.Background())
Output:
$ echo Hello, World!
Hello, World!

func (ExecCommand) Run

func (s ExecCommand) Run(ctx context.Context) error

Run executes the command with full terminal access.

ctx carries cancellation signals; if the enclosing Task sets Env, the runner embeds a filtered environment in ctx that is applied to the subprocess.

It prints the command being executed, then runs it with stdin/stdout/stderr connected to the terminal to support interactive commands like sudo.

Returns ErrEmptyCommand if command slice is empty. Returns the error from exec.CommandContext.Run if command execution fails.

Usage:

cmd := ExecCommand{"ls", "-la"}
if err := cmd.Run(context.Background()); err != nil {
	log.Fatal(err)
}

type Flags

type Flags struct {
	All      bool
	List     bool
	Parallel bool
	Tags     []string
	Tasks    []string
}

Flags holds the values for all shō runner CLI flags after parsing.

It is returned by SetupFlags and populated once the associated FlagSet is parsed.

func NewFlags

func NewFlags(fset *flag.FlagSet) *Flags

NewFlags registers all shō runner CLI flags on fset and returns a *Flags whose fields are populated once fset.Parse is called.

It does not configure fset.Usage; callers should set their own.

Usage:

fset := flag.NewFlagSet("tasks", flag.ExitOnError)
flg := NewFlags(fset)
fset.Parse(os.Args[1:])
// flg.All, flg.List, flg.Parallel, flg.Tags, flg.Tasks are now set

func (Flags) String

func (f Flags) String() string

String returns the flag values as a space-separated CLI argument string, using short flag names. Only flags set to a non-zero value are included.

Implements fmt.Stringer.

Usage:

fmt.Println(flg)              // "-a -p -t ci"
strings.Fields(flg.String())  // []string{"-a", "-p", "-t", "ci"}

type FuncCommand

type FuncCommand struct {
	Command func(context.Context) error
}

FuncCommand is a Command implementation that wraps a Go function.

This allows tasks to be defined as arbitrary Go code rather than shell commands. The function receives the context for cancellation awareness.

Example:

cmd := FuncCommand{func(ctx context.Context) error {
	fmt.Println("Task executed")
	return nil
}}
Example
task := FuncCommand{func(ctx context.Context) error {
	fmt.Println("Task executed")
	return nil
}}
task.Run(context.Background())
Output:
Task executed

func (FuncCommand) Run

func (f FuncCommand) Run(ctx context.Context) error

Run executes the wrapped function.

Returns error from wrapped function.

Usage:

cmd := FuncCommand{func(ctx context.Context) error {
	return performComplexOperation()
}}
if err := cmd.Run(context.Background()); err != nil {
	log.Fatal(err)
}

type Runner

type Runner struct {
	// contains filtered or unexported fields
}

Runner manages a collection of named tasks optionally organised by tags.

Example (Run)
r := NewRunner()

// Register multiple tasks
_ = r.Register(Task{
	Name:        "task1",
	Description: "First task",
	Command:     ExecCommand{"echo", "Task 1"},
})
_ = r.Register(Task{
	Name:        "task2",
	Description: "Second task",
	Command:     ExecCommand{"echo", "Task 2"},
})

// Run only task1
r.Run("task1")
Output:
Executing tasks...

[1/1] task1: First task
$ echo Task 1
Task 1

✓ task1 completed successfully

Summary: 1 succeeded
Example (Run_all)
r := NewRunner()

// Register tasks
_ = r.Register(Task{
	Name:        "hello",
	Description: "Say hello",
	Command:     ExecCommand{"echo", "Hello"},
})
_ = r.Register(Task{
	Name:        "world",
	Description: "Say world",
	Command:     ExecCommand{"echo", "World"},
})

// Run all tasks by collecting task names from iterator
var allTasks []string
for task := range r.Tasks() {
	allTasks = append(allTasks, task.Name)
}
r.Run(allTasks...)
Output:
Executing tasks...

[1/2] hello: Say hello
$ echo Hello
Hello

✓ hello completed successfully

[2/2] world: Say world
$ echo World
World

✓ world completed successfully

Summary: 2 succeeded
Example (Run_failure)
r := NewRunner()

// Register a task that will fail
_ = r.Register(Task{
	Name:        "fail",
	Description: "Failing task",
	Command:     ExecCommand{"false"},
})

// Run the failing task
r.Run("fail")
Output:
Executing tasks...

[1/1] fail: Failing task
$ false

Summary: 0 succeeded, 1 failed

func NewRunner

func NewRunner() *Runner

NewRunner creates a new Runner with empty task and tag registries.

Usage:

r := NewRunner()
r.Register(Task{
	Name:        "build",
	Description: "Build the project",
	Command:     ExecCommand{"make", "build"},
})
r.Run("build")

func (*Runner) Register

func (r *Runner) Register(tasks ...Task) error

Register adds one or more tasks to the runner.

Errors from invalid tasks are collected and returned together as a joined error, so callers see every problem in a single call. Valid tasks are registered even if others in the same call fail.

The task name must be non-empty, free of whitespace, and unique within this runner. Description and Tags are optional. If tags are provided, tag names must be non-empty and free of whitespace.

Returns ErrNoTasksProvided if no tasks are provided. Returns ErrTaskNameEmpty if a task name is empty. Returns ErrTaskNameWhitespace if a task name contains whitespace. Returns ErrTaskCommandNil if a task command is nil. Returns ErrTagNameEmpty if a tag name is empty. Returns ErrTagNameWhitespace if a tag name contains whitespace. Returns ErrTaskDuplicate if a task with the same name is already registered.

Usage:

r := NewRunner()
err := r.Register(
	Task{
		Name:        "build",
		Description: "Build the project",
		Command:     ExecCommand{"go", "build", "./..."},
	},
	Task{
		Name:        "deploy",
		Description: "Deploy to production",
		Command:     ExecCommand{"./deploy.sh"},
		Tags:        []string{"production", "critical"},
	},
)
if err != nil {
	log.Fatal(err)
}
Example (Function)
r := NewRunner()

// Register a function command task
_ = r.Register(Task{
	Name:        "greet",
	Description: "Greet user",
	Command: FuncCommand{func(ctx context.Context) error {
		fmt.Println("Greetings from Go function!")
		return nil
	}},
})

// Execute via run
r.Run("greet")
Output:
Executing tasks...

[1/1] greet: Greet user
Greetings from Go function!

✓ greet completed successfully

Summary: 1 succeeded
Example (Shell)
r := NewRunner()

// Register a shell command task
_ = r.Register(Task{
	Name:        "hello",
	Description: "Say hello",
	Command:     ExecCommand{"echo", "Hello from task"},
})

// Execute via run
r.Run("hello")
Output:
Executing tasks...

[1/1] hello: Say hello
$ echo Hello from task
Hello from task

✓ hello completed successfully

Summary: 1 succeeded
Example (Validation)
r := NewRunner()

// Attempt to register task with empty name
err := r.Register(Task{
	Name:        "",
	Description: "Empty name task",
	Command:     ExecCommand{"echo", "test"},
})
if err != nil {
	fmt.Println("Error:", err)
}

// Attempt to register task with whitespace in name
err = r.Register(Task{
	Name:        "my task",
	Description: "Whitespace name",
	Command:     ExecCommand{"echo", "test"},
})
if err != nil {
	fmt.Println("Error:", err)
}
Output:
Error: task name cannot be empty
Error: task "my task": task name cannot contain whitespace

func (*Runner) Run

func (r *Runner) Run(taskNames ...string) error

Run executes the specified tasks sequentially.

At least one task name must be provided. Tasks run with full terminal access (stdin/stdout/stderr connected). Failed tasks are logged in red to stderr, but execution continues with remaining tasks.

Returns error if no task names provided. Returns errors.Join of all task failures (including not found errors).

Usage:

r := NewRunner()
r.Register(Task{Name: "test", Description: "Run tests", Command: ExecCommand{"go", "test", "./..."}})
r.Register(Task{Name: "build", Description: "Build", Command: ExecCommand{"go", "build", "./..."}})

if err := r.Run("test", "build"); err != nil {
	log.Fatal(err)
}

func (*Runner) RunParallel

func (r *Runner) RunParallel(taskNames ...string) error

RunParallel executes the specified tasks concurrently.

At least one task name must be provided. Tasks run in parallel, with the Go runtime managing scheduling across available CPUs. Each task gets full terminal access, though output may be interleaved. Failed tasks are collected and returned as a joined error.

Returns error if no task names provided. Returns errors.Join of all task failures (including not found errors).

Usage:

r := NewRunner()
r.Register(Task{Name: "lint", Description: "Lint", Command: ExecCommand{"golint", "./..."}})
r.Register(Task{Name: "test", Description: "Test", Command: ExecCommand{"go", "test", "./..."}})

if err := r.RunParallel("lint", "test"); err != nil {
	log.Fatal(err)
}
Example
r := NewRunner()

// Register multiple tasks
_ = r.Register(Task{
	Name:        "task1",
	Description: "First task",
	Command:     ExecCommand{"echo", "Task 1"},
})
_ = r.Register(Task{
	Name:        "task2",
	Description: "Second task",
	Command:     ExecCommand{"echo", "Task 2"},
})
_ = r.Register(Task{
	Name:        "task3",
	Description: "Third task",
	Command:     ExecCommand{"echo", "Task 3"},
})

// Run tasks in parallel
// Note: Output order is non-deterministic in parallel execution
r.RunParallel("task1", "task2", "task3")
// Output varies due to parallel execution

func (*Runner) Tag

func (r *Runner) Tag(name string) []Task

Tag returns all tasks with the specified tag.

Tasks are returned in registration order as Task values (copied to prevent modification). Returns nil if the tag doesn't exist.

Usage:

r := NewRunner()
r.Register(Task{Name: "test", Description: "Test", Command: ShellCommand{"go", "test"}, Tags: []string{"ci"}})

tasks := r.Tag("ci")
for _, task := range tasks {
	fmt.Println(task.Name)
}

func (*Runner) Tags

func (r *Runner) Tags() func(yield func(string) bool)

Tags returns an iterator over all registered tag names.

The iterator yields tag names in sorted order.

Usage:

r := NewRunner()
r.Register(Task{Name: "t1", Description: "Task 1", Command: ShellCommand{"echo"}, Tags: []string{"quick"}})

for tag := range r.Tags() {
	fmt.Println(tag)
}

func (*Runner) Task

func (r *Runner) Task(name string) (Task, bool)

Task returns the task with the specified name.

Returns the task by value (with cloned Tags slice) to prevent modification. Returns a zero-value Task and false if the task doesn't exist.

Usage:

r := NewRunner()
r.Register(Task{Name: "build", Description: "Build project", Command: ShellCommand{"make"}})

if task, ok := r.Task("build"); ok {
	fmt.Println(task.Description)
}

func (*Runner) Tasks

func (r *Runner) Tasks() func(yield func(Task) bool)

Tasks returns an iterator over all registered tasks.

The iterator yields Task values in sorted order by name. Task values are returned by value to prevent modification.

Usage:

r := NewRunner()
r.Register(Task{Name: "build", Description: "Build", Command: ShellCommand{"make"}})

for task := range r.Tasks() {
	fmt.Printf("%s: %s\n", task.Name, task.Description)
}
Example
r := NewRunner()

// Register tasks
_ = r.Register(Task{
	Name:        "hello",
	Description: "Say hello",
	Command:     ExecCommand{"echo", "Hello"},
})
_ = r.Register(Task{
	Name:        "goodbye",
	Description: "Say goodbye",
	Command:     ExecCommand{"echo", "Goodbye"},
})

// Iterate over all tasks
for task := range r.Tasks() {
	fmt.Printf("%s: %s\n", task.Name, task.Description)
}
Output:
goodbye: Say goodbye
hello: Say hello

type ShellCommand

type ShellCommand struct {
	Command string
}

ShellCommand runs a command string through the system shell (sh -c).

The Command field is a single shell command string interpreted by sh. Shell syntax such as &&, ||, pipes, redirections, and variable expansion is fully supported. Environment variables from the calling process (including $PATH) are available to the shell.

Note: because the string is passed directly to the shell, interpolating untrusted user input creates a shell-injection risk. Use ExecCommand when you need to pass user-supplied values as arguments.

Example:

cmd := ShellCommand{"echo $PATH && echo Done"}
Example
cmd := ShellCommand{"echo Hello && echo World"}
cmd.Run(context.Background())
Output:
$ echo Hello && echo World
Hello
World

func (ShellCommand) Run

func (s ShellCommand) Run(ctx context.Context) error

Run executes the shell command string with full terminal access.

ctx carries cancellation signals; if the enclosing Task sets Env, the runner embeds a filtered environment in ctx that is applied to the subprocess.

It prints the command string, then runs it via sh -c with stdin/stdout/stderr connected to the terminal.

Returns ErrEmptyCommand if Command is empty. Returns the error from exec.CommandContext.Run if command execution fails.

Usage:

cmd := ShellCommand{"apt update && apt upgrade -y"}
if err := cmd.Run(context.Background()); err != nil {
	log.Fatal(err)
}

type Task

type Task struct {
	Name        string
	Description string   // Optional - empty string is valid
	Tags        []string // Optional - nil or empty slice is valid
	Env         []string // Optional - nil inherits full env; non-nil is a whitelist of variable names
	Command     Command
}

Task represents a task that can be registered and executed by a Runner.

Tasks are identified by name and can optionally be organized with tags.

Example (Env)
r := NewRunner()

// Only PATH is proxied; SHO_SENTINEL (if set) is stripped from the env.
_ = r.Register(Task{
	Name:    "filtered",
	Command: ShellCommand{`[ -z "$SHO_SENTINEL" ] && echo ok`},
	Env:     []string{"PATH"},
})

r.Run("filtered")
Output:
Executing tasks...

[1/1] filtered
$ [ -z "$SHO_SENTINEL" ] && echo ok
ok

✓ filtered completed successfully

Summary: 1 succeeded

Directories

Path Synopsis
cmd
sho command
Package main provides the sho CLI tool, a quality-of-life companion for the shō task runner.
Package main provides the sho CLI tool, a quality-of-life companion for the shō task runner.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL