cli-output
SkillAI & modelsUse 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.
No other account needed.
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
| Stream | Field | Purpose |
|---|---|---|
| stdout | ios.Out | Data, status, success, and next steps |
| stderr | ios.ErrOut | Warnings, 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) — viaprintError()in Main(), or pre-printed beforeSilentError - 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/* lscommands —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/--quietwith PreRunE mutual exclusivitycmdutil.AddFilterFlags(cmd)→ registers repeatable--filter key=value
Output modes:
| User Flag | Output Mode | Handler |
|---|---|---|
| (none) | Styled TTY table / plain tabwriter | opts.TUI.NewTable(headers...) |
--format table | Same as default | Same |
--json or --format json | Pretty-printed JSON | cmdutil.WriteJSON(ios.Out, rows) |
--format '{{.ID}}' | Go template, one line per item | cmdutil.ExecuteTemplate(ios.Out, format, items) |
--format 'table {{.ID}}\t{{.Size}}' | Go template through tabwriter | Same (tabwriter-wrapped) |
-q / --quiet | IDs only, one per line | fmt.Fprintln(ios.Out, id) |
Mutual exclusivity (enforced in PreRunE):
--quietvs--format/--json→FlagError--formatvs--json→FlagError
See Section 7 for the complete list command wiring pattern.
3. The 4 Output Scenarios
Decision Table
| Scenario | User Input? | Live Rendering? | Imports | Wiring |
|---|---|---|---|---|
| Static | No | No | iostreams | f.IOStreams |
| Static-interactive | Mid-flow y/n | No | iostreams + prompter | f.IOStreams + f.Prompter() |
| Live-display | No | Yes (continuous) | iostreams + tui | f.IOStreams + f.TUI |
| Live-interactive | Full keyboard | Yes (stateful) | iostreams + tui | f.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
| Method | TTY Output | Non-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.
| Method | Usage | Color |
|---|---|---|
cs.Primary(s) | Brand, titles | ColorBurntOrange (#E8714A) |
cs.Secondary(s) | Supporting text | ColorDeepSkyBlue (#00BFFF) |
cs.Accent(s) | Emphasis | ColorSalmon (#FF6B6B) |
cs.Success(s) | Positive outcomes | ColorEmerald (#04B575) |
cs.Warning(s) | Caution | ColorAmber (#FFCC00) |
cs.Error(s) | Errors | ColorHotPink (#FF5F87) |
cs.Info(s) | Informational | ColorSkyBlue (#87CEEB) |
cs.Muted(s) | Dimmed/secondary | ColorDimGray (#626262) |
cs.Highlight(s) | Attention | ColorOrchid (#AD58B4) |
cs.Disabled(s) | Inactive | ColorCharcoal (#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 activecs.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
| Type | Usage | Main() Behavior |
|---|---|---|
fmt.Errorf(...) | Default error | Prints "Error: <message>" + help hint |
cmdutil.FlagErrorf(...) | Bad flag/arg | Prints error + command usage + help hint |
cmdutil.FlagErrorWrap(err) | Wrap existing as flag error | Same as FlagErrorf |
cmdutil.SilentError | Already displayed | Exits non-zero silently |
&cmdutil.ExitError{Code: N} | Container exit code propagation | Exits with code N (runs defers first) |
userFormattedError interface | Rich 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/tablewithStyleFunc. Muted uppercase headers (TableHeaderStyle), primary color first column (TablePrimaryColumnStyle), no borders. Column widths auto-sized by median-based resizer. - Non-TTY / piped (plain):
text/tabwriterwith 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→ returnsDefault(or error ifRequiredwith no default)Confirm→ returnsdefaultYesSelect→ returnsdefaultIdx
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