Pocket User Guide

June 12, 2026 · View on GitHub

This guide covers everything you need to know to use Pocket effectively, from defining your first task to building complex CI pipelines.

Table of Contents


Tasks

Tasks are the fundamental units of work in Pocket—linting, testing, building, deploying. Each task has a name, description, optional flags, and a body that defines what it does.

Defining Tasks

Tasks are created as struct literals:

var Hello = &pk.Task{
    Name:  "hello",
    Usage: "print a greeting",
    Do: func(ctx context.Context) error {
        fmt.Println("Hello!")
        return nil
    },
}

For composed tasks, use the Body field instead of Do:

var Lint = &pk.Task{
    Name:  "lint",
    Usage: "run linters",
    Body:  pk.Serial(Install, lintCmd()),
}

The Do Helper

The pk.Do function wraps a simple Go function func(context.Context) error into a Runnable. This is the most common way to implement task logic.

pk.Do(func(ctx context.Context) error {
    // Your task logic here
    return nil
})

Hidden Tasks

If a task is only intended to be used as a dependency or called programmatically, hide it from CLI help output:

var InternalTask = &pk.Task{
    Name:   "internal",
    Usage:  "...",
    Body:   body,
    Hidden: true,
}

Hidden tasks still execute when part of a composition tree—they just don't appear in ./pok -h.

Manual Tasks

By default, tasks in Config.Auto run when you execute bare ./pok. If you want a task to only run when explicitly named (e.g., ./pok deploy), add it to Config.Manual:

var Config = &pk.Config{
    Auto:   pk.Serial(Lint, Test),
    Manual: []pk.Runnable{Deploy},
}

Task Flags

Flags are defined as a struct with flag and usage struct tags, and accessed at runtime with run.GetFlags[T]:

type DeployFlags struct {
    Env string `flag:"env" usage:"target environment"`
}

var Deploy = &pk.Task{
    Name:  "deploy",
    Usage: "deploy the app",
    Flags: DeployFlags{Env: "staging"},
    Do: func(ctx context.Context) error {
        f := run.GetFlags[DeployFlags](ctx)
        fmt.Printf("Deploying to %s...\n", f.Env)
        return nil
    },
}

Run it with:

./pok deploy -env prod

Supported field types: string, bool, int, int64, uint, uint64, float64, time.Duration, and pointer variants (*string, *bool, etc.) for optional overrides.

Pointer fields use nil = not set (inherits default), non-nil = explicit override. This is useful with pk.WithFlags — you only set what you want to change, and nil fields are skipped during diffing.

The struct approach provides compile-time safety. Field access via the returned struct means typos are caught by the compiler, not at runtime.

Suppressing Headers

By default, tasks print a :: taskname header before execution. For tasks that output machine-readable data (e.g., JSON), suppress the header:

var Export = &pk.Task{
    Name:       "export",
    Usage:      "output JSON data",
    Body:       body,
    HideHeader: true,
}

Executing Commands

The Exec Helper

run.Exec runs external commands with proper output handling:

pk.Do(func(ctx context.Context) error {
    return run.Exec(ctx, "go", "test", "./...")
})

Output behavior:

  • With -v flag: output streams to stdout/stderr in real-time
  • Without -v flag: output is captured and shown if:
    • The command fails, OR
    • The output contains warnings (detects: warn, deprecat, notice, caution, error)

This keeps CI logs clean while ensuring warnings and deprecation notices are never silently hidden.

To override the default notice patterns or disable detection entirely, use pk.WithNoticePatterns(...) in your WithOptions scope:

pk.WithOptions(
    NoisyTask,
    pk.WithNoticePatterns(), // disable notice detection entirely
)

Other features:

  • Respects context cancellation (graceful shutdown)
  • Adds .pocket/bin to the command's PATH
  • Sends SIGINT on cancellation (Unix), allowing graceful cleanup

Output Functions

Use these instead of fmt.Print* to ensure correct output handling in parallel contexts:

FunctionDescription
run.Printf(ctx, format, args...)Formatted output to stdout
run.Println(ctx, args...)Line output to stdout
run.Errorf(ctx, format, args...)Formatted output to stderr
pk.Do(func(ctx context.Context) error {
    run.Printf(ctx, "Processing %d items...\n", count)
    return nil
})

Tool Management

One of Pocket's strengths is automated tool installation. Each tool package owns its complete lifecycle: installation, versioning, and making itself available for execution.

Tool Availability Patterns

Tools make themselves available through one of three patterns:

PatternWhen to useHow tasks invoke
SymlinkNative binaries (Go, Rust, C)run.Exec(ctx, "tool", ...)
Tool ExecStandalone runtime-dependent toolstool.Exec(ctx, ...)
Runtime RunProject-managed tools (pyproject.toml, package.json)uv.Run(ctx, opts, "tool", ...)

Symlink pattern: The tool downloads a self-contained binary to .pocket/tools/<tool>/<version>/ and symlinks it to .pocket/bin/. Since run.Exec adds .pocket/bin/ to PATH, tasks can invoke it by name.

Tool Exec pattern: For tools that require a runtime (Node.js, Python), the tool package exposes an Exec() function that handles runtime invocation internally. No symlink is created because the tool's shebang (#!/usr/bin/env node) would fail without the runtime on system PATH.

Runtime Run pattern: For tools defined in the project's dependency file (pyproject.toml, package.json), use the runtime's Run() function directly. The project controls tool versions, not Pocket.

Go Tools

Use golang.Install for Go-based tools:

import (
    "github.com/fredrikaverpil/pocket/pk/run"
    "github.com/fredrikaverpil/pocket/tools/golang"
)

var installLint = &pk.Task{
    Name:   "install:golangci-lint",
    Usage:  "install linter",
    Body:   golang.Install("github.com/golangci/golangci-lint/v2/cmd/golangci-lint", "v2.1.6"),
    Hidden: true,
    Global: true,
}

var Lint = &pk.Task{
    Name:  "lint",
    Usage: "run golangci-lint",
    Body: pk.Serial(
        installLint,
        pk.Do(func(ctx context.Context) error {
            return run.Exec(ctx, "golangci-lint", "run")
        }),
    ),
}

Install tasks use Hidden: true and Global: true:

  • Hidden: Excludes from CLI help (internal implementation detail)
  • Global: Deduplicates by name only, ignoring path context

Without Global: true, if Lint runs in multiple paths (via WithDetect), the install would run once per path. With Global: true, it runs once total.

Custom Tools

For non-Go tools, use the Download API to fetch binaries from GitHub releases or other sources. Import "github.com/fredrikaverpil/pocket/pk/download".

import "github.com/fredrikaverpil/pocket/pk/download"

var installStyLua = &pk.Task{
    Name:   "install:stylua",
    Usage:  "install StyLua formatter",
    Hidden: true,
    Global: true,
    Body: download.Download(
        fmt.Sprintf(
            "https://github.com/JohnnyMorganz/StyLua/releases/download/v%s/stylua-%s-%s.zip",
            "2.0.2",
            pk.HostOS(),
            pk.ArchToX8664(pk.HostArch()),
        ),
        download.WithDestDir(pk.FromToolsDir("stylua", "2.0.2")),
        download.WithFormat("zip"),
        download.WithExtract(download.WithExtractFile(pk.BinaryName("stylua"))),
        download.WithSymlink(),
        download.WithSkipIfExists(pk.FromToolsDir("stylua", "2.0.2", pk.BinaryName("stylua"))),
    ),
}

Download API

download.Download creates a Runnable that fetches a URL and optionally extracts it.

func Download(url string, opts ...Opt) pk.Runnable

Download Options:

OptionDescription
WithDestDir(dir)Destination directory for extraction
WithFormat(format)Archive format: "tar.gz", "tar", "zip", ""
WithExtract(opt)Add extraction options (see Extract API)
WithSymlink()Create symlink in .pocket/bin/ after extraction
WithSkipIfExists(path)Skip download if the specified file exists

Extract API

Extraction options control how archives are unpacked:

OptionDescription
WithExtractFile(name)Extract only the specified file (by base name)
WithRenameFile(src, dest)Extract a file and rename it
WithFlatten()Flatten directory structure to destDir root

Standalone extraction functions:

func ExtractTarGz(src, destDir string, opts ...ExtractOpt) error
func ExtractTar(src, destDir string, opts ...ExtractOpt) error
func ExtractZip(src, destDir string, opts ...ExtractOpt) error

Python Tools

Pocket provides Python tooling via the tools/uv and tasks/python packages. Tools are installed from the project's pyproject.toml into version-specific venvs under .pocket/venvs/.

Using the Python Task Bundle

The tasks/python package provides ready-to-use tasks for common Python workflows:

import (
    "github.com/fredrikaverpil/pocket/pk"
    "github.com/fredrikaverpil/pocket/tasks/python"
)

var Config = &pk.Config{
    Auto: pk.Serial(
        // Format, lint, typecheck, and test with Python 3.9 (with coverage)
        pk.WithOptions(
            python.Tasks(),
            pk.WithNameSuffix("3.9"),
            pk.WithFlags(python.FormatFlags{Python: "3.9"}),
            pk.WithFlags(python.LintFlags{Python: "3.9"}),
            pk.WithFlags(python.TypecheckFlags{Python: "3.9"}),
            pk.WithFlags(python.TestFlags{Python: "3.9", Coverage: true}),
            pk.WithDetect(python.Detect()),
        ),
        // Test against remaining Python versions (without coverage)
        pk.WithOptions(
            pk.Parallel(
                pk.WithOptions(python.Test, pk.WithNameSuffix("3.10"), pk.WithFlags(python.TestFlags{Python: "3.10"})),
                pk.WithOptions(python.Test, pk.WithNameSuffix("3.11"), pk.WithFlags(python.TestFlags{Python: "3.11"})),
                pk.WithOptions(python.Test, pk.WithNameSuffix("3.12"), pk.WithFlags(python.TestFlags{Python: "3.12"})),
                pk.WithOptions(python.Test, pk.WithNameSuffix("3.13"), pk.WithFlags(python.TestFlags{Python: "3.13"})),
            ),
            pk.WithDetect(python.Detect()),
        ),
    ),
}

Configuring Python tasks:

Use pk.WithNameSuffix() to add a suffix to task names (e.g., py-test:3.9) and pk.WithFlags() to set the Python version and enable coverage:

Available tasks:

TaskDescription
python.Tasks()All tasks: Format, Lint, Typecheck, Test
python.FormatFormat with ruff
python.LintLint with ruff (auto-fix by default)
python.TypecheckType-check with mypy
python.TestRun pytest
python.Detect()DetectFunc for pyproject.toml

When pk.WithNameSuffix("3.9") is used, tasks are automatically named with a suffix (e.g., py-test:3.9) for CLI invocation and GitHub Actions per-task workflow generation.

Note

Tests run without coverage by default to avoid conflicts when testing multiple Python versions. Use python.TestFlags{Coverage: true} to enable coverage for one version.

Venv Location

Venvs are created at .pocket/venvs/<project-path>/venv-<version>/:

  • Root project: .pocket/venvs/venv-3.9/
  • Subdirectory project: .pocket/venvs/services/api/venv-3.9/

This path-scoped approach prevents collisions in monorepos with multiple pyproject.toml files.

Low-Level uv API

For custom Python tasks, use the tools/uv package directly:

import "github.com/fredrikaverpil/pocket/tools/uv"

var Docs = &pk.Task{
    Name:  "docs",
    Usage: "build documentation",
    Body: pk.Serial(
        uv.Install,
        pk.Do(func(ctx context.Context) error {
            // Sync dependencies from pyproject.toml
            if err := uv.Sync(ctx, uv.SyncOptions{
                PythonVersion: "3.12",
                AllGroups:     true,
            }); err != nil {
                return err
            }
            // Run the tool from the synced environment
            return uv.Run(ctx, uv.RunOptions{
                PythonVersion: "3.12",
            }, "mkdocs", "build")
        }),
    ),
}

Key types and functions:

Type/FunctionDescription
uv.InstallTask that ensures uv is available
uv.ContentHash(data...)Content-addressable directory name
uv.ExecTool(ctx, venvDir, name, args…)Run tool via Python interpreter (no shebang)
uv.EnsureInstalled(venvDir, name, fn)Skip install if binary + Python exist
uv.IsInstalled(venvDir, name)Check tool binary + Python interpreter exist
uv.SyncOptionsConfig for uv sync (version, venv, groups)
uv.RunOptionsConfig for uv run (version, venv)
uv.Sync(ctx, opts)Install deps from pyproject.toml
uv.Run(ctx, opts, cmd, args...)Run command from synced environment
uv.VenvPath(projectPath, pythonVersion)Compute venv path for a project

SyncOptions fields:

FieldDescription
PythonVersionPython version (default: uv.DefaultPythonVersion)
VenvPathExplicit venv path (default: auto-computed)
ProjectDirWhere pyproject.toml lives (default: run.PathFromContext)
AllGroupsInstall all dependency groups

Important

uv bundles Python download metadata at build time. The uv version in tools/uv/uv.go must be recent enough to know about the Python version specified by DefaultPythonVersion (or any PythonVersion you pass). If Renovate bumps one independently, uv sync --python <version> will fail on fresh CI environments with "No interpreter found for Python X.Y.Z in managed installations or search path", while appearing to work locally from cached Python. Run uv python list --only-downloads | grep <version> to verify compatibility.

Standalone Python Tools

For tools managed entirely by Pocket (not from pyproject.toml), create a tool package in tools/<toolname>/ that provides an Exec() function. This follows the Tool Exec pattern—no symlink is created because Python scripts have shebangs that require the runtime on PATH.

See tools/mdformat/ for a complete example:

// tools/mdformat/mdformat.go
package mdformat

//go:embed pyproject.toml
var pyprojectTOML []byte

//go:embed uv.lock
var uvLock []byte

// Version returns a content hash based on pyproject.toml, uv.lock, and Python version.
func Version() string {
    return uv.ContentHash(pyprojectTOML, uvLock, []byte(uv.DefaultPythonVersion))
}

// Install ensures mdformat is available.
var Install = &pk.Task{
    Name:   "install:mdformat",
    Usage:  "install mdformat",
    Body:   pk.Serial(uv.Install, installMdformat()),
    Hidden: true,
    Global: true,
}

func installMdformat() pk.Runnable {
    installDir := pk.FromToolsDir(Name, Version())
    venvPath := filepath.Join(installDir, "venv")
    return uv.EnsureInstalled(venvPath, Name, func(ctx context.Context) error {
        os.MkdirAll(installDir, 0o755)
        os.WriteFile(filepath.Join(installDir, "pyproject.toml"), pyprojectTOML, 0o644)
        os.WriteFile(filepath.Join(installDir, "uv.lock"), uvLock, 0o644)
        return uv.Sync(ctx, uv.SyncOptions{
            PythonVersion: uv.DefaultPythonVersion,
            VenvPath:      venvPath,
            ProjectDir:    installDir,
        })
    })
}

// Exec runs mdformat with the given arguments.
func Exec(ctx context.Context, args ...string) error {
    installDir := pk.FromToolsDir(Name, Version())
    venvDir := filepath.Join(installDir, "venv")
    return uv.ExecTool(ctx, venvDir, Name, args...)
}

Tasks use the tool via its Exec() function:

// tasks/markdown/format.go
var Format = &pk.Task{
    Name:  "md-format",
    Usage: "format Markdown files",
    Body: pk.Serial(mdformat.Install, pk.Do(func(ctx context.Context) error {
        return mdformat.Exec(ctx, "--wrap", "80", ".")
    })),
}

Project Python Tools

For tools where the project controls versions via its own pyproject.toml—useful for linters, test runners, documentation generators, or any tool that should match the project's Python environment:

import "github.com/fredrikaverpil/pocket/tools/uv"

var Docs = &pk.Task{
    Name:  "docs",
    Usage: "build documentation",
    Body: pk.Serial(
        uv.Install,
        pk.Do(func(ctx context.Context) error {
            // Sync dependencies from the project's pyproject.toml.
            if err := uv.Sync(ctx, uv.SyncOptions{
                AllGroups: true,
            }); err != nil {
                return err
            }

            // Run the tool from the synced environment.
            return uv.Run(ctx, uv.RunOptions{}, "zensical", "build")
        }),
    ),
}

When ProjectDir is left empty, both uv.Sync and uv.Run default to run.PathFromContext(ctx)—the directory containing the project's pyproject.toml. The venv is stored at .pocket/venvs/<project-path>/venv-<version>/, keeping it out of the project tree.

No tool package under tools/ is needed—the project's lockfile controls the version. Only uv.Install is required to ensure the uv binary is available.

When to use which pattern:

AspectStandalone (.pocket/tools/)Project-managed (.pocket/venvs/)
Version controlPocket controls versionProject's pyproject.toml
Use caseShared tools across projectsProject-specific tooling
Examplemdformat, zensicalruff, mypy, pytest, zensical
Invocationtool.Exec(ctx, ...)uv.Run(ctx, opts, "tool", ...)

Note

Some tools (like zensical) can be used either way. Use standalone when Pocket should control the version. Use project-managed when the project needs to pin its own version in pyproject.toml and uv.lock.

Node Tools

Pocket provides bun for JavaScript/TypeScript tools via the tools/bun package.

Standalone Node Tools

For tools managed entirely by Pocket, create a tool package in tools/<toolname>/ that provides an Exec() function. Embed package.json and bun.lock to control versions. This follows the Tool Exec pattern—no symlink is created because Node scripts have shebangs that require Node on PATH.

See tools/prettier/ for a complete example:

// tools/prettier/prettier.go
package prettier

//go:embed package.json
var packageJSON []byte

//go:embed bun.lock
var lockfile []byte

// Version returns a content hash based on package.json and bun.lock.
func Version() string {
    return bun.ContentHash(packageJSON, lockfile)
}

// Install ensures prettier is available.
var Install = &pk.Task{
    Name:   "install:prettier",
    Usage:  "install prettier",
    Body:   pk.Serial(bun.Install, installPrettier()),
    Hidden: true,
    Global: true,
}

func installPrettier() pk.Runnable {
    installDir := pk.FromToolsDir(Name, Version())
    return bun.EnsureInstalled(installDir, Name, func(ctx context.Context) error {
        os.MkdirAll(installDir, 0o755)
        os.WriteFile(filepath.Join(installDir, "package.json"), packageJSON, 0o644)
        os.WriteFile(filepath.Join(installDir, "bun.lock"), lockfile, 0o644)
        return bun.InstallFromLockfile(ctx, installDir)
    })
}

// Exec runs prettier with the given arguments.
func Exec(ctx context.Context, args ...string) error {
    installDir := pk.FromToolsDir(Name, Version())
    return bun.Run(ctx, installDir, Name, args...)
}

Tasks use the tool via its Exec() function:

// tasks/markdown/format.go
var Format = &pk.Task{
    Name:  "md-format",
    Usage: "format Markdown files",
    Body: pk.Serial(prettier.Install, pk.Do(func(ctx context.Context) error {
        return prettier.Exec(ctx, "--write", "**/*.md")
    })),
}

Key functions:

FunctionDescription
bun.InstallTask that ensures bun is available
bun.ContentHash(data...)Content-addressable directory name
bun.EnsureInstalled(dir, name, fn)Skip install if binary exists
bun.IsInstalled(installDir, name)Check tool binary exists in node_modules
bun.InstallFromLockfile(ctx, dir)Install from package.json + bun.lock
bun.BinaryPath(installDir, name)Path to binary in node_modules/.bin
bun.Run(ctx, installDir, pkg, args...)Run a package via bun

Project Node Tools

For tools where the project controls versions via package.json—useful for build tools, test runners, or tools that should match the project's Node environment:

import "github.com/fredrikaverpil/pocket/tools/bun"

var Build = &pk.Task{
    Name:  "build",
    Usage: "build frontend",
    Body: pk.Serial(
        bun.Install,
        pk.Do(func(ctx context.Context) error {
            projectDir := pk.FromGitRoot("frontend")

            // Install dependencies from project's package.json + bun.lock.
            if err := bun.InstallFromLockfile(ctx, projectDir); err != nil {
                return err
            }

            // Run the build script.
            return bun.Run(ctx, projectDir, "build")
        }),
    ),
}

When to use which pattern:

AspectStandaloneProject-managed
Version controlPocket (embedded lockfile)Project's package.json + bun.lock
Storage location.pocket/tools/<tool>/<ver>/Project's node_modules/
Use caseShared tools across projectsProject-specific tooling
ExampleprettierBuild tools, test runners
Invocationtool.Exec(ctx, ...)bun.Run(ctx, dir, "tool", ...)

Platform Helpers

These functions help construct platform-specific download URLs. All are available directly from the pk package:

FunctionDescription
pk.HostOS()Current OS: "darwin", "linux", "windows"
pk.HostArch()Current arch: "amd64", "arm64"
pk.ArchToX8664(arch)Convert amd64x86_64, arm64aarch64
pk.ArchToX64(arch)Convert amd64x64
pk.BinaryName(name)Append .exe on Windows
pk.OSToTitle(os)Convert darwinDarwin
pk.DefaultArchiveFormat()Returns "zip" on Windows, "tar.gz" otherwise

Platform constants (access via pk.Darwin, pk.Linux, etc.):

pk.Darwin  // "darwin"
pk.Linux   // "linux"
pk.Windows // "windows"
pk.AMD64   // "amd64"
pk.ARM64   // "arm64"
pk.X8664   // "x86_64"
pk.AARCH64 // "aarch64"
pk.X64     // "x64"

Composition

Tasks are composed using Serial and Parallel combinators to build execution trees.

Serial Execution

pk.Serial runs tasks one after another. If any task returns an error, execution stops immediately.

var Auto = pk.Serial(
    Format,  // runs first
    Lint,    // runs second
    Test,    // runs third
)

Parallel Execution

pk.Parallel runs tasks concurrently. Pocket automatically buffers output so logs don't interleave—each task's output flushes atomically when it completes.

var Auto = pk.Parallel(Lint, Test, Build)

Behavior:

  • Single task in Parallel → runs without buffering (real-time output)
  • Multiple tasks → buffered output, first-to-complete flushes first
  • If one task fails, context is cancelled and remaining tasks exit early

Task Deduplication

The same task at the same path only runs once per invocation, even if referenced multiple times in your composition tree. This makes it safe to compose shared dependencies without redundant work.

pk.Serial(
    pk.Parallel(Lint, Test),  // Both depend on Install
    Build,                     // Also depends on Install
)
// Install runs once, not three times

Referencing the same task under multiple path scopes runs it in the union of all scopes' paths, deduplicated per path:

pk.Serial(
    pk.WithOptions(Lint, pk.WithPath("svc-a")),
    pk.WithOptions(Lint, pk.WithPath("svc-b")),
)
// Lint runs in svc-a and svc-b

Use WithForceRun() to bypass deduplication when needed:

pk.WithOptions(
    CleanTask,
    pk.WithForceRun(),  // Always run, even if already executed
)

Force streamed output for specific tasks, regardless of whether the -v CLI flag was passed. Set Verbose: true directly on the task:

var Deploy = &pk.Task{
    Name:    "deploy",
    Usage:   "deploy application",
    Verbose: true,  // Always stream output in real-time
    Do:      func(ctx context.Context) error { ... },
}

Or use WithVerbose() to force it via composition:

pk.WithOptions(
    DeployTask,
    pk.WithVerbose(),  // Always stream output in real-time
)

Options

pk.WithOptions wraps a Runnable with scoped execution options:

OptionDescription
pk.WithPath(patterns...)Only run in matching directories
pk.WithSkipPath(patterns...)Skip matching directories
pk.WithSkipTask(task)Remove a task from scope
pk.WithSkipTask(task, patterns...)Skip a task in matching directories
pk.WithDetect(fn)Auto-detect directories
pk.WithNameSuffix(suffix)Add suffix to task names (e.g., :v2)
pk.WithFlags(flagsStruct)Override a task's flags
pk.WithForceRun()Disable deduplication
pk.WithVerbose()Force verbose (streamed) output
pk.WithNoticePatterns(...)Override warning detection patterns

Use pk.WithFlags() to set task flags explicitly:

pk.WithOptions(
    python.Tasks(),
    pk.WithNameSuffix("3.9"),
    pk.WithFlags(python.FormatFlags{Python: "3.9"}),
    pk.WithFlags(python.LintFlags{Python: "3.9"}),
    pk.WithFlags(python.TestFlags{Python: "3.9", Coverage: true}),
    pk.WithDetect(python.Detect()),
)

Note

A task referenced from multiple scopes must resolve to the same flag overrides in all of them — conflicting WithFlags values (including an override in one scope but not another) fail plan building. Use pk.WithNameSuffix to create distinct variants instead.

Creating custom options: When building your own task packages, use pk.WithFlags to set task flags:

type MyFlags struct {
    Feature bool `flag:"feature" usage:"enable feature"`
}

func EnableFeature() pk.Option {
    return pk.WithFlags(MyFlags{Feature: true})
}

Path Filtering

In monorepos or multi-module projects, you often want to run tasks only in specific directories. All path patterns are regular expressions.

Include and Exclude

Use pk.WithOptions to apply path constraints:

pk.WithOptions(
    pk.Parallel(Lint, Test),
    pk.WithPath("services/.*"),     // Only in services/ subdirectories
    pk.WithSkipPath("vendor"),             // Skip vendor/ everywhere
)

Auto-Detection

Auto-detection scans your repository for marker files (like go.mod or package.json) and runs tasks in those directories:

pk.WithOptions(
    golang.Tasks(),
    pk.WithDetect(pk.DetectByFile("go.mod")),
)

Built-in detection:

func DetectByFile(filenames ...string) DetectFunc

Pocket uses refining composition: nested WithOptions accumulate constraints. Inner detection functions only search within directories allowed by the outer scope.

pk.WithOptions(
    pk.WithOptions(
        golang.Tasks(),
        pk.WithDetect(pk.DetectByFile("go.mod")),
    ),
    pk.WithSkipPath("testdata"),     // Applies to inner scope too
)

The filesystem is walked once and cached, ensuring detection is fast even in large repositories.

Task-Specific Scoping

Apply constraints to specific tasks without refactoring the tree:

OptionDescription
WithSkipPath(patterns...)Skip paths for ALL tasks in scope
WithSkipTask(task)Remove a task entirely from scope
WithSkipTask(task, patterns...)Skip a task in matching directories
WithFlags(flagsStruct)Set flag overrides for a task
pk.WithOptions(
    golang.Tasks(),
    pk.WithSkipPath("vendor"),                 // Global: no tasks run in vendor/
    pk.WithSkipTask(golang.Test, "foo/.*"),    // Only go-test skips foo/
    pk.WithSkipTask(golang.Lint),              // Remove linting entirely
    pk.WithFlags(golang.TestFlags{Race: true}), // Enable race detector
)

Tasks can be specified by string name or task object (recommended for type safety).

Note

WithFlags overrides only apply when the task runs as part of the composition tree (e.g., via bare ./pok). When you invoke a task directly (e.g., ./pok my-task), it runs with its default flag values, bypassing composition-level overrides. If you need the flag applied for direct invocation, pass it explicitly: ./pok my-task -flag-name value.

Shim Scoping

Pocket generates ./pok shims in directories matched by WithPath or WithDetect.

  • Running ./pok from root shows and executes all tasks
  • Running ./pok from a subdirectory only shows and executes tasks scoped to that path
  • Running ./pok <task> from a subdirectory executes that task only for the shim's path
./pok                       # runs all tasks across all paths
cd services/api && ./pok    # only runs tasks scoped to services/api
./pok go-test               # runs go-test only in services/api

Configuration

Config Struct

The main entry point for configuring Pocket:

type Config struct {
    Auto   Runnable     // Tasks executed on bare ./pok
    Manual []Runnable   // Tasks only run when explicitly invoked
    Plan   *PlanConfig  // Plan building, shims, and CI configuration
}

type PlanConfig struct {
    SkipDirs          []string    // Directories to skip during filesystem walk
    IncludeHiddenDirs bool        // Include hidden directories (default: false)
    Shims             *ShimConfig // Which shim scripts to generate
}

Directory Skipping

Control which directories are skipped during filesystem walking:

// Default skip list (used when SkipDirs is nil)
var DefaultSkipDirs = []string{
    "vendor",       // Go, PHP, Ruby dependencies
    "node_modules", // Node.js dependencies
    "dist",         // Build output
    "__pycache__",  // Python bytecode cache
    "venv",         // Python virtual environment
}

Usage:

var Config = &pk.Config{
    Auto: pk.Serial(Lint, Test),

    Plan: &pk.PlanConfig{
        // Extend defaults
        SkipDirs: append(pk.DefaultSkipDirs, "testdata", "generated"),

        // Or skip nothing
        // SkipDirs: []string{},

        // Include hidden directories (.git, .cache, etc.)
        IncludeHiddenDirs: false, // default
    },
}

Shim Generation

Control which shim scripts are generated:

type ShimConfig struct {
    Posix      bool  // pok (default)
    Windows    bool  // pok.cmd
    PowerShell bool  // pok.ps1
}

Helper:

pk.AllShimsConfig()  // All three shims (POSIX is the default when omitted)

Usage:

var Config = &pk.Config{
    Auto: pk.Serial(Lint, Test),

    Plan: &pk.PlanConfig{
        Shims: pk.AllShimsConfig(), // Generate all platform shims
    },
}

Git Diff Check

Pocket can run git diff --exit-code after task execution to catch unintended file modifications. This is enabled with the -g flag:

./pok -g          # Run all auto tasks, then git diff
./pok lint -g     # Run lint task, then git diff

The -g flag causes Pocket to fail if there are uncommitted changes after tasks complete. This is useful in CI to ensure generated files are up to date.

Conventional Commits Check

Pocket can validate commit messages against the Conventional Commits format. This is enabled with the -c flag:

./pok -c          # Run all auto tasks, then validate commits
./pok lint -c     # Run lint task, then validate commits

The -c flag validates commits between HEAD and the upstream tracking branch (or the origin's default branch for new branches). Merge commits are skipped. Each commit message must match the format type[(scope)][!]: description, where the description must not start with uppercase.

Both flags can be combined: ./pok -g -c.


Plan Introspection

Pocket builds an execution plan before running tasks. This plan is accessible at runtime for advanced use cases like CI workflow generation. For human inspection, ./pok plan prints the configured tree, and ./pok plan < tree.json or ./pok plan tree.json prints a JSON task tree without executing it.

Accessing the Plan

pk.Do(func(ctx context.Context) error {
    plan := run.PlanFromContext(ctx)
    if plan == nil {
        return errors.New("no plan in context")
    }

    // Use plan for introspection
    for _, task := range plan.Tasks() {
        fmt.Printf("Task: %s\n", task.Name)
    }
    return nil
})

Plan Structure

type Plan struct {
    // Internal: tree, tasks, pathMappings, moduleDirectories, shimConfig
}

// Public methods
func (p *Plan) Tasks() []TaskInfo        // All tasks in the plan
func (p *Plan) ShimConfig() *ShimConfig  // Resolved shim configuration

Context accessors (from the pk/run package):

FunctionDescription
run.PlanFromContext(ctx)Get the Plan from context (nil if not set)
run.PathFromContext(ctx)Current execution path relative to git root
run.Verbose(ctx)Whether -v flag was provided

Path helpers:

FunctionDescription
FromGitRoot(elems...)Absolute path relative to git repository root
FromPocketDir(elems...)Absolute path relative to .pocket/
FromBinDir(elems...)Absolute path relative to .pocket/bin/
FromToolsDir(elems...)Absolute path relative to .pocket/tools/

GitHub Actions Integration

Pocket provides two approaches for GitHub Actions CI/CD integration. Both are configured through github.Tasks() and pk.WithOptions.

Simple Workflow (Default)

By default, github.Tasks() generates a simple pocket.yml workflow that runs all tasks on configured platforms:

import "github.com/fredrikaverpil/pocket/tasks/github"

var Config = &pk.Config{
    Auto: pk.Parallel(
        golang.Tasks(),
        pk.WithOptions(
            github.Tasks(),
        ),
    ),
}

This generates .github/workflows/pocket.yml:

jobs:
  pocket:
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - run: ./pok -v

Pros: Simple, predictable, easy to understand.

Cons: All tasks run serially; no per-task platform customization.

Per-Task Workflow

For per-task parallelism and platform customization, enable the per-task workflow. This generates static job definitions at workflow creation time—each task/platform combination becomes a separate job in the YAML file.

import "github.com/fredrikaverpil/pocket/tasks/github"

var Config = &pk.Config{
    Auto: pk.Parallel(
        golang.Tasks(),
        pk.WithOptions(
            github.Tasks(),
            pk.WithFlags(github.WorkflowFlags{
                PerPocketTaskJob: new(true),
                Platforms:        github.AllPlatforms(),
                PerPocketTaskJobOptions: map[string]github.PerPocketTaskJobOption{
                    golang.Lint.Name: {Platforms: []github.Platform{github.Ubuntu}}, // lint only on Linux
                    "github-workflows": {Exclude: true},
                },
            }),
        ),
    ),
}

This configuration:

  1. github.Tasks() returns the Workflows task
  2. pk.WithFlags(github.WorkflowFlags{...}) enables the per-task workflow and configures platforms and per-task options

Running ./pok github-workflows generates jobs like:

jobs:
  go-lint-ubuntu:
    name: go-lint (ubuntu-latest)
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      # ... setup steps ...
      - run: ./pok -v -g go-lint

  go-test-ubuntu:
    name: go-test (ubuntu-latest)
    runs-on: ubuntu-latest
    # ...

  go-test-macos:
    name: go-test (macos-latest)
    runs-on: macos-latest
    # ...

  go-test-windows:
    name: go-test (windows-latest)
    runs-on: windows-latest
    # ...

GitHub Task Configuration

Use pk.WithFlags() to configure the github.Workflows task:

type WorkflowFlags struct {
    // CLI flags (can be set via command line or pk.WithFlags):
    ConventionalCommitWorkflow *bool `flag:"conventional-commit-workflow" usage:"conventional commit PR"`
    GhPagesWorkflow            *bool `flag:"gh-pages-workflow"            usage:"GitHub Pages"`
    GoReleaserWorkflow         *bool `flag:"goreleaser-workflow"          usage:"GoReleaser release"`
    PerPocketTaskJob           *bool `flag:"per-pocket-task-job"          usage:"per-task jobs"`
    ReleasePleaseWorkflow      *bool `flag:"release-please-workflow"      usage:"release-please"`
    StaleWorkflow              *bool `flag:"stale-workflow"               usage:"stale issues"`
    GitDiff                    *bool `flag:"git-diff"                     usage:"check uncommitted changes"`

    // Programmatic-only (set via pk.WithFlags, not available as CLI flags):
    Platforms               []Platform
    PerPocketTaskJobOptions map[string]PerPocketTaskJobOption
}

Pointer semantics: *bool fields use nil = inherit default, new(true)/new(false) = explicit override. This is what makes WithFlags ergonomic — you only set what you want to change.

PerPocketTaskJobOption

Per-task options are configured directly within WorkflowFlags via the PerPocketTaskJobOptions map:

type PerPocketTaskJobOption struct {
    // Platforms overrides WorkflowFlags.Platforms for this task.
    Platforms []Platform

    // Exclude removes this task from the per-task workflow entirely.
    Exclude bool

    // GitDiff overrides WorkflowFlags.GitDiff for this task.
    GitDiff *bool
}

Available platforms are github.Ubuntu, github.MacOS, and github.Windows. Use github.AllPlatforms() to get all platforms (returns []Platform).

Note

The github-workflows task also accepts CLI flags directly (e.g., ./pok github-workflows -per-pocket-task-job). Flag overrides via pk.WithFlags only apply when running through the composition tree (bare ./pok). When invoking tasks directly, pass flags explicitly. Programmatic-only fields (Platforms, PerPocketTaskJobOptions) can only be set via pk.WithFlags.

Benefits comparison:

FeatureSimplePer-Job
Per-task visibility in GitHub UINoYes
Per-task platform configurationNoYes
Parallel task executionNoYes
Fail-fast granularityAll tasksPer task
Configuration complexityLowMedium

JSON Execution

In addition to the typed .pocket/config.go path, Pocket can be driven from a JSON document. This is aimed at LLMs and agents that need to compose ad-hoc task trees without scaffolding a Go project. The JSON path uses the exact same engine as the Go config path — same Serial/Parallel semantics, the same deduplication, the same output buffering, the same global flag behavior.

Schema

A versioned root with optional global execution options and a single execution tree. Each node has an explicit type: task, command, serial, or parallel. Unknown fields error.

{
  "version": 1,
  "options": {
    "gitdiff": true
  },
  "tree": {
    "type": "serial",
    "children": [
      {
        "type": "task",
        "name": "go-format"
      },
      {
        "type": "parallel",
        "children": [
          { "type": "task", "name": "go-test" },
          {
            "type": "command",
            "name": "custom-vet",
            "argv": ["go", "vet", "./..."]
          }
        ]
      }
    ]
  }
}

options mirrors global execution flags such as -g, -s, -v, and -c. task nodes reference existing Pocket tasks by name. command nodes run raw argument vectors; argv[0] is the executable and the rest are arguments. Task and command nodes accept an optional paths array of literal directories relative to the git root. Omit paths to use the referenced task's existing paths, or the repository root for raw commands. See the JSON Execution reference for the full set of validation rules.

Executing JSON

Pipe a document into the exec builtin:

echo '{"version":1,"tree":{"type":"command","argv":["echo","hello"],"name":"greet"}}' \
  | ./pok exec

Global flags work the same as for any other task:

./pok -v exec < tree.json    # stream task output instead of buffering
./pok -s exec < tree.json    # force serial execution
./pok -g exec < tree.json    # run git diff check after execution

Validation and parse errors are emitted to stderr as JSON objects, one per error, so agents can parse the failure:

{ "error": "tree.children[0].name: required for command nodes" }

The CLI exits non-zero on any validation or execution error.

Print the JSON Schema (Draft-07) for the v1 format with:

./pok exec --schema

Inspecting a Go Project as JSON

The global --json flag emits the executable task tree of the current .pocket/config.go project, instead of executing it:

./pok --json                 # emit the full Auto tree as JSON
./pok --json go-test         # emit a single task reference
./pok --json -g go-test      # include the git-diff post-action as JSON options

Because Go-defined task bodies are not shell commands, emitted task nodes use { "type": "task", "name": "..." } references rather than raw argv commands. The output is accepted by ./pok exec in the same Pocket project.