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

185 lines
4.3 KiB
Markdown

# 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
```go
// 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:
```go
// 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`:
```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
```
```go
// 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"
)
```
```go
// 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`