CLI Implementation Guide#

This document describes how to implement CLI commands in scafctl. It provides patterns, code examples, and best practices based on the existing codebase.


Table of Contents#

  1. Architecture Overview
  2. Package Structure
  3. Creating a New Command
  4. Command Components
  5. Terminal Output
  6. Data Output with kvx
  7. Flags and Parameters
  8. Context Management
  9. Testing Commands
  10. Common Patterns
  11. Checklist

Architecture Overview#

The scafctl CLI follows a kubectl-style command structure:

scafctl <verb> <kind> <name[@version]> [flags]

The CLI is built using Cobra and organized into a hierarchical command tree:

root (scafctl)
├── version
├── get
│   ├── solution / solutions      # Get or list solutions
│   ├── provider / providers      # Get or list providers
│   └── catalog / catalogs        # Get or list catalogs
├── run
│   └── solution                  # Execute resolvers + actions
├── render
│   └── solution                  # Dry-run with options:
│                                 #   --graph: Show dependency graph
│                                 #   --snapshot: Save execution snapshot
├── package
│   ├── solution                  # Package solution into local catalog
│   └── plugin                    # Package plugin into local catalog
├── push
│   ├── solution                  # Push solution to remote catalog
│   └── plugin                    # Push plugin to remote catalog
├── pull
│   ├── solution                  # Pull solution from remote catalog
│   └── plugin                    # Pull plugin from remote catalog
├── inspect
│   ├── solution                  # Inspect solution metadata
│   └── plugin                    # Inspect plugin metadata/providers
├── tag
│   ├── solution                  # Tag solution version
│   └── plugin                    # Tag plugin version
├── save
│   ├── solution                  # Export solution to tar
│   └── plugin                    # Export plugin to tar
├── load                          # Import artifact from tar
├── explain
│   ├── solution                  # Explain solution metadata
│   └── provider                  # Explain provider schema
├── snapshot
│   ├── show                      # Display saved snapshot
│   └── diff                      # Compare two snapshots
├── delete
│   └── solution                  # Delete solution from catalog
├── plugins
│   ├── install                   # Pre-fetch plugin binaries from catalogs
│   └── list                      # List cached plugin binaries
└── config
    ├── view                      # View current config
    ├── get                       # Get a config value
    ├── set                       # Set a config value
    ├── unset                     # Remove a config value
    ├── add-catalog               # Add a catalog
    ├── remove-catalog            # Remove a catalog
    └── use-catalog               # Set default catalog

Note: Singular and plural forms are supported for listing (e.g., get solution and get solutions both list all solutions when no name is provided).


Package Structure#

Commands live under pkg/cmd/scafctl/:

pkg/cmd/scafctl/
├── root.go              # Root command and global flags
├── root_test.go
├── flags/               # Shared flag helpers
│   ├── output.go        # kvx output flags
│   └── output_test.go
├── get/                 # 'get' verb
│   ├── get.go           # Parent command
│   ├── solution/        # 'get solution' subcommand
│   │   ├── solution.go
│   │   └── solution_test.go
│   ├── provider/        # 'get provider' subcommand
│   ├── catalog/         # 'get catalog' subcommand
│   └── snapshot/        # 'get snapshot' display saved snapshot
├── run/                 # 'run' verb
│   ├── run.go           # Parent command
│   ├── solution.go      # 'run solution' (resolvers + actions)
│   ├── solution_test.go
│   ├── common.go        # Shared helpers
│   ├── params.go        # Parameter parsing
│   └── progress.go      # Progress reporting
├── render/              # 'render' verb
│   ├── render.go
│   ├── solution.go      # 'render solution' (dry-run, --action-graph, --snapshot)
│   └── graph.go         # Graph rendering logic
├── packagecmd/          # 'package' verb ('build' alias, analogous to docker build)
│   ├── package.go
│   ├── solution.go      # 'package solution' to local catalog
│   └── plugin.go        # 'package plugin' to local catalog
├── push/                # 'push' verb (analogous to docker push)
│   ├── push.go
│   ├── solution.go      # 'push solution' to remote catalog
│   └── plugin.go        # 'push plugin' to remote catalog
├── pull/                # 'pull' verb (analogous to docker pull)
│   ├── pull.go
│   ├── solution.go      # 'pull solution' from remote catalog
│   └── plugin.go        # 'pull plugin' from remote catalog
├── inspect/             # 'inspect' verb
│   ├── inspect.go
│   ├── solution.go      # 'inspect solution' metadata
│   └── plugin.go        # 'inspect plugin' metadata/providers
├── tag/                 # 'tag' verb
│   ├── tag.go
│   ├── solution.go      # 'tag solution' create alias
│   └── plugin.go        # 'tag plugin' create alias
├── save/                # 'save' verb (analogous to docker save)
│   ├── save.go
│   ├── solution.go      # 'save solution' export to tar
│   └── plugin.go        # 'save plugin' export to tar
├── load/                # 'load' verb (analogous to docker load)
│   └── load.go          # 'load' import from tar
├── explain/             # 'explain' verb
│   ├── explain.go
│   ├── solution.go      # 'explain solution' metadata
│   └── provider.go      # 'explain provider' schema
├── extract/             # 'extract' verb (polymorphic extraction)
│   ├── extract.go
│   └── bundle.go        # 'extract bundle' extract bundle contents
├── diff/                # 'diff' verb (polymorphic comparison)
│   ├── diff.go
│   ├── solution.go      # 'diff solution' compare solution files
│   ├── bundle.go        # 'diff bundle' compare two bundles
│   └── snapshot.go      # 'diff snapshot' compare snapshots
├── delete/              # 'delete' verb
│   ├── delete.go
│   └── solution.go      # 'delete solution' from catalog
├── config/              # 'config' verb
│   ├── config.go
│   ├── view.go
│   ├── get.go
│   ├── set.go
│   ├── unset.go
│   ├── add_catalog.go
│   ├── remove_catalog.go
│   └── use_catalog.go
└── version/
    ├── version.go
    └── version_test.go

Naming Conventions#

FilePurpose
<verb>.goParent command (e.g., run.go, get.go)
<kind>.goSubcommand implementation (e.g., solution.go)
<kind>_test.goUnit tests for the subcommand
common.goShared code within a verb package
params.goParameter/flag parsing logic

Creating a New Command#

Step 1: Create the Command Package#

For a new verb foo:

// pkg/cmd/scafctl/foo/foo.go
package foo

import (
    "fmt"

    "github.com/oakwood-commons/scafctl/pkg/settings"
    "github.com/oakwood-commons/scafctl/pkg/terminal"
    "github.com/spf13/cobra"
)

// CommandFoo creates the 'foo' command.
func CommandFoo(cliParams *settings.Run, ioStreams *terminal.IOStreams, path string) *cobra.Command {
    cCmd := &cobra.Command{
        Use:     "foo",
        Aliases: []string{"f"},
        Short:   fmt.Sprintf("Does foo things with %s", settings.CliBinaryName),
        Long: `Longer description of what foo does.

SUBCOMMANDS:
  bar    Do bar things`,
        SilenceUsage: true,
    }

    // Add subcommands
    cCmd.AddCommand(CommandBar(cliParams, ioStreams, fmt.Sprintf("%s/%s", path, cCmd.Use)))

    return cCmd
}

Step 2: Create the Subcommand#

// pkg/cmd/scafctl/foo/bar.go
package foo

import (
    "context"
    "fmt"
    "path/filepath"

    "github.com/oakwood-commons/scafctl/pkg/cmd/flags"
    "github.com/oakwood-commons/scafctl/pkg/logger"
    "github.com/oakwood-commons/scafctl/pkg/settings"
    "github.com/oakwood-commons/scafctl/pkg/terminal"
    "github.com/oakwood-commons/scafctl/pkg/terminal/kvx"
    "github.com/oakwood-commons/scafctl/pkg/terminal/output"
    "github.com/oakwood-commons/scafctl/pkg/terminal/writer"
    "github.com/spf13/cobra"
)

// BarOptions holds configuration for the bar command.
type BarOptions struct {
    IOStreams  *terminal.IOStreams
    CliParams  *settings.Run
    
    // Command-specific flags
    File       string
    Verbose    bool
    
    // kvx output flags (for data-returning commands)
    flags.KvxOutputFlags
}

// CommandBar creates the 'foo bar' subcommand.
func CommandBar(cliParams *settings.Run, ioStreams *terminal.IOStreams, path string) *cobra.Command {
    options := &BarOptions{}

    cCmd := &cobra.Command{
        Use:     "bar",
        Aliases: []string{"b"},
        Short:   "Do bar things",
        Long: `Detailed description of the bar command.

Examples:
  # Basic usage
  scafctl foo bar -f config.yaml

  # With verbose output
  scafctl foo bar -f config.yaml --verbose`,
        RunE: func(cCmd *cobra.Command, args []string) error {
            // Set up entry point path for tracing
            cliParams.EntryPointSettings.Path = filepath.Join(path, cCmd.Use)
            ctx := settings.IntoContext(context.Background(), cliParams)

            // Get logger from parent context
            lgr := logger.FromContext(cCmd.Context())
            if lgr != nil {
                ctx = logger.WithLogger(ctx, lgr)
            }

            // Get or create writer
            w := writer.FromContext(cCmd.Context())
            if w == nil {
                w = writer.New(ioStreams, cliParams)
            }
            ctx = writer.WithWriter(ctx, w)

            // Attach streams and params
            options.IOStreams = ioStreams
            options.CliParams = cliParams

            // Validate arguments if needed
            if err := output.ValidateCommands(args); err != nil {
                w.Error(err.Error())
                return err
            }

            // Validate output format
            if err := flags.ValidateKvxOutputFormat(options.Output); err != nil {
                w.Error(err.Error())
                return err
            }

            return options.Run(ctx)
        },
        SilenceUsage: true,
    }

    // Add flags
    cCmd.Flags().StringVarP(&options.File, "file", "f", "", "Path to config file")
    cCmd.Flags().BoolVar(&options.Verbose, "verbose", false, "Enable verbose output")
    
    // Add kvx output flags (-o, -i, -e)
    flags.AddKvxOutputFlagsToStruct(cCmd, &options.KvxOutputFlags)

    return cCmd
}

// Run executes the bar command.
func (o *BarOptions) Run(ctx context.Context) error {
    lgr := logger.FromContext(ctx)
    lgr.V(1).Info("running bar command", "file", o.File, "verbose", o.Verbose)

    // Your command logic here...
    results := map[string]any{
        "status": "success",
        "file":   o.File,
    }

    return o.writeOutput(ctx, results)
}

// writeOutput writes results using kvx infrastructure.
func (o *BarOptions) writeOutput(ctx context.Context, data any) error {
    kvxOpts := flags.NewKvxOutputOptionsFromFlags(
        o.Output,
        o.Interactive,
        o.Expression,
        kvx.WithOutputContext(ctx),
        kvx.WithOutputNoColor(o.CliParams.NoColor),
        kvx.WithOutputAppName("scafctl foo bar"),
    )
    kvxOpts.IOStreams = o.IOStreams

    return kvxOpts.Write(data)
}

Step 3: Register with Root Command#

// pkg/cmd/scafctl/root.go

import (
    // ... existing imports
    "github.com/oakwood-commons/scafctl/pkg/cmd/scafctl/foo"
)

func Root() *cobra.Command {
    // ... existing setup

    cCmd.AddCommand(foo.CommandFoo(cliParams, ioStreams, settings.CliBinaryName))
    
    return cCmd
}

Command Components#

Options Struct#

Every command should have an options struct that holds:

  1. IOStreams and CliParams - Required for output handling
  2. Command-specific flags - With JSON/YAML tags and doc annotations
  3. KvxOutputFlags (optional) - For commands returning structured data
type CommandOptions struct {
    IOStreams  *terminal.IOStreams
    CliParams  *settings.Run
    
    // Flags with proper tags (see Struct Tags section below)
    File       string   `json:"file,omitempty" yaml:"file,omitempty" doc:"Path to file" example:"/path/to/file" maxLength:"4096"`
    Timeout    time.Duration `json:"timeout,omitempty" yaml:"timeout,omitempty" doc:"Operation timeout" example:"30s"`
    Items      []string `json:"items,omitempty" yaml:"items,omitempty" doc:"List of items" maxItems:"100"`
    
    // For data-returning commands
    flags.KvxOutputFlags
    
    // For testing dependency injection
    getter SomeInterface
}

Struct Tags#

Always add JSON/YAML tags and Huma validation tags :

Field TypeRequired Tags
All fieldsdoc
StringsmaxLength, example, pattern (optional), patternDescription (optional)
Integersmaximum, example
ArraysmaxItems (no example)
Objects/mapsNo example tag

Run Method#

The Run method contains the main command logic:

func (o *CommandOptions) Run(ctx context.Context) error {
    lgr := logger.FromContext(ctx)
    lgr.V(1).Info("starting command", "file", o.File)

    // 1. Load/validate input
    data, err := o.loadData(ctx)
    if err != nil {
        return fmt.Errorf("failed to load data: %w", err)
    }

    // 2. Execute main logic
    result, err := o.process(ctx, data)
    if err != nil {
        return fmt.Errorf("processing failed: %w", err)
    }

    // 3. Write output
    return o.writeOutput(ctx, result)
}

Terminal Output#

Using Writer#

The writer package provides centralized terminal output. Never use fmt.Fprintf directly.

import "github.com/oakwood-commons/scafctl/pkg/terminal/writer"

func (o *Options) Run(ctx context.Context) error {
    w := writer.FromContext(ctx)
    if w == nil {
        return fmt.Errorf("writer not initialized in context")
    }
    
    // Success message (respects --quiet and --no-color)
    w.Success("Operation completed")
    w.Successf("Created %d items", count)
    
    // Warning message (respects --quiet and --no-color)
    w.Warning("This is deprecated")
    w.Warningf("File %s not found, using default", path)
    
    // Error message (always shown, respects --no-color)
    w.Error("Something went wrong")
    w.Errorf("Failed to open %s: %v", path, err)
    
    // Info message (respects --quiet and --no-color)
    w.Info("Processing started")
    w.Infof("Found %d files", count)
    
    // Debug message (respects --quiet and log level)
    w.Debug("Internal state")
    w.Debugf("Value: %v", value)
    
    // Plain output (respects --quiet only)
    w.Plainln("Raw output line")
    w.Plainlnf("Count: %d", n)
    
    // Error with exit
    w.ErrorWithExit("Fatal error")        // exits with code 1
    w.ErrorWithCode(2, "Validation failed") // exits with specified code
    
    return nil
}

Writer Methods#

MethodRespects --quietRespects --no-colorOutput Stream
Success / Successf✅✅stdout
Warning / Warningf✅✅stdout
Error / Errorf❌✅stderr
Info / Infof✅✅stdout
Debug / Debugf✅✅stdout
Plain / Plainln✅❌stdout

Creating Writer in Tests#

func TestCommand(t *testing.T) {
    streams, outBuf, errBuf := terminal.NewTestIOStreams()
    cliParams := settings.NewCliParams()
    
    // Create writer with test exit function
    var exitCode int
    w := writer.New(streams, cliParams, writer.WithExitFunc(func(code int) {
        exitCode = code
    }))
    
    ctx := writer.WithWriter(context.Background(), w)
    
    // Run command...
    
    // Verify output
    assert.Contains(t, outBuf.String(), "expected output")
    assert.Equal(t, 1, exitCode)
}

Data Output with kvx#

For commands that return structured data, use the kvx package for flexible output:

Adding kvx Flags#

import "github.com/oakwood-commons/scafctl/pkg/cmd/flags"

type Options struct {
    // ... other fields
    flags.KvxOutputFlags  // Embeds Output, Interactive, Expression
}

func CommandFoo(...) *cobra.Command {
    options := &Options{}
    
    cCmd := &cobra.Command{...}
    
    // Add -o/--output, -i/--interactive, -e/--expression flags
    flags.AddKvxOutputFlagsToStruct(cCmd, &options.KvxOutputFlags)
    
    return cCmd
}

Writing kvx Output#

import (
    "github.com/oakwood-commons/scafctl/pkg/cmd/flags"
    "github.com/oakwood-commons/scafctl/pkg/terminal/kvx"
)

func (o *Options) writeOutput(ctx context.Context, data any) error {
    kvxOpts := flags.NewKvxOutputOptionsFromFlags(
        o.Output,       // "table", "json", "yaml", "quiet"
        o.Interactive,  // Launch TUI
        o.Expression,   // CEL filter expression
        
        // Optional configuration
        kvx.WithOutputContext(ctx),
        kvx.WithOutputNoColor(o.CliParams.NoColor),
        kvx.WithOutputAppName("scafctl foo bar"),
        kvx.WithOutputHelp("Results", []string{
            "Navigate: ↑↓ arrows",
            "Search: /",
            "Quit: q",
        }),
    )
    kvxOpts.IOStreams = o.IOStreams

    return kvxOpts.Write(data)
}

Output Formats#

FormatFlagDescription
table-o tableInteractive table view (default for terminals)
json-o jsonJSON output for piping
yaml-o yamlYAML output for piping
quiet-o quietNo output, exit code only

Interactive Mode#

Enable with -i or --interactive:

scafctl run solution -f config.yaml -i

Display Schemas for Array Output#

kvx.OutputOptions has two distinct schema slots, and they do different things:

OptionSlotEffect
kvx.WithOutputSchemaJSONcolumn-hint schemaTunes the table view only (column titles, widths).
kvx.WithOutputDisplaySchemaJSONdisplay schemaDrives the interactive TUI (-i): a card list plus a sectioned detail pane.

Rule of thumb: a command that emits an array of objects should attach a display schema by default so -i renders a rich card/detail view instead of falling back to a plain KEY/VALUE table. A command that emits a single object or a scalar (for example auth token) does not need one – a KEY/VALUE table renders fine, and the author may add a schema only if they want to.

Prefer a go:embed-ed <cmd>_schema.json file. The schema is a JSON Schema whose root type is array, decorated with x-kvx-* vendor extensions:

  • x-kvx-icon, x-kvx-collectionTitle – collection-level chrome.
  • x-kvx-list – card rendering (titleField, subtitleField, badgeFields).
  • x-kvx-detail – detail pane (titleField, hiddenFields, sections[] with a per-section layout of inline, paragraph, tags, or table).
  • items – a standard JSON Schema object describing each element.

The canonical example is get provider:

//go:embed provider_schema.json
var providerSchemaJSON []byte

func (o *Options) writeOutput(ctx context.Context, data any) error {
    kvxOpts := flags.ToKvxOutputOptions(&o.KvxOutputFlags,
        kvx.WithOutputContext(ctx),
        kvx.WithOutputAppName(o.BinaryName+" get provider"),
        kvx.WithOutputDisplaySchemaJSON(providerSchemaJSON), // interactive card/detail
        kvx.WithIOStreams(o.IOStreams),
    )
    return kvxOpts.Write(data)
}

See pkg/cmd/scafctl/get/provider/provider.go and its provider_schema.json for the full schema. You can attach both slots – a column-hint schema for the table and a display schema for -i – when you want to tune both views.

CEL Filtering#

Use -e or --expression to filter/transform output:

# Select specific field
scafctl run solution -f config.yaml -e '_.database'

# Filter array
scafctl run solution -f config.yaml -e '_.items.filter(x, x.enabled)'

# Compute values
scafctl run solution -f config.yaml -e 'size(_.results)'

Flags and Parameters#

Standard Flags#

// String flag
cCmd.Flags().StringVarP(&options.File, "file", "f", "", "Path to file")

// Bool flag
cCmd.Flags().BoolVar(&options.Verbose, "verbose", false, "Enable verbose")

// Duration flag
cCmd.Flags().DurationVar(&options.Timeout, "timeout", 30*time.Second, "Timeout")

// String slice (repeatable)
cCmd.Flags().StringArrayVarP(&options.Params, "param", "p", nil, "Parameters")

// Int flag
cCmd.Flags().Int64Var(&options.MaxSize, "max-size", 1024*1024, "Max size in bytes")

Key-Value Flags#

For key=value parameters, use the flags package:

import "github.com/oakwood-commons/scafctl/pkg/flags"

// In command setup
cCmd.Flags().StringArrayVarP(&options.Params, "param", "p", nil, 
    "Parameters (key=value or @file.yaml)")

// In Run method
params, err := flags.ParseKeyValueCSV(options.Params)
if err != nil {
    return fmt.Errorf("invalid parameters: %w", err)
}

Supported formats:

  • key=value - Simple key-value
  • key=val1,key=val2 - Multiple values (becomes array)
  • @file.yaml - Load from file
  • @- - Read parameters from stdin (YAML or JSON)
  • "key=value with spaces" - Quoted values

Validating Input Keys Against a Schema#

When a command accepts dynamic key=value inputs and has a known set of valid keys (e.g. from a provider’s JSON Schema or a solution’s parameter resolvers), use flags.ValidateInputKeys for early detection of typos:

import "github.com/oakwood-commons/scafctl/pkg/flags"

// After parsing inputs and looking up valid keys
validKeys := []string{"url", "method", "headers", "body", "timeout"}
if err := flags.ValidateInputKeys(inputs, validKeys, `provider "http"`); err != nil {
    // Error: provider "http" does not accept input "urll" — did you mean "url"?
    return err
}

This uses Levenshtein distance to suggest the closest valid key when a typo is detected.

Hidden Flags#

cCmd.Flags().String("internal-flag", "", "For internal use")
if err := cCmd.Flags().MarkHidden("internal-flag"); err != nil {
    return nil
}

Context Management#

Setting Up Context#

func RunE(cCmd *cobra.Command, args []string) error {
    // 1. Create base context with settings
    cliParams.EntryPointSettings.Path = filepath.Join(path, cCmd.Use)
    ctx := settings.IntoContext(context.Background(), cliParams)

    // 2. Attach logger from parent (or create new)
    lgr := logger.FromContext(cCmd.Context())
    if lgr != nil {
        ctx = logger.WithLogger(ctx, lgr)
    }

    // 3. Attach writer
    w := writer.FromContext(cCmd.Context())
    if w == nil {
        w = writer.New(ioStreams, cliParams)
    }
    ctx = writer.WithWriter(ctx, w)

    return options.Run(ctx)
}

Accessing Context Values#

func (o *Options) Run(ctx context.Context) error {
    // Logger
    lgr := logger.FromContext(ctx)
    lgr.V(1).Info("message", "key", value)

    // Writer
    w := writer.FromContext(ctx)
    if w == nil {
        return fmt.Errorf("writer not initialized in context")
    }
    w.Success("Done")

    // Settings
    settings := settings.FromContext(ctx)
}

Testing Commands#

Basic Command Test#

func TestCommandFoo(t *testing.T) {
    t.Parallel()

    streams, _, _ := terminal.NewTestIOStreams()
    cliParams := settings.NewCliParams()

    cmd := CommandFoo(cliParams, streams, "")

    // Verify command setup
    assert.Equal(t, "foo", cmd.Use)
    assert.NotEmpty(t, cmd.Short)

    // Verify flags
    flags := cmd.Flags()
    assert.NotNil(t, flags.Lookup("file"))
    assert.NotNil(t, flags.Lookup("output"))
}

Testing Command Execution#

func TestFooOptions_Run(t *testing.T) {
    t.Parallel()

    // Create test streams
    var stdout, stderr bytes.Buffer
    streams := &terminal.IOStreams{
        In:           io.NopCloser(bytes.NewReader(nil)),
        Out:          &stdout,
        ErrOut:       &stderr,
        ColorEnabled: false,
    }

    // Create test context
    lgr := logger.Get(0)
    ctx := logger.WithLogger(context.Background(), lgr)
    
    cliParams := settings.NewCliParams()
    w := writer.New(streams, cliParams)
    ctx = writer.WithWriter(ctx, w)

    // Set up options with test dependencies
    options := &FooOptions{
        IOStreams: streams,
        CliParams: cliParams,
        File:      "/path/to/file",
        KvxOutputFlags: flags.KvxOutputFlags{
            Output: "json",
        },
        // Inject mock dependencies
        getter: &mockGetter{...},
    }

    // Run and verify
    err := options.Run(ctx)
    require.NoError(t, err)

    // Check output
    assert.Contains(t, stdout.String(), `"status":"success"`)
}

Testing with Exit Capture#

func TestErrorWithExit(t *testing.T) {
    streams, _, errBuf := terminal.NewTestIOStreams()
    cliParams := settings.NewCliParams()

    var exitCode int
    w := writer.New(streams, cliParams, writer.WithExitFunc(func(code int) {
        exitCode = code
    }))

    w.ErrorWithExit("fatal error")

    assert.Equal(t, 1, exitCode)
    assert.Contains(t, errBuf.String(), "fatal error")
}

Testing Flag Defaults#

func TestCommandFoo_FlagDefaults(t *testing.T) {
    t.Parallel()

    streams, _, _ := terminal.NewTestIOStreams()
    cliParams := settings.NewCliParams()

    cmd := CommandFoo(cliParams, streams, "")
    flags := cmd.Flags()

    file, err := flags.GetString("file")
    require.NoError(t, err)
    assert.Empty(t, file)

    timeout, err := flags.GetDuration("timeout")
    require.NoError(t, err)
    assert.Equal(t, 30*time.Second, timeout)

    output, err := flags.GetString("output")
    require.NoError(t, err)
    assert.Equal(t, "table", output)
}

Mock Dependency Injection#

// In options struct
type FooOptions struct {
    // ... flags
    
    // For testing
    getter GetterInterface
}

// In Run method
func (o *FooOptions) Run(ctx context.Context) error {
    getter := o.getter
    if getter == nil {
        getter = NewDefaultGetter()  // Production default
    }
    
    data, err := getter.Get(ctx, o.File)
    // ...
}

// In tests
func TestWithMock(t *testing.T) {
    options := &FooOptions{
        getter: &mockGetter{
            data: testData,
        },
    }
    // ...
}

Common Patterns#

Exit Codes#

Define and use consistent exit codes:

const (
    ExitSuccess          = 0
    ExitGeneralError     = 1
    ExitValidationFailed = 2
    ExitInvalidInput     = 3
    ExitFileNotFound     = 4
)

func (o *Options) exitWithCode(err error, code int) error {
    // Could log the code or set process exit code
    return err
}

Shared RunE Factory#

For consistent command setup across related subcommands:

// common.go
type runCommandConfig struct {
    cliParams     *settings.Run
    ioStreams     *terminal.IOStreams
    path          string
    runner        interface{ Run(context.Context) error }
    getOutputFn   func() string
    setIOStreamFn func(*terminal.IOStreams, *settings.Run)
}

func makeRunEFunc(cfg runCommandConfig, cmdUse string) func(*cobra.Command, []string) error {
    return func(cCmd *cobra.Command, args []string) error {
        cfg.cliParams.EntryPointSettings.Path = filepath.Join(cfg.path, cmdUse)
        ctx := settings.IntoContext(context.Background(), cfg.cliParams)

        lgr := logger.FromContext(cCmd.Context())
        if lgr != nil {
            ctx = logger.WithLogger(ctx, lgr)
        }

        w := writer.FromContext(cCmd.Context())
        if w == nil {
            w = writer.New(cfg.ioStreams, cfg.cliParams)
        }
        ctx = writer.WithWriter(ctx, w)

        cfg.setIOStreamFn(cfg.ioStreams, cfg.cliParams)

        if err := output.ValidateCommands(args); err != nil {
            w.Error(err.Error())
            return err
        }

        return cfg.runner.Run(ctx)
    }
}

Reading from File or Stdin#

func (o *Options) loadData(ctx context.Context) ([]byte, error) {
    // Handle stdin
    if o.File == "-" {
        data, err := io.ReadAll(o.IOStreams.In)
        if err != nil {
            return nil, fmt.Errorf("failed to read from stdin: %w", err)
        }
        return data, nil
    }

    // Handle file
    if o.File != "" {
        data, err := os.ReadFile(o.File)
        if err != nil {
            return nil, fmt.Errorf("failed to read file: %w", err)
        }
        return data, nil
    }

    // Auto-discovery
    return o.autoDiscover(ctx)
}

Progress Reporting#

For long-running operations:

if o.Progress {
    progress := NewProgressReporter(o.IOStreams.ErrOut, totalItems)
    defer progress.Wait()
    
    // Update progress
    progress.Update(itemName, "processing")
    progress.Complete(itemName)
}

Checklist#

Before submitting a new command:

  • Options struct has proper JSON/YAML tags and Huma validation tags
  • Command has Use, Aliases, Short, Long, and examples
  • SilenceUsage: true is set
  • Logger is retrieved from context: logger.FromContext(ctx)
  • Writer is used for all terminal output (no fmt.Fprintf)
  • Errors are wrapped with context: fmt.Errorf("context: %w", err)
  • Data output uses kvx for structured data
  • Unit tests cover command setup and flag defaults
  • Unit tests cover main execution paths
  • Tests use dependency injection for external services
  • golangci-lint run passes
  • Command is registered in parent command
  • Documentation in Long field includes examples

Quick Reference#

Imports#

import (
    "github.com/oakwood-commons/scafctl/pkg/cmd/flags"       // Shared flag helpers
    "github.com/oakwood-commons/scafctl/pkg/logger"          // Logging
    "github.com/oakwood-commons/scafctl/pkg/settings"        // CLI settings
    "github.com/oakwood-commons/scafctl/pkg/terminal"        // IOStreams
    "github.com/oakwood-commons/scafctl/pkg/terminal/kvx"    // Data output
    "github.com/oakwood-commons/scafctl/pkg/terminal/output" // Validation helpers
    "github.com/oakwood-commons/scafctl/pkg/terminal/writer" // Terminal writer
    "github.com/spf13/cobra"                                 // CLI framework
)

Minimal Command Template#

package mycommand

import (
    "context"
    "path/filepath"

    "github.com/oakwood-commons/scafctl/pkg/logger"
    "github.com/oakwood-commons/scafctl/pkg/settings"
    "github.com/oakwood-commons/scafctl/pkg/terminal"
    "github.com/oakwood-commons/scafctl/pkg/terminal/writer"
    "github.com/spf13/cobra"
)

type Options struct {
    IOStreams *terminal.IOStreams
    CliParams *settings.Run
    Name      string `json:"name" yaml:"name" doc:"Name" example:"example" maxLength:"255"`
}

func Command(cliParams *settings.Run, ioStreams *terminal.IOStreams, path string) *cobra.Command {
    opts := &Options{}
    
    cmd := &cobra.Command{
        Use:          "mycommand",
        Short:        "Does something",
        SilenceUsage: true,
        RunE: func(cmd *cobra.Command, args []string) error {
            cliParams.EntryPointSettings.Path = filepath.Join(path, cmd.Use)
            ctx := settings.IntoContext(context.Background(), cliParams)
            
            if lgr := logger.FromContext(cmd.Context()); lgr != nil {
                ctx = logger.WithLogger(ctx, lgr)
            }
            
            w := writer.FromContext(cmd.Context())
            if w == nil {
                w = writer.New(ioStreams, cliParams)
            }
            ctx = writer.WithWriter(ctx, w)
            
            opts.IOStreams = ioStreams
            opts.CliParams = cliParams
            
            return opts.Run(ctx)
        },
    }
    
    cmd.Flags().StringVarP(&opts.Name, "name", "n", "", "Name to use")
    
    return cmd
}

func (o *Options) Run(ctx context.Context) error {
    w := writer.FromContext(ctx)
    if w == nil {
        return fmt.Errorf("writer not initialized in context")
    }
    w.Successf("Hello, %s!", o.Name)
    return nil
}

Implementation Priority#

Recommended order for implementing CLI changes:

Phase 1: Core Fixes (High Priority) ✅ COMPLETED#

  1. Fix run solution - Execute resolvers AND actions (not just resolvers) ✅
  2. Remove run workflow - Merge functionality into run solution ✅
  3. Add singular/plural aliases - solution/solutions, provider/providers, catalog/catalogs

Phase 2: Render Enhancements#

  1. Add --graph to render solution - Move from resolver graph
  2. Add --snapshot to render solution - Move from snapshot save
  3. Remove old commands - Delete resolver graph, snapshot save

Phase 3: Discovery Commands#

  1. Implement get providers - List all registered providers
  2. Implement explain provider - Show provider schema/docs
  3. Implement explain solution - Show solution metadata

Phase 4: Configuration#

  1. Implement config view - View current configuration
  2. Implement config get/set/unset - Manage config values
  3. Implement catalog config commands - add-catalog, remove-catalog, use-catalog

Phase 5: Catalog Integration#

  1. Implement get catalogs - List configured catalogs
  2. Implement get catalog - Show catalog details
  3. Implement get solutions - List solutions from catalog
  4. Implement publish solution - Publish to catalog
  5. Implement delete solution - Remove from catalog
  6. Add name@version resolution - Catalog lookup for all commands

Command Implementation Status#

This section tracks which commands from the design are implemented and what work remains.

Legend#

StatusMeaning
✅Implemented
⚠️Partially implemented (needs changes)
❌Not implemented

Current Status#

CommandStatusNotes
version✅Complete
get solution✅Complete with catalog support
get solutions✅List all solutions (plural alias)
get provider✅Show provider metadata
get providers✅List all registered providers (plural alias)
eval refs✅Extract resolver references from templates/expressions (was get resolver refs)
get celfunction✅Show CEL function details
run solution✅Executes resolvers AND actions
run resolver✅Executes resolvers only (for debugging and inspection)
render solution✅Includes --action-graph, --snapshot, --redact flags
package solution✅Package solution into local catalog (verifies bundle completeness; --strict/--no-verify)
catalog push✅Push artifacts to remote catalog
catalog pull✅Pull artifacts from remote catalog (re-checks bundle completeness; warns, or --strict to fail)
catalog list✅List catalog contents
catalog inspect✅Inspect artifact metadata
catalog delete✅Delete artifact from catalog
catalog prune✅Prune unused catalog entries
catalog tag✅Create version aliases
catalog save✅Export artifact to tar
catalog load✅Import artifact from tar
inspect solution✅Show solution structure/metadata (was explain solution; --usage for the usage view)
explain provider✅Show provider schema/docs
get snapshot✅Display saved snapshot
diff snapshot✅Compare two snapshots
config view✅View current configuration
config get✅Get specific config value
config set✅Set config value
config unset✅Remove config value
catalog remote add✅Add catalog configuration
catalog remote remove✅Remove catalog
catalog remote default✅Set default catalog
config init✅Initialize configuration
config schema✅Show config schema
config validate✅Validate config file
eval cel✅Evaluate CEL expressions
eval template✅Evaluate Go templates
eval validate✅Validate expressions
new solution✅Scaffold new solution
lint✅Lint solution files
lint rules✅List lint rules
lint rule✅Explain a lint rule (was lint explain, now a hidden deprecated alias)
test functional✅Run functional tests
test list✅List test cases
test init✅Scaffold test suite
examples list✅List available examples
examples get✅Get an example
diff bundle✅Diff two bundles
extract bundle✅Extract bundle contents
vendor update✅Update vendored dependencies
secrets list✅List secrets
secrets get✅Get a secret
secrets set✅Set a secret
secrets delete✅Delete a secret
secrets exists✅Check if a secret exists
secrets export✅Export secrets
secrets import✅Import secrets
secrets rotate✅Rotate encryption key
auth login✅Authenticate with a handler
auth logout✅Clear stored credentials
auth status✅Show auth status
auth token✅Get an access token
auth list✅List auth handlers
mcp serve✅Start MCP server
cache clear✅Clear caches

Commands Removed/Refactored#

CommandActionReason
run workflow✅ RemovedMerged into run solution (solution now runs resolvers + actions)
snapshot save✅ RemovedReplaced with render solution --snapshot
resolver graph✅ RemovedReplaced with run resolver --graph

Code Changes Required#

1. Fix run solution ✅ COMPLETED#

Previous behavior: Executed resolvers only, output resolver results.

New behavior: Executes resolvers AND actions. Per design doc:

“Execute a solution’s resolver and perform its actions.”

Changes made:

  • Removed pkg/cmd/scafctl/run/workflow.go (merged into solution.go)
  • Updated run/solution.go to execute actions after resolvers complete
  • Added run resolver command for resolver-only execution (debugging/inspection)
  • Added --dry-run flag to show what would execute
  • Added --action-timeout and --max-action-concurrency flags
  • Actions run using the action executor with resolver results in context
// After resolver execution succeeds:
if sol.Spec.HasWorkflow() {
    actionExecutor := action.NewExecutor(...)
    result, err := actionExecutor.Execute(ctx, sol.Spec.Workflow)
    // ...
}

2. Add --graph and --snapshot to render solution#

Changes needed:

  • Move graph logic from pkg/cmd/scafctl/resolver/graph.go to pkg/cmd/scafctl/render/
  • Remove pkg/cmd/scafctl/resolver/ directory
  • Remove pkg/cmd/scafctl/snapshot/save.go
  • Add flags to render solution:
type RenderOptions struct {
    // ... existing fields
    
    // Graph mode - show dependency graph instead of rendering
    Graph       bool   `json:"graph,omitempty" yaml:"graph,omitempty" doc:"Show dependency graph"`
    GraphFormat string `json:"graphFormat,omitempty" yaml:"graphFormat,omitempty" doc:"Graph format: ascii, dot, mermaid, json" example:"ascii" maxLength:"10"`
    
    // Snapshot mode - save execution snapshot to file
    Snapshot    string `json:"snapshot,omitempty" yaml:"snapshot,omitempty" doc:"Save snapshot to file" maxLength:"4096"`
    Redact      bool   `json:"redact,omitempty" yaml:"redact,omitempty" doc:"Redact sensitive values in snapshot"`
}

Examples:

# Normal render (resolvers + action preview)
scafctl render solution -f solution.yaml

# Show resolver dependency graph
scafctl run resolver -f solution.yaml --graph
scafctl run resolver -f solution.yaml --graph --graph-format dot | dot -Tpng > graph.png
scafctl run resolver -f solution.yaml --graph --graph-format mermaid

# Save snapshot
scafctl render solution -f solution.yaml --snapshot output.json
scafctl render solution -f solution.yaml --snapshot output.json --redact

3. Implement get provider / get providers#

// pkg/cmd/scafctl/get/provider/provider.go

func CommandProvider(...) *cobra.Command {
    // get provider <name> - show provider details
    // get provider (no name) OR get providers - list all
}

Output for get provider <name>:

  • Provider name and version
  • Description
  • Supported operations
  • Configuration schema
  • Example usage

Output for get providers:

  • Table of all registered providers with name, version, description

4. Implement Singular/Plural Aliases#

Use Cobra aliases for plural forms:

func CommandSolution(...) *cobra.Command {
    cmd := &cobra.Command{
        Use:     "solution [name[@version]]",
        Aliases: []string{"solutions"},  // Plural alias
        Short:   "Get solution(s)",
        // ...
    }
    // ...
}

Apply to all get subcommands:

  • solution / solutions
  • provider / providers
  • catalog / catalogs

5. Implement get catalog / get catalogs#

// pkg/cmd/scafctl/get/catalog/catalog.go

func CommandCatalog(...) *cobra.Command {
    // get catalog <name> - show catalog details
    // get catalog (no name) OR get catalogs - list all configured
}

6. Implement Catalog Artifact Commands#

package solution / package plugin#
// pkg/cmd/scafctl/packagecmd/solution.go

type SolutionOptions struct {
    File string // -f flag
}

Flags:

  • package solution: -f, --file - Solution file path
  • package plugin: --name, --kind, --version, and one or more --platform os/arch=path

Examples:

scafctl package solution -f ./solution.yaml
scafctl package plugin --name my-plugin --kind provider --version 1.0.0 \
  --platform linux/amd64=./bin/my-plugin
push solution / push plugin#
// pkg/cmd/scafctl/push/solution.go

type PushOptions struct {
    Name    string // From args (name@version)
    Catalog string // --catalog for target
}

Flags:

  • --catalog - Target catalog for publishing

Examples:

scafctl push solution my-solution@1.7.0
scafctl push plugin aws-provider@1.5.0
scafctl push solution my-solution@1.7.0 --catalog=production
pull solution / pull plugin#
// pkg/cmd/scafctl/pull/solution.go

type PullOptions struct {
    Name string // From args (name@version)
}

Examples:

scafctl pull solution example@1.7.0
scafctl pull plugin aws-provider@1.5.0
inspect solution / inspect plugin#
// pkg/cmd/scafctl/inspect/solution.go

type InspectOptions struct {
    Name   string // From args (name@version)
    Output string // Output format
}

Examples:

scafctl inspect solution example@1.7.0
scafctl inspect plugin aws-provider@1.5.0
tag solution / tag plugin#
// pkg/cmd/scafctl/tag/solution.go

type TagOptions struct {
    Source string // Source reference
    Target string // Target tag
}

Examples:

scafctl tag solution my-solution@1.2.3 my-solution:latest
scafctl tag plugin aws-provider@1.5.0 aws-provider:stable
save solution / save plugin#
// pkg/cmd/scafctl/save/solution.go

type SaveOptions struct {
    Name   string // From args (name@version)
    Output string // -o flag for output file
}

Examples:

scafctl save solution my-solution@1.2.3 -o solution.tar
scafctl save plugin aws-provider@1.5.0 -o plugin.tar
load#
// pkg/cmd/scafctl/load/load.go

type LoadOptions struct {
    Input string // -i flag for input file
}

Examples:

scafctl load -i solution.tar
scafctl load -i plugin.tar

7. Implement explain solution#

// pkg/cmd/scafctl/explain/solution.go

type ExplainSolutionOptions struct {
    File   string // -f flag for local file
    Name   string // Solution name from catalog
    Output string // Output format
}

Output for explain solution:

  • Name, version, description
  • List of resolvers with their providers
  • List of actions with types
  • Required parameters (from parameter provider usage)
  • Dependencies between resolvers (summary)

Examples:

scafctl explain solution -f solution.yaml
scafctl explain solution example
scafctl explain solution example@1.0.0

8. Implement explain provider#

// pkg/cmd/scafctl/explain/provider.go

type ExplainOptions struct {
    ProviderName string
}

Output: Detailed documentation for a provider including:

  • Description
  • Configuration schema with types and validation
  • Example configurations
  • Supported features

9. Implement config Commands#

// pkg/cmd/scafctl/config/config.go

func CommandConfig(...) *cobra.Command {
    cmd.AddCommand(CommandView(...))
    cmd.AddCommand(CommandGet(...))
    cmd.AddCommand(CommandSet(...))
    cmd.AddCommand(CommandUnset(...))
    cmd.AddCommand(CommandAddCatalog(...))
    cmd.AddCommand(CommandRemoveCatalog(...))
    cmd.AddCommand(CommandUseCatalog(...))
}

Config file location: ~/.scafctl/config.yaml

Config structure:

catalogs:
  - name: default
    type: filesystem
    path: ./
  - name: internal
    type: oci
    url: oci://registry.example.com/scafctl
settings:
  defaultCatalog: default

10. Implement delete solution#

// pkg/cmd/scafctl/delete/solution.go

type DeleteSolutionOptions struct {
    Name    string // Solution name (from args)
    Version string // Version (parsed from name@version)
    Catalog string // --catalog flag
    Force   bool   // --force skip confirmation
}

Flags:

  • --catalog - Target catalog (inherited from global)
  • --force - Skip confirmation prompt

Examples:

scafctl delete solution example@1.7.0
scafctl delete solution example@1.7.0 --catalog=staging
scafctl delete solution example@1.7.0 --force

Behavior:

  • Requires name@version (cannot delete all versions at once)
  • Prompts for confirmation unless --force
  • Requires catalog support

Catalog Dependency#

Many commands require catalog functionality for name@version resolution:

CommandCatalog Required?
run solution -f file.yamlNo
run solution exampleYes (lookup by name)
run solution example@1.0.0Yes (lookup by name + version)
get solutionsYes (list from catalog)
publish solutionYes (publish to catalog)
delete solutionYes (delete from catalog)

Recommendation: Implement CLI structure first with file-based operations (-f flag), then add catalog support. Commands requiring catalog can return helpful errors:

if o.File == "" && name != "" {
    return fmt.Errorf("catalog lookup not yet implemented; use -f flag to specify a file")
}

Global Flags#

Global flags are available on all commands. Users can view them via scafctl options. They are defined as persistent flags on the root command in root.go and are hidden from --help output — instead, every command’s help footer says:

Use "scafctl options" for a list of global command-line options (applies to all commands).
FlagShortTypeDefaultDescription
--cwd-Cstring""Change the working directory before executing the command (similar to git -C)
--catalogstring""Target a specific configured catalog
--output-ostringtableOutput format: table, json, yaml, quiet
--quiet-qboolfalseSuppress non-essential output
--no-colorboolfalseDisable colored output
--configstring~/.scafctl/config.yamlPath to config file
--log-levelstringnoneLog level: none, error, warn, info, debug, trace, or numeric V-level
--debug-dboolfalseEnable debug logging (shorthand for –log-level debug)
--log-formatstringconsoleLog format: console (colored) or json (structured)
--log-filestring""Write logs to a file path

Adding Global Flags#

Global flags are defined as persistent flags in root.go. The custom usage template ensures they are hidden from all --help output and only shown via scafctl options:

func Root() *cobra.Command {
    cCmd.PersistentFlags().StringVar(&cliParams.Catalog, "catalog", "", 
        "Target a specific configured catalog")
    cCmd.PersistentFlags().StringVar(&cliParams.ConfigFile, "config", "", 
        "Path to config file (default: ~/.scafctl/config.yaml)")
    // ... existing flags
}

Command Groups#

Top-level commands are organized into named groups using Cobra’s AddGroup() API. Groups are defined in root.go and each command is assigned via the withGroup() helper:

Group IDTitleCommands
coreCore Commandsrun, render, lint, test
inspectInspection Commandseval, explain, get, snapshot, solution
scaffoldScaffolding Commandsnew, build, bundle, catalog, vendor
configConfiguration & Security Commandsauth, cache, config, secrets
pluginPlugin Commandsmcp, plugins

Commands without a group (e.g., completion, examples, help, options, version) appear under Additional Commands.

When adding a new top-level command, assign it to the appropriate group:

cCmd.AddCommand(withGroup(groupCore, myCmd.CommandMyCmd(cliParams, ioStreams, settings.CliBinaryName)))