cli-output

SkillAI & models

Use when adding or changing command output in clawker: stdout and stderr streams, tables, --format and filter flags, ColorScheme, prompts, progress and tree display, and output tests.

Available today. Use it from your connected AI after setup.

Add ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.

Then ask your AI: use the cli-output skill

What this skill tells your AI

The instructions your AI receives, as published by schmitthub/clawker in .agents/skills/cli-output/SKILL.md and read by ahel’s review.

Read this reference for CLI output examples. The output rules in the Serena cli/core memory define stream placement; examples below apply those rules.

1. Core Principle

Follow GitHub CLI (gh) conventions: fmt.Fprintf with ios.ColorScheme() directly. No output wrappers, no abstraction layers — just formatted writes to the correct stream.

cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Built image %s\n", cs.SuccessIcon(), cs.Bold(imageTag))

2. Stream Conventions

Base Rules

StreamFieldPurpose
stdoutios.OutData, status, success, and next steps
stderrios.ErrOutWarnings, errors, diagnostics, and progress

Per output type:

  • Data (tables, IDs, JSON, command results) → ios.Out (stdout) — always
  • Status ("Created container X", "Removed 3 volumes") → ios.Out (stdout)
  • Errors → ios.ErrOut (stderr) — via printError() in Main(), or pre-printed before SilentError
  • Warnings → ios.ErrOut (stderr) — always visible regardless of piping
  • Next steps → ios.Out (stdout)
  • Prompts → ios.ErrOut (stderr) — visible even when stdout is piped

With --format, formatted data goes to stdout and status/progress goes to stderr.

These base rules apply to static (non-TUI) output. Live/interactive scenarios have their own rendering strategy — see per-scenario stream rules in Section 3.

Machine-Readable Output (Format/Filter Flags)

Format/filter flags are for static list commands only (Scenario 1). They produce one-shot tabular data that users pipe, grep, or script against. Do NOT add these to live-display or live-interactive commands — streaming output has its own --progress flag.

When to add format/filter flags:

  • * list / * ls commands — container list, image list, volume list, network list, worktree list
  • Any command whose primary output is a table of resources

When NOT to add:

  • * build, * run, * start — these are live-display (Scenario 3) with streaming progress
  • * inspect, * logs — single-resource detail or streaming logs, not tabular lists
  • * remove, * prune — action commands, not data queries
  • Live-interactive commands (Scenario 4) — BubbleTea owns the terminal

Flag registration (via cmdutil):

  • cmdutil.AddFormatFlags(cmd) → registers --format, --json, -q/--quiet with PreRunE mutual exclusivity
  • cmdutil.AddFilterFlags(cmd) → registers repeatable --filter key=value

Output modes:

User FlagOutput ModeHandler
(none)Styled TTY table / plain tabwriteropts.TUI.NewTable(headers...)
--format tableSame as defaultSame
--json or --format jsonPretty-printed JSONcmdutil.WriteJSON(ios.Out, rows)
--format '{{.ID}}'Go template, one line per itemcmdutil.ExecuteTemplate(ios.Out, format, items)
--format 'table {{.ID}}\t{{.Size}}'Go template through tabwriterSame (tabwriter-wrapped)
-q / --quietIDs only, one per linefmt.Fprintln(ios.Out, id)

Mutual exclusivity (enforced in PreRunE):

  • --quiet vs --format/--json → FlagError
  • --format vs --json → FlagError

See Section 7 for the complete list command wiring pattern.

3. The 4 Output Scenarios

Decision Table

ScenarioUser Input?Live Rendering?ImportsWiring
StaticNoNoiostreamsf.IOStreams
Static-interactiveMid-flow y/nNoiostreams + prompterf.IOStreams + f.Prompter()
Live-displayNoYes (continuous)iostreams + tuif.IOStreams + f.TUI
Live-interactiveFull keyboardYes (stateful)iostreams + tuif.IOStreams + f.TUI

Scenario 1: Static (Non-Interactive)

Print and done. Data, status, results.

Stream strategy:

  • Data output (tables, IDs, results) → ios.Out (stdout)
  • Status messages, success confirmations, and next steps → ios.Out (stdout)
  • Warnings → ios.ErrOut (stderr)
  • Errors → returned to Main() → ios.ErrOut (stderr)
func runList(opts *ListOptions) error {
    ios := opts.IOStreams
    cs := ios.ColorScheme()

    // Data output to stdout (pipeable)
    tp := opts.TUI.NewTable("NAME", "STATUS", "IMAGE")
    for _, c := range containers {
        tp.AddRow(c.Name, c.Status, c.Image)
    }
    return tp.Render()
}

func runRemove(opts *RemoveOptions) error {
    ios := opts.IOStreams
    cs := ios.ColorScheme()

    // ... perform removal ...

    // Status to stdout
    fmt.Fprintf(ios.Out, "%s Removed container %s\n", cs.SuccessIcon(), name)
    return nil
}

Scenario 2: Static-Interactive

Static output with y/n prompts mid-flow.

Stream strategy:

  • Same as Static: data and status to stdout; warnings to stderr
  • Prompts render to stderr (visible when stdout is piped)
  • Confirmation results influence what data goes to stdout
func runPrune(opts *PruneOptions) error {
    ios := opts.IOStreams
    prompter := opts.Prompter()

    ok, err := prompter.Confirm("Remove all stopped containers?", false)
    if err != nil { return err }
    if !ok { return nil }

    // ... perform action ...
    fmt.Fprintf(ios.Out, "%s Removed %d containers\n", cs.SuccessIcon(), count)
    return nil
}

Hybrid Scenario 3+4: Wizard + Live-Display

Some commands combine an interactive wizard (Scenario 4) with live progress display (Scenario 3). The init command is the canonical example: it runs a multi-step wizard for user choices, then a TUI progress display for the image build.

Stream strategy:

  • Wizard phase: BubbleTea owns terminal (alt screen), wizard manages all rendering
  • Progress phase: same as Scenario 3 (TUI manages terminal, summary to stderr)
  • After both phases: static next steps to stdout
func Run(ctx context.Context, opts *InitOptions) error {
    // Phase 1: Interactive wizard (Scenario 4)
    fields := buildWizardFields()
    result, err := opts.TUI.RunWizard(fields)
    if err != nil { return fmt.Errorf("wizard failed: %w", err) }
    if !result.Submitted { return nil }

    buildImage := result.Values["build"] == "Yes"
    flavor := result.Values["flavor"]

    // Phase 2: TUI progress display (Scenario 3)
    ch := make(chan tui.ProgressStep, 4)
    go func() {
        defer close(ch)
        ch <- tui.ProgressStep{ID: "build", Name: "Building base image", Status: tui.StepRunning}
        buildErr = client.BuildImage(ctx, buildContext, buildOpts)
        // ... send StepComplete or StepError ...
    }()
    opts.TUI.RunProgress("auto", tui.ProgressDisplayConfig{
        Title: "Building", Subtitle: tag, CompletionVerb: "Built",
    }, ch)

    // Static next steps to stdout
    fmt.Fprintln(ios.Out, "Next Steps:")
}

Scenario 3: Live-Display

No user input, but continuous rendering with layout management.

Stream strategy (TTY mode):

  • BubbleTea manages the terminal — live progress renders via the TUI framework
  • After TUI exits, final summary renders as status (stderr)
  • If the command produces capturable data (e.g., image tag), write to stdout separately

Stream strategy (plain/non-TTY fallback):

  • Progress lines ([run]/[ok]/[fail]) → ios.ErrOut (stderr) — ephemeral status
  • Final summary → ios.ErrOut (stderr) — status
  • Capturable data → ios.Out (stdout)
func runBuild(opts *BuildOptions) error {
    ch := make(chan tui.ProgressStep, 64)
    // ... set up OnProgress callback to send to ch ...
    go func() {
        buildErr = builder.Build(ctx, tag, buildOpts)
        close(ch) // channel closure = done signal
    }()

    result := opts.TUI.RunProgress(opts.Progress, tui.ProgressDisplayConfig{
        Title: "Building", Subtitle: tag,
        MaxVisible: 5, LogLines: 3,
        IsInternal: whail.IsInternalStep,
        CleanName:  whail.CleanStepName,
        ParseGroup: whail.ParseBuildStage,
    }, ch)
    return result.Err
}

Scenario 4: Live-Interactive

Full keyboard/mouse input, stateful navigation. Uses tui.RunProgram.

Stream strategy:

  • BubbleTea owns the full terminal (alternate screen)
  • All rendering managed by the TUI framework
  • Not pipeable — interactive commands require a TTY
  • On exit, any final results → ios.Out (stdout)
model := newMonitorModel(ios)
finalModel, err := tui.RunProgram(ios, model, tui.WithAltScreen(true))

4. ColorScheme API Reference

Access: cs := ios.ColorScheme()

Icons

MethodTTY OutputNon-TTY
cs.SuccessIcon()✓ (green)[ok]
cs.WarningIcon()! (yellow)[warn]
cs.FailureIcon()✗ (red)[fail]
cs.InfoIcon()ℹ (blue)[info]

Each icon has a *WithColor(text) variant that applies the icon's color to the given text.

Semantic Colors (preferred)

Each has a *f(format, args...) variant returning a formatted colored string.

MethodUsageColor
cs.Primary(s)Brand, titlesColorBurntOrange (#E8714A)
cs.Secondary(s)Supporting textColorDeepSkyBlue (#00BFFF)
cs.Accent(s)EmphasisColorSalmon (#FF6B6B)
cs.Success(s)Positive outcomesColorEmerald (#04B575)
cs.Warning(s)CautionColorAmber (#FFCC00)
cs.Error(s)ErrorsColorHotPink (#FF5F87)
cs.Info(s)InformationalColorSkyBlue (#87CEEB)
cs.Muted(s)Dimmed/secondaryColorDimGray (#626262)
cs.Highlight(s)AttentionColorOrchid (#AD58B4)
cs.Disabled(s)InactiveColorCharcoal (#4A4A4A)

Concrete Colors

Use semantic colors when possible. Concrete colors for specific design needs:

cs.Red/Redf, cs.Yellow/Yellowf, cs.Green/Greenf, cs.Blue/Bluef, cs.Cyan/Cyanf, cs.Magenta/Magentaf, cs.BrandOrange/BrandOrangef (deprecated, delegates to Primary/Primaryf)

Text Decorations

cs.Bold/Boldf, cs.Italic/Italicf, cs.Underline/Underlinef, cs.Dim/Dimf

Query Methods

  • cs.Enabled() bool — whether colors are active
  • cs.Theme() string — "dark", "light", or "none"

5. Output Helpers — All Deprecated

cmdutil/output.go has no active helpers. All output functions are deprecated — commands use fmt.Fprintf with ios.ColorScheme() directly (gh-style). See section 10 for migration recipes.

6. Error Handling

Error Flow

Commands return errors — they never print them directly. Centralized rendering happens in Main() → printError().

Command RunE → return error → Main() → printError(ios.ErrOut, err, cmd)

Error Types

TypeUsageMain() Behavior
fmt.Errorf(...)Default errorPrints "Error: <message>" + help hint
cmdutil.FlagErrorf(...)Bad flag/argPrints error + command usage + help hint
cmdutil.FlagErrorWrap(err)Wrap existing as flag errorSame as FlagErrorf
cmdutil.SilentErrorAlready displayedExits non-zero silently
&cmdutil.ExitError{Code: N}Container exit code propagationExits with code N (runs defers first)
userFormattedError interfaceRich error (e.g., Docker)Calls FormatUserError()

Canonical Error Patterns

// Default error — most common
return fmt.Errorf("container %q not found", name)

// Flag validation error — triggers usage display
return cmdutil.FlagErrorf("--timeout must be positive, got %d", timeout)

// Already displayed the error, just exit non-zero
fmt.Fprintf(ios.ErrOut, "%s\n", richErrorMessage)
return cmdutil.SilentError

// Container exit code propagation (lets defers run, unlike os.Exit)
return &cmdutil.ExitError{Code: exitCode}

Anti-Pattern: Direct Error Printing

// BAD — prints error AND returns it (double printing)
fmt.Fprintf(ios.ErrOut, "Error: %s\n", err)
return err

// BAD — cmdutil.HandleError + return SilentError (unnecessary indirection)
cmdutil.HandleError(ios, err)
return cmdutil.SilentError

// GOOD — just return the error
return fmt.Errorf("failed to start container: %w", err)

7. List Commands: Tables + Format/Filter Flags

This section is the canonical recipe for implementing a list command with full format/filter support. It covers: Options struct, flag registration, display row struct, the format dispatch switch, TablePrinter, filters, and testing.

Applies to: Scenario 1 (static) list commands only. See Section 2 for when to use.

7.1 Options Struct Pattern

Every list command needs FormatFlags and optionally FilterFlags on its Options:

type ListOptions struct {
    IOStreams *iostreams.IOStreams
    TUI      *tui.TUI
    Client   func(context.Context) (*docker.Client, error)

    Format *cmdutil.FormatFlags   // --format, --json, --quiet
    Filter *cmdutil.FilterFlags   // --filter key=value (optional)
    All    bool                   // command-specific flags
}

7.2 Flag Registration (NewCmd)

Register format/filter flags in the command constructor. They chain PreRunE automatically:

func NewCmdList(f *cmdutil.Factory, runF func(context.Context, *ListOptions) error) *cobra.Command {
    opts := &ListOptions{
        IOStreams: f.IOStreams,
        TUI:      f.TUI,
        Client:   f.Client,
    }

    cmd := &cobra.Command{
        Use:     "list",
        Aliases: []string{"ls"},
        // ...
        RunE: func(cmd *cobra.Command, args []string) error {
            if runF != nil { return runF(cmd.Context(), opts) }
            return listRun(cmd.Context(), opts)
        },
    }

    opts.Format = cmdutil.AddFormatFlags(cmd)   // registers --format, --json, -q/--quiet
    opts.Filter = cmdutil.AddFilterFlags(cmd)   // registers --filter (repeatable)
    cmd.Flags().BoolVarP(&opts.All, "all", "a", false, "Show all resources")
    return cmd
}

7.3 Display Row Struct

Define a struct for template/JSON output. JSON tags are lowercase. Field names are what users see in --format '{{.FieldName}}':

type imageRow struct {
    Image   string `json:"image"`
    ID      string `json:"id"`
    Created string `json:"created"`
    Size    string `json:"size"`
}

Build rows from domain objects:

func buildRows(items []whail.ImageSummary) []imageRow {
    var rows []imageRow
    for _, img := range items {
        rows = append(rows, imageRow{
            Image:   img.RepoTags[0],
            ID:      truncateID(img.ID),
            Created: formatCreated(img.Created),
            Size:    formatSize(img.Size),
        })
    }
    return rows
}

7.4 Format Dispatch Switch (listRun)

The run function follows this canonical structure:

func listRun(ctx context.Context, opts *ListOptions) error {
    ios := opts.IOStreams

    // 1. Parse and validate filters
    filters, err := opts.Filter.Parse()
    if err != nil { return err }
    if err := cmdutil.ValidateFilterKeys(filters, validFilterKeys); err != nil {
        return err
    }

    // 2. Fetch data
    items, err := fetchItems(ctx, opts)
    if err != nil { return fmt.Errorf("listing resources: %w", err) }

    // 3. Apply local filters
    items = applyFilters(items, filters)

    // 4. Handle empty results
    if len(items) == 0 {
        fmt.Fprintln(ios.ErrOut, "No resources found.")
        return nil
    }

    // 5. Build display rows
    rows := buildRows(items)

    // 6. Format dispatch
    switch {
    case opts.Format.Quiet:
        for _, item := range items {
            fmt.Fprintln(ios.Out, item.ID)
        }
        return nil

    case opts.Format.IsJSON():
        return cmdutil.WriteJSON(ios.Out, rows)

    case opts.Format.IsTemplate():
        return cmdutil.ExecuteTemplate(ios.Out, opts.Format.Template(), cmdutil.ToAny(rows))

    default:
        tp := opts.TUI.NewTable("NAME", "ID", "STATUS")
        for _, r := range rows {
            tp.AddRow(r.Name, r.ID, r.Status)
        }
        return tp.Render()
    }
}

// cmdutil.ToAny converts typed slice to []any for ExecuteTemplate.
// Defined in cmdutil/format.go — use instead of local helpers.

Key rules for the switch:

  • Quiet first (cheapest path, no row construction needed in theory, but rows are built before switch for simplicity)
  • JSON second (structured data)
  • Template third (user-defined format)
  • Default last (styled table)
  • Empty results handled BEFORE the switch — print to stderr, return nil

7.5 TablePrinter API

internal/tui/table.go — TTY-aware tabular output to ios.Out.

Access via Factory noun: opts.TUI.NewTable(headers...)

tp := opts.TUI.NewTable("NAME", "STATUS", "IMAGE")
tp.AddRow("web", "running", "nginx:latest")
tp.AddRow("db", "stopped", "postgres:16")
return tp.Render()

Rendering modes:

  • TTY + color (styled): lipgloss/table with StyleFunc. Muted uppercase headers (TableHeaderStyle), primary color first column (TablePrimaryColumnStyle), no borders. Column widths auto-sized by median-based resizer.
  • Non-TTY / piped (plain): text/tabwriter with 2-space column gaps, no styling — machine-parseable.

Style overrides (optional, for commands needing custom column colors):

tp := opts.TUI.NewTable("NAME", "STATUS")
tp.WithPrimaryStyle(func(s string) string { return cs.Success(s) })

7.6 Filter Implementation

Each list command defines its valid filter keys and matching logic:

var validFilterKeys = []string{"reference", "status"}

func applyFilters(items []Item, filters []cmdutil.Filter) []Item {
    if len(filters) == 0 { return items }
    var result []Item
    for _, item := range items {
        if matchesFilters(item, filters) {
            result = append(result, item)
        }
    }
    return result
}

func matchesFilters(item Item, filters []cmdutil.Filter) bool {
    for _, f := range filters {
        switch f.Key {
        case "reference":
            if !matchGlob(item.Name, f.Value) { return false }
        case "status":
            if item.Status != f.Value { return false }
        }
    }
    return true  // all filters passed
}

Glob matching (trailing * only, Docker CLI convention):

func matchGlob(s, pattern string) bool {
    if prefix, ok := strings.CutSuffix(pattern, "*"); ok {
        return strings.HasPrefix(s, prefix)
    }
    return s == pattern
}

7.7 Anti-Patterns

// BAD — raw tabwriter loses TTY styling
w := tabwriter.NewWriter(ios.Out, 0, 0, 2, ' ', 0)

// GOOD — use TablePrinter via TUI Factory noun
tp := opts.TUI.NewTable("NAME", "STATUS", "IMAGE")
// BAD — format flags on a streaming command
opts.Format = cmdutil.AddFormatFlags(cmd) // in image build? NO!

// GOOD — format flags on list commands only
// Live-display commands use --progress flag instead
// BAD — quiet mode via separate bool field
type ListOptions struct { Quiet bool }
cmd.Flags().BoolVarP(&opts.Quiet, "quiet", "q", false, "...")

// GOOD — quiet is part of FormatFlags, validated for mutual exclusivity
opts.Format = cmdutil.AddFormatFlags(cmd)
// access via opts.Format.Quiet

Remaining raw tabwriter usages (7 files, migration needed):

  • container/list, container/top, container/stats (x2)
  • volume/list, network/list, worktree/list

7.8 Testing List Commands

Flag parsing tests (no Docker, no fakes):

func TestNewCmdList_FormatFlags(t *testing.T) {
    tests := []struct {
        name    string
        input   string
        wantErr string
    }{
        {name: "json flag", input: "--json"},
        {name: "quiet and json exclusive", input: "-q --json", wantErr: "mutually exclusive"},
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            tio, _, _, _ := iostreams.Test()
            f := &cmdutil.Factory{IOStreams: tio}
            cmd := NewCmdList(f, func(_ context.Context, _ *ListOptions) error { return nil })
            argv, _ := shlex.Split(tt.input)
            cmd.SetArgs(argv)
            cmd.SetIn(&bytes.Buffer{})
            cmd.SetOut(&bytes.Buffer{})
            cmd.SetErr(&bytes.Buffer{})
            _, err := cmd.ExecuteC()
            if tt.wantErr != "" {
                require.Contains(t, err.Error(), tt.wantErr)
            } else {
                require.NoError(t, err)
            }
        })
    }
}

Rendering tests (uses docker/mocks fakes, exercises full listRun):

t.Run("json_output", func(t *testing.T) {
    fake := mocks.NewFakeClient()
    fake.SetupImageList(mocks.ImageSummaryFixture("myapp:latest"))
    f, tio := testFactory(t, fake)
    cmd := NewCmdList(f, nil)  // nil runF = real implementation
    cmd.SetArgs([]string{"--json"})
    cmd.SetIn(&bytes.Buffer{})
    cmd.SetOut(out)
    cmd.SetErr(errOut)
    err := cmd.Execute()
    require.NoError(t, err)
    assert.Contains(t, out.String(), `"image": "myapp:latest"`)
})

t.Run("filter_reference", func(t *testing.T) {
    fake := mocks.NewFakeClient(configmocks.NewBlankConfig())
    fake.SetupImageList(
        mocks.ImageSummaryFixture("clawker-demo:latest"),
        mocks.ImageSummaryFixture("node:20-slim"),
    )
    f, tio := testFactory(t, fake)
    cmd := NewCmdList(f, nil)
    cmd.SetArgs([]string{"--filter", "reference=clawker*"})
    // ...
    assert.Contains(t, out.String(), "clawker-demo:latest")
    assert.NotContains(t, out.String(), "node:20-slim")
})

Golden file tests (for table output stability):

GOLDEN_UPDATE=1 go test ./internal/cmd/image/list/... -run TestImageList_Golden -v

8. Prompter

internal/prompter — Interactive prompts via f.Prompter(). Access through Factory noun.

API

prompter := f.Prompter()

// String prompt with validation
name, err := prompter.String(prompter.PromptConfig{
    Message: "Project name", Default: "my-project", Required: true,
    Validator: func(s string) error { /* ... */ },
})

// Yes/No confirmation
ok, err := prompter.Confirm("Delete all volumes?", false)

// Selection from list
idx, err := prompter.Select("Base image", []prompter.SelectOption{
    {Label: "Debian Bookworm", Description: "Recommended"},
    {Label: "Alpine 3.22", Description: "Smaller"},
}, 0)

Non-Interactive Fallback

In CI/non-TTY (checked via ios.IsInteractive()):

  • String → returns Default (or error if Required with no default)
  • Confirm → returns defaultYes
  • Select → returns defaultIdx

Deprecated: PromptForConfirmation

// BAD — writes to os.Stderr directly, not testable
prompter.PromptForConfirmation(cmd.InOrStdin(), "Continue?")

// GOOD — uses IOStreams, testable, handles non-interactive
ok, err := f.Prompter().Confirm("Continue?", false)

Wizard (Multi-Step Prompts)

For multi-step forms with back-navigation, use TUI.RunWizard instead of chaining multiple Prompter calls.

Key types: WizardField (field spec), WizardResult (collected values + submitted flag), FieldOption (label + description for select fields), WizardFieldKind (FieldSelect, FieldText, FieldConfirm)

Entry point: f.TUI.RunWizard(fields []tui.WizardField) (tui.WizardResult, error)

Example (based on init command):

fields := []tui.WizardField{
    {
        ID: "build", Title: "Build Image", Prompt: "Build an initial base image?",
        Kind: tui.FieldSelect,
        Options: []tui.FieldOption{
            {Label: "Yes", Description: "Recommended"},
            {Label: "No", Description: "Skip for now"},
        },
        DefaultIdx: 0,
    },
    {
        ID: "flavor", Title: "Flavor", Prompt: "Select Linux flavor",
        Kind: tui.FieldSelect,
        Options: flavorOptions,
        SkipIf: func(vals tui.WizardValues) bool { return vals["build"] != "Yes" },
    },
    {
        ID: "confirm", Title: "Submit", Prompt: "Proceed?",
        Kind: tui.FieldConfirm, DefaultYes: true,
    },
}

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
55
Forks
6
Last commit
Sep 2026
Advanced
Item type
skill
Key
cli-output
Source
github.com/schmitthub/clawker