cli/docs/plans/2026-01-30-cli-commands-design.md
Snider f2f7e27e77 docs(cli): add CLI commands registration design
Design for moving cmd/ into self-registering packages in pkg/:
- RegisterCommands() pattern like RegisterLocales()
- rootCmd stored in core.App
- Build variants via import files with build tags
- Smaller attack surface through selective compilation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 21:18:54 +00:00

4.3 KiB

CLI Commands Registration Design

Overview

Move CLI commands from cmd/ into self-registering packages in pkg/. This enables build variants with reduced attack surface - only compiled code exists in the binary.

Pattern

Same pattern as i18n.RegisterLocales():

  • Packages register themselves during init()
  • Registration is stored until cli.Init() runs
  • Build tags control which packages are imported

Registration API

// pkg/cli/commands.go

type CommandRegistration func(root *cobra.Command)

var (
    registeredCommands   []CommandRegistration
    registeredCommandsMu sync.Mutex
)

// RegisterCommands registers a function that adds commands to the CLI.
func RegisterCommands(fn CommandRegistration) {
    registeredCommandsMu.Lock()
    defer registeredCommandsMu.Unlock()
    registeredCommands = append(registeredCommands, fn)
}

func attachRegisteredCommands(root *cobra.Command) {
    registeredCommandsMu.Lock()
    defer registeredCommandsMu.Unlock()
    for _, fn := range registeredCommands {
        fn(root)
    }
}

Integration with Core.App

The CLI stores rootCmd in core.App, unifying GUI and CLI under the same pattern:

// pkg/cli/runtime.go

func Init(opts Options) error {
    once.Do(func() {
        rootCmd := &cobra.Command{
            Use:     opts.AppName,
            Version: opts.Version,
        }

        attachRegisteredCommands(rootCmd)

        c, err := framework.New(
            framework.WithApp(rootCmd),
            // ... services ...
        )
        // ...
    })
    return initErr
}

func RootCmd() *cobra.Command {
    return framework.App().(*cobra.Command)
}

func Execute() error {
    return RootCmd().Execute()
}

Package Structure

Commands move from cmd/ to pkg/ with a cmd.go file:

pkg/
├── php/
│   ├── i18n.go          # registers locales
│   ├── cmd.go           # registers commands
│   ├── locales/
│   └── ...
├── dev/
│   ├── cmd.go           # registers commands
│   └── ...
├── cli/
│   ├── commands.go      # RegisterCommands API
│   ├── runtime.go       # Init, Execute
│   └── ...

Each cmd.go:

// pkg/php/cmd.go
package php

import "github.com/host-uk/core/pkg/cli"

func init() {
    cli.RegisterCommands(AddCommands)
}

func AddCommands(root *cobra.Command) {
    // ... existing command setup ...
}

Build Variants

Import files with build tags in cmd/variants/:

cmd/
├── main.go
└── variants/
    ├── full.go      # default: all packages
    ├── ci.go        # CI/release only
    ├── php.go       # PHP tooling only
    └── minimal.go   # core only
// cmd/variants/full.go
//go:build !ci && !php && !minimal

package variants

import (
    _ "github.com/host-uk/core/pkg/ai"
    _ "github.com/host-uk/core/pkg/build"
    _ "github.com/host-uk/core/pkg/ci"
    _ "github.com/host-uk/core/pkg/dev"
    _ "github.com/host-uk/core/pkg/docs"
    _ "github.com/host-uk/core/pkg/doctor"
    _ "github.com/host-uk/core/pkg/go"
    _ "github.com/host-uk/core/pkg/php"
    _ "github.com/host-uk/core/pkg/pkg"
    _ "github.com/host-uk/core/pkg/sdk"
    _ "github.com/host-uk/core/pkg/setup"
    _ "github.com/host-uk/core/pkg/test"
    _ "github.com/host-uk/core/pkg/vm"
)
// cmd/variants/ci.go
//go:build ci

package variants

import (
    _ "github.com/host-uk/core/pkg/build"
    _ "github.com/host-uk/core/pkg/ci"
    _ "github.com/host-uk/core/pkg/doctor"
    _ "github.com/host-uk/core/pkg/sdk"
)

Build Commands

  • go build → full variant (default)
  • go build -tags ci → CI variant
  • go build -tags php → PHP-only variant

Benefits

  1. Smaller attack surface - only compiled code exists in binary
  2. Self-registering packages - same pattern as i18n.RegisterLocales()
  3. Uses existing core.App - no new framework concepts
  4. Simple build variants - just add -tags flag
  5. Defence in depth - no code = no vulnerabilities

Migration Steps

  1. Add RegisterCommands() to pkg/cli/commands.go
  2. Update pkg/cli/runtime.go to use core.App for rootCmd
  3. Move each cmd/* package to pkg/*/cmd.go
  4. Create cmd/variants/ with build tag files
  5. Simplify cmd/main.go to minimal entry point
  6. Remove old cmd/core_dev.go and cmd/core_ci.go