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
├── build
   ├── solution                  # Build solution into local catalog
   └── plugin                    # Build 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
├── 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
├── build/               # 'build' verb (analogous to docker build)
   ├── build.go
   ├── solution.go      # 'build solution' to local catalog
   └── plugin.go        # 'build 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
├── snapshot/            # 'snapshot' verb (analysis only)
   ├── snapshot.go
   ├── show.go          # 'snapshot show' display saved snapshot
   └── diff.go          # 'snapshot diff' 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 / Successfstdout
Warning / Warningfstdout
Error / Errorfstderr
Info / Infofstdout
Debug / Debugfstdout
Plain / Plainlnstdout

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

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
versionComplete
get solutionComplete with catalog support
get solutionsList all solutions (plural alias)
get providerShow provider metadata
get providersList all registered providers (plural alias)
get resolverShow resolver details
get authhandlerShow auth handler details
get celfunctionShow CEL function details
run solutionExecutes resolvers AND actions
run resolverExecutes resolvers only (for debugging and inspection)
render solutionIncludes --action-graph, --snapshot, --redact flags
build solutionBuild solution into local catalog
catalog pushPush artifacts to remote catalog
catalog pullPull artifacts from remote catalog
catalog listList catalog contents
catalog inspectInspect artifact metadata
catalog deleteDelete artifact from catalog
catalog prunePrune unused catalog entries
catalog tagCreate version aliases
catalog saveExport artifact to tar
catalog loadImport artifact from tar
explain solutionShow solution metadata
explain providerShow provider schema/docs
snapshot showDisplay saved snapshot
snapshot diffCompare two snapshots
config viewView current configuration
config getGet specific config value
config setSet config value
config unsetRemove config value
config add-catalogAdd catalog configuration
config remove-catalogRemove catalog
config use-catalogSet default catalog
config initInitialize configuration
config schemaShow config schema
config validateValidate config file
eval celEvaluate CEL expressions
eval templateEvaluate Go templates
eval validateValidate expressions
new solutionScaffold new solution
lintLint solution files
lint rulesList lint rules
lint explainExplain a lint rule
test functionalRun functional tests
test listList test cases
test initScaffold test suite
examples listList available examples
examples getGet an example
bundle verifyVerify bundle integrity
bundle diffDiff two bundles
bundle extractExtract bundle contents
vendor updateUpdate vendored dependencies
secrets listList secrets
secrets getGet a secret
secrets setSet a secret
secrets deleteDelete a secret
secrets existsCheck if a secret exists
secrets exportExport secrets
secrets importImport secrets
secrets rotateRotate encryption key
auth loginAuthenticate with a handler
auth logoutClear stored credentials
auth statusShow auth status
auth tokenGet an access token
auth listList auth handlers
mcp serveStart MCP server
cache clearClear caches

Commands Removed/Refactored#

CommandActionReason
run workflowRemovedMerged into run solution (solution now runs resolvers + actions)
snapshot saveRemovedReplaced with render solution --snapshot
resolver graphRemovedReplaced 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#

build solution / build plugin#
// pkg/cmd/scafctl/build/solution.go

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

Flags:

  • -f, --file - Solution/plugin file path

Examples:

scafctl build solution -f ./solution.yaml
scafctl build plugin -f ./plugin-config.yaml
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)))