sho

package module
v0.0.0-...-e29505c Latest Latest
Warning

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

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

README

将 (Shō)

A simple, extensible task runner for automating system updates and custom tasks using Go.

Features

  • Simple task registration
  • Multiple task types, shell commands 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
  • Global tasks directory, per-user tasks at ~/.config/sho/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

sho CLI

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

Installation
go install codeberg.org/zerodeps/sho/cmd/sho@latest
Usage
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

All shō runner flags are forwarded to the compiled tasks binary; only -g/--global is consumed by the sho tool itself.

Commands
sho --init

Scaffolds a tasks/ directory in the current project:

sho --init
  • Always writes tasks/go.mod (module tasks, requires codeberg.org/zerodeps/sho)
  • Downloads tasks/main.go entry-point (skipped if one already exists)
  • Downloads a starter tasks/tasks.go template (skipped if one already exists)

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

rm tasks/tasks.go && sho --init
sho --clean

Removes the compiled binary cached for the resolved tasks directory:

sho --clean      # clean cache for the nearest local tasks directory
sho --clean -g   # clean cache for the global tasks directory
sho --purge

Removes all cached task binaries:

sho --purge
Tasks directory resolution

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

  1. If -g/--global is set, use the 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 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
        Task{
            Name:        "apt-update",
            Description: "Update APT packages",
            Command:     ShellCommand{"sudo", "sh", "-c", "apt update && apt upgrade -y"},
            Tags:        []string{"system"},
        },
        // Go function task
        Task{
            Name:        "custom",
            Description: "Custom task",
            Command: FuncCommand(func() error {
                // Your custom Go code here
                return nil
            }),
        },
    )
}

Task, 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
Shell Commands
Register(Task{
    Name:        "hello",
    Description: "Say hello",
    Command:     ShellCommand{"echo", "Hello, World!"},
})
Go Functions
Register(Task{
    Name:        "complex",
    Description: "Complex task",
    Command: FuncCommand(func() error {
        // Complex logic here
        fmt.Println("Doing something complex")
        return nil
    }),
})

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 shō, a simple, extensible task runner for automating system updates and custom tasks.

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.

Typical usage in a tasks/ directory:

go run .              # list all registered tasks (implicit)
go run . -l           # list all registered tasks (explicit)
go run . task1 task2  # run specific tasks
go run . -a           # run all tasks

Index

Examples

Constants

This section is empty.

Variables

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

Functions

func Main

func Main()

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

Call this from the main function of your tasks directory:

func main() { sho.Main() }

func PrintFlags

func PrintFlags(fset *flag.FlagSet, out io.Writer)

PrintFlags 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.PrintFlags(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:     ShellCommand{"./deploy.sh"},
		Tags:        []string{"production"},
	})
}

Types

type Command

type Command interface {
	Run() error
}

Command is the interface that wraps the Run method.

Run executes the command and returns any error that occurred. Implementations should connect to os.Stdin, os.Stdout, and os.Stderr to support interactive commands.

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 SetupFlags

func SetupFlags(fset *flag.FlagSet) *Flags

SetupFlags 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 := sho.SetupFlags(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 func() 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.

Example:

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

Task executed

func (FuncCommand) Run

func (f FuncCommand) Run() error

Run executes the wrapped function.

Returns error from wrapped function.

Usage:

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

type Runner

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

Runner manages a collection of named tasks and executes them on demand.

Tasks can be registered via Register and executed via Run. The zero value is ready to use (maps are lazily initialized).

Example (Run)
r := NewRunner()

// Register multiple tasks
_ = r.Register(Task{
	Name:        "task1",
	Description: "First task",
	Command:     ShellCommand{"echo", "Task 1"},
})
_ = r.Register(Task{
	Name:        "task2",
	Description: "Second task",
	Command:     ShellCommand{"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:     ShellCommand{"echo", "Hello"},
})
_ = r.Register(Task{
	Name:        "world",
	Description: "Say world",
	Command:     ShellCommand{"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:     ShellCommand{"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:     ShellCommand{"make", "build"},
})
r.Run("build")

func (*Runner) Register

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

Register adds one or more tasks to the runner.

All tasks are attempted; 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:     ShellCommand{"go", "build", "./..."},
	},
	Task{
		Name:        "deploy",
		Description: "Deploy to production",
		Command:     ShellCommand{"./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() 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:     ShellCommand{"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:     ShellCommand{"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:     ShellCommand{"echo", "test"},
})
if err != nil {
	fmt.Println("Error:", err)
}
Output:

Error: task name cannot be empty
Error: task name "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: ShellCommand{"go", "test"}})
r.Register(Task{Name: "build", Description: "Build", Command: ShellCommand{"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: ShellCommand{"golint"}})
r.Register(Task{Name: "test", Description: "Test", Command: ShellCommand{"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:     ShellCommand{"echo", "Task 1"},
})
_ = r.Register(Task{
	Name:        "task2",
	Description: "Second task",
	Command:     ShellCommand{"echo", "Task 2"},
})
_ = r.Register(Task{
	Name:        "task3",
	Description: "Third task",
	Command:     ShellCommand{"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:     ShellCommand{"echo", "Hello"},
})
_ = r.Register(Task{
	Name:        "goodbye",
	Description: "Say goodbye",
	Command:     ShellCommand{"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 []string

ShellCommand represents a shell command as a slice of strings.

The first element is the command and remaining elements are arguments.

Example:

cmd := ShellCommand{"echo", "Hello, World!"}
Example
cmd := ShellCommand{"echo", "Hello, World!"}
cmd.Run()
Output:

$ echo Hello, World!
Hello, World!

func (ShellCommand) Run

func (s ShellCommand) Run() error

Run executes the shell command with full terminal access.

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

Returns error if command slice is empty. Returns error from exec.Command.Run if command execution fails.

Usage:

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

type Task

type Task struct {
	Name        string
	Description string // Optional - empty string is valid
	Command     Command
	Tags        []string // Optional - nil or empty slice is valid
}

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.

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