cmd

package
v0.1.11 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 7, 2026 License: MIT Imports: 60 Imported by: 0

README

cmd/ - Command Definitions

This directory contains command files following the ultra-thin command pattern.

Overview

Command files in this directory are intentionally minimal (~20-30 lines) and serve as thin CLI wrappers. All business logic, configuration, and metadata are separated into dedicated packages.

Directory Structure

cmd/
├── README.md              # This file
├── root.go               # Root command (framework file)
├── flags.go              # Flag registration helpers (framework file)
├── helpers.go            # Command creation helpers (framework file)
├── ping.go               # Example ultra-thin command
└── docs.go               # Example ultra-thin command with subcommands

Framework vs User Files

Framework Files (DO NOT EDIT unless modifying the framework)
  • root.go - Root command setup
  • flags.go - Flag registration logic
  • helpers.go - NewCommand() and MustAddToRoot() helpers
User Files (Edit these when adding commands)
  • <command>.go - Individual command files

Creating a New Command

task generate:command name=mycommand

This creates:

  • cmd/mycommand.go - Ultra-thin command file
  • internal/config/commands/mycommand_config.go - Metadata + options
Option 2: Manual Creation
  1. Create command config in internal/config/commands/mycommand_config.go:
package commands

import "github.com/peiman/ckeletin-go/internal/config"

// MycommandMetadata defines all metadata for the mycommand command
var MycommandMetadata = config.CommandMetadata{
    Use:   "mycommand",
    Short: "Short description",
    Long:  `Long description...`,
    ConfigPrefix: "app.mycommand",
    FlagOverrides: map[string]string{
        "app.mycommand.some_option": "flag-name",
    },
}

func MycommandOptions() []config.ConfigOption {
    return []config.ConfigOption{
        {
            Key:          "app.mycommand.some_option",
            DefaultValue: "default",
            Description:  "Option description",
            Type:         "string",
        },
    }
}

func init() {
    config.RegisterOptionsProvider(MycommandOptions)
}
  1. Create command file in cmd/mycommand.go:
package cmd

import (
    "github.com/peiman/ckeletin-go/internal/config/commands"
    "github.com/peiman/ckeletin-go/internal/mycommand"
    "github.com/spf13/cobra"
)

var mycommandCmd = NewCommand(commands.MycommandMetadata, runMycommand)

func init() {
    MustAddToRoot(mycommandCmd)
}

func runMycommand(cmd *cobra.Command, args []string) error {
    cfg := mycommand.Config{
        SomeOption: getConfigValueWithFlags[string](cmd, "flag-name", "app.mycommand.some_option"),
    }
    return mycommand.NewExecutor(cfg, cmd.OutOrStdout()).Execute()
}
  1. Create business logic in internal/mycommand/mycommand.go:
package mycommand

import "io"

// Config holds configuration for mycommand business logic
type Config struct {
    SomeOption string
}

type Executor struct {
    cfg    Config
    writer io.Writer
}

func NewExecutor(cfg Config, writer io.Writer) *Executor {
    return &Executor{cfg: cfg, writer: writer}
}

func (e *Executor) Execute() error {
    // Business logic here
    return nil
}

Ultra-Thin Pattern Rules

✅ DO
  • Use NewCommand() to create commands from metadata
  • Use MustAddToRoot() to register commands
  • Keep command files ~20-30 lines
  • Move all business logic to internal/<command>/
  • Define metadata in internal/config/commands/<command>_config.go
  • Use dependency injection (pass io.Writer, etc.)
  • Add tests for business logic in internal/<command>/<command>_test.go
❌ DON'T
  • Hardcode command metadata (Use, Short, Long) in cmd files
  • Put business logic in cmd files
  • Manually call RegisterFlagsForPrefixWithOverrides() (NewCommand does this)
  • Manually call RootCmd.AddCommand() (MustAddToRoot does this)
  • Set defaults with viper.SetDefault() (use config registry)

Validation

Run the validation script to ensure commands follow the pattern:

task validate:commands
Whitelisting Commands

If you need to deviate from the pattern (e.g., complex command hierarchy), add this comment to the command file:

// ckeletin:allow-custom-command

Examples

  • Simple command: cmd/ping.go (~30 lines)
  • Command with subcommands: cmd/docs.go (~48 lines)

Architecture Benefits

  • Separation of Concerns: CLI wiring separate from business logic
  • Testability: Business logic easily testable without Cobra
  • Consistency: All commands follow same pattern
  • Maintainability: Metadata and options centralized
  • Discoverability: Single source of truth for each command
  • internal/config/command_metadata.go - CommandMetadata struct definition
  • internal/config/command_options.go - ConfigOption struct definition
  • internal/config/commands/ - All command configs (metadata + options)
  • cmd/helpers.go - Framework helpers for creating commands
  • scripts/validate-command-patterns.sh - Validation script

Documentation

Overview

ckeletin:allow-custom-command

ckeletin:allow-custom-command

This file is not a command — it's a helper for ask.go's --read flag. The ultra-thin-command validator (ADR-001) flags it because of its location (`cmd/`); the whitelist comment opts out of that check. The logic is small and ask-specific (resolving --read rank/id and composing search + body-fetch + render), so keeping it next to ask.go rather than splitting into a new internal/ package is the leaner shape.

ckeletin:allow-custom-command

`episode` is a utility command with no persistent user-facing config keys: its single flag (--output-dir) defaults to a path inside the project's identity vault, and its positional arg is a path provided per-invocation. There are no viper-bound settings worth the ceremony of the ckeletin config registry + generated constants. The marker above documents the deliberate exception to the MustNewCommand pattern used by config-driven commands elsewhere in cmd/.

Format helpers for `vaultmind experiment *` subcommands.

Consolidated here so each experiment_<subcommand>.go can be wire-only (cobra command + run function + small result types) without each one shipping its own ad-hoc text formatter. Output shape evolves across summary/trace/compare together, not piecemeal.

ckeletin:allow-custom-command

This file is not a command — it's a helper that installs a custom help-rendering function on the root command. The ultra-thin-command validator (ADR-001) flags it because of its location (`cmd/`) and the presence of `cobra.Command` references; the whitelist comment above opts out of that check. The agent-first help layout this file implements is co-designed via inter-agent review and the rationale for *not* moving it to internal/help/ is documented inline in installAgentRootHelp's docstring.

ckeletin:allow-custom-command

cmd/zz_catalog.go ckeletin:allow-custom-command

This file is the SINGLE SOURCE OF TRUTH for the command catalog: the cobra group every user-facing command belongs to, its when-to-use trigger phrase, and the composition of that trigger into each command's --help (Long) text.

Why a "zz_" filename: command registration happens in per-file init() functions scattered across cmd/. Go runs a package's init() functions in filename order, so a "zz_"-prefixed file is guaranteed to run LAST — after every command has been added to the tree. That lets decorateCommandCatalog walk a fully-assembled tree. The four groups themselves are registered in root.go's init() (group registration has no subcommand dependency).

The validator (ADR-001) flags this file because it lives in cmd/ and references cobra.Command without being a thin command; the whitelist comment above opts out. It is catalog wiring, not a command.

Index

Constants

View Source
const (
	// ConfigPathModeXDG searches XDG-style config directory (default).
	// On macOS, this means ~/.config/<app> unless XDG_CONFIG_HOME is set.
	ConfigPathModeXDG = "xdg"
	// ConfigPathModeNative searches the OS-native config directory.
	// On macOS, this means ~/Library/Application Support/<app>.
	ConfigPathModeNative = "native"
	// ConfigPathModeBoth searches both XDG and native directories.
	ConfigPathModeBoth = "both"
)

Variables

View Source
var (
	Version = "dev"
	Commit  = ""
	Date    = ""
)
View Source
var RootCmd = &cobra.Command{
	Use:           "",
	Short:         "A production-ready Go CLI application",
	Long:          "",
	SilenceErrors: true,
	PersistentPreRunE: func(cmd *cobra.Command, args []string) error {

		if err := bindFlags(cmd); err != nil {
			return fmt.Errorf("failed to bind flags: %w", err)
		}

		if f := cmd.Root().PersistentFlags().Lookup("output-format"); f != nil && f.Changed {
			output.SetOutputMode(f.Value.String())
		}
		output.SetCommandName(cmd.Name())

		zerolog.SetGlobalLevel(zerolog.InfoLevel)

		if err := initConfig(); err != nil {
			return err
		}

		if err := logger.Init(nil); err != nil {
			return fmt.Errorf("failed to initialize logger: %w", err)
		}

		output.SetOutputMode(viper.GetString(config.KeyAppOutputFormat))
		if output.IsJSONMode() {
			zerolog.SetGlobalLevel(zerolog.Disabled)
		}

		if configFileStatus != "" {
			if configFileUsed != "" {
				log.Debug().Str("config_file", logger.SanitizePath(configFileUsed)).Msg(configFileStatus)
			} else {
				log.Debug().Msg(configFileStatus)
			}
		}

		telemetry := viper.GetString(config.KeyExperimentsTelemetry)
		if telemetry == experiment.TelemetryOff {
			log.Debug().Msg("Experiments disabled (telemetry: off)")
		} else if expDB, expErr := openExperimentDB(); expErr != nil {
			log.Debug().Err(expErr).Msg("Experiment DB unavailable")
		} else {

			if telemetry == "" {
				if firstRun, _ := expDB.IsFirstRun(); firstRun {
					if isatty.IsTerminal(os.Stdin.Fd()) {
						telemetry = experiment.PromptTelemetry(os.Stdin, cmd.ErrOrStderr())
						viper.Set(config.KeyExperimentsTelemetry, telemetry)
						if cf := viper.ConfigFileUsed(); cf != "" {
							if err := persistTelemetryChoice(telemetry, cf); err != nil {
								log.Debug().Err(err).Msg("Failed to persist telemetry choice to config file")
							}
						}
						if telemetry == experiment.TelemetryOff {
							log.Debug().Msg("User chose telemetry: off")
							_ = expDB.Close()
							return nil
						}
					} else {
						log.Debug().Msg("Non-interactive session, defaulting to anonymous telemetry")
					}
				}
			}

			if recovered, recErr := expDB.RecoverOrphans(); recErr != nil {
				log.Debug().Err(recErr).Msg("Failed to recover orphan sessions")
			} else if recovered > 0 {
				log.Debug().Int("recovered", recovered).Msg("Recovered orphan experiment sessions")
			}
			caller, callerMeta := experiment.DetectCaller()
			sid, startErr := expDB.StartSessionWithCaller("", caller, callerMeta)
			if startErr != nil {
				log.Debug().Err(startErr).Msg("Failed to start experiment session")
				_ = expDB.Close()
			} else {
				outcomeWindow := viper.GetInt(config.KeyExperimentsOutcomeWindowSessions)
				if outcomeWindow <= 0 {
					outcomeWindow = 2
				}
				experimentSession = &experiment.Session{DB: expDB, ID: sid, OutcomeWindow: outcomeWindow}
				cmd.SetContext(experiment.WithSession(cmd.Context(), experimentSession))
			}
		}

		return nil
	},
	PersistentPostRunE: func(cmd *cobra.Command, args []string) error {
		if experimentSession != nil {
			_ = experimentSession.DB.EndSession(experimentSession.ID)
			_ = experimentSession.DB.Close()
			experimentSession = nil
		}
		return nil
	},
}

Export RootCmd so that tests in other packages can manipulate it without getters/setters.

Functions

func EnvPrefix

func EnvPrefix() string

EnvPrefix returns a sanitized environment variable prefix based on the binary name

func Execute

func Execute() error

func MustAddToRoot

func MustAddToRoot(cmd *cobra.Command)

MustAddToRoot adds a command to RootCmd and sets up configuration inheritance.

This is a convenience wrapper that combines two common operations:

  1. Adding the command to the root command
  2. Setting up command configuration to inherit from parent

Usage:

func init() {
    MustAddToRoot(myCmd)
}

This should be called in the init() function of your command file.

func MustNewCommand

func MustNewCommand(meta config.CommandMetadata, runE func(*cobra.Command, []string) error) *cobra.Command

MustNewCommand creates a Cobra command and panics on error.

This is a convenience wrapper for NewCommand intended for use in init() functions where there's no way to handle errors gracefully. For testable code or runtime command creation, use NewCommand instead.

Usage in init():

var myCmd = MustNewCommand(config.MyMetadata, runMy)

The runE function signature must be: func(*cobra.Command, []string) error

func NewCommand

func NewCommand(meta config.CommandMetadata, runE func(*cobra.Command, []string) error) (*cobra.Command, error)

NewCommand creates a Cobra command from metadata following ckeletin-go patterns.

This helper enforces the ultra-thin command pattern by:

  1. Creating the command from metadata (Use, Short, Long)
  2. Auto-registering flags from the config registry
  3. Applying custom flag overrides from metadata

Returns an error if flag registration fails, allowing callers to handle errors gracefully.

Usage:

cmd, err := NewCommand(config.MyMetadata, runMy)
if err != nil {
    return err
}

For init() functions where you want to panic on error, use MustNewCommand instead.

The runE function signature must be: func(*cobra.Command, []string) error

func RegisterFlagsForPrefixWithOverrides

func RegisterFlagsForPrefixWithOverrides(cmd *cobra.Command, prefix string, overrides map[string]string) error

RegisterFlagsForPrefixWithOverrides registers Cobra flags for all configuration options whose keys start with the provided prefix. It binds each flag to Viper using the option's key. Flag names are derived from the key suffix by converting underscores to hyphens, unless an explicit override is provided in the overrides map. Returns an error if flag binding fails.

Types

type ConfigPathInfo

type ConfigPathInfo struct {
	// ConfigName is the base config name without extension (e.g. "config")
	// Viper will search for config.yaml, config.yml, config.json, config.toml
	ConfigName string
	// XDGDir is the XDG-style config directory (e.g. "$XDG_CONFIG_HOME/myapp" or "~/.config/myapp")
	XDGDir string
	// NativeDir is the OS-native config directory (e.g. macOS "~/Library/Application Support/myapp").
	NativeDir string
	// Mode controls which user config directory is searched: xdg, native, or both.
	Mode string
	// SearchPaths lists all viper search paths in priority order.
	SearchPaths []string
}

ConfigPaths returns configuration paths for the application.

Config file search order (handled by viper):

  1. --config flag (explicit override)
  2. ./config.{yaml,yml,json,toml} (project-local config)
  3. User config directory based on path mode: - xdg (default): $XDG_CONFIG_HOME/<binaryName> or ~/.config/<binaryName> - native: OS-native config path (macOS: ~/Library/Application Support/<binaryName>) - both: xdg first, then native

Viper automatically detects the file format based on extension.

func ConfigPaths

func ConfigPaths() ConfigPathInfo

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL