CLI Best Practices
January 26, 2026 · View on GitHub
This document describes best practices for adding and maintaining CLI commands in ToolHive. These guidelines ensure a consistent, user-friendly command-line experience across the entire application.
Table of Contents
- Core Principles
- Command Structure
- Command Design
- Flags and Arguments
- Output and Formatting
- Error Messages
- User Feedback
- Testing CLI Commands
- Adding New Commands
Core Principles
0. CLI as Thin Wrappers (Architecture)
CRITICAL: CLI commands must be thin wrappers around business logic in pkg/ packages.
The CLI layer (cmd/thv/app/) is responsible ONLY for:
- Parsing flags and arguments
- Calling business logic from
pkg/packages - Formatting output (text/JSON)
All business logic must live in pkg/ packages where it can be:
- Thoroughly unit tested
- Reused by other components (API, operator)
- Maintained independently of CLI concerns
// ❌ Bad - Business logic in CLI
func listCmdFunc(cmd *cobra.Command, args []string) error {
// Complex container queries, filtering, transformation...
// 100+ lines of business logic here
}
// ✅ Good - CLI delegates to pkg/
func listCmdFunc(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
manager, err := workloads.NewManager(ctx)
if err != nil {
return fmt.Errorf("failed to create workload manager: %w", err)
}
workloadList, err := manager.ListWorkloads(ctx, listAll, listLabelFilter...)
if err != nil {
return fmt.Errorf("failed to list workloads: %w", err)
}
// CLI only handles formatting
switch listFormat {
case FormatJSON:
return printJSONOutput(workloadList)
default:
printTextOutput(workloadList)
return nil
}
}
Testing implication: Test business logic with unit tests in pkg/, test CLI with E2E tests. See Testing CLI Commands section.
1. Silent Success
Commands should be quiet on success. Users should only see output when:
- Something requires their attention
- They explicitly request verbose output with
--debug - The operation takes more than 2-3 seconds (show progress)
# Good - silent success
$ thv run fetch
# Avoid - verbose success messages
$ thv run fetch
INFO: Checking container runtime...
INFO: Container runtime found...
Server 'fetch' is now running!
2. Consistency Across Commands
- Use the same flag names for similar functionality (e.g.,
--format,--all,--group) - Follow established patterns for output formatting
- Maintain consistent command naming conventions
3. User-Centric Error Messages
- Provide actionable error messages with hints
- Guide users to relevant commands or documentation
- Never expose internal implementation details in errors
4. Progressive Disclosure
- Show minimal information by default
- Provide flags for more detailed output (
--debug,--format json) - Use
listvsstatuspattern: list shows summary, status shows details
Command Structure
Basic Command Template
var myCmd = &cobra.Command{
Use: "command-name [flags] REQUIRED_ARG [OPTIONAL_ARG]",
Short: "Brief one-line description",
Long: `Detailed description explaining:
- What the command does
- When to use it
- How it relates to other commands
Examples:
# Common use case with explanation
thv command-name arg1
# Advanced use case
thv command-name arg1 --flag value`,
Args: validateArgs,
RunE: commandFunc,
ValidArgsFunction: completeArgs, // For shell completion
}
Command Organization
Commands are organized in cmd/thv/app/:
- One file per command (e.g.,
list.go,run.go,status.go) - Group related flags and validation logic with the command
- Register commands in
commands.go
Reference: cmd/thv/app/list.go, cmd/thv/app/run.go
Command Design
Naming Conventions
Command Names
- Use verbs for actions:
run,stop,list,remove - Keep names short and memorable
- Avoid abbreviations and acronyms for the command name, reserve for aliases for situations where they are likely to be universally understood.
- Provide common aliases:
lsforlist,rmforremove
var listCmd = &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "List running MCP servers",
...
}
Flag Names
- Use lowercase with hyphens:
--format,--remote-auth - Common flags should use consistent names:
--all: Show all items (including stopped/hidden)--format: Output format (json/text)--group: Filter/target by group--debug: Enable debug logging
- Provide short flags sparingly, only for frequently used options
Help Text
Short Description
- One line, under 80 characters
- Start with a verb
- Don't end with a period
Short: "List running MCP servers",
Long Description
Structure the long description as:
- Detailed explanation of what the command does
- When and why to use it
- At least 2-3 practical examples with explanations
Long: `List all MCP servers managed by ToolHive, including their status and configuration.
The list command shows running servers by default. Use --all to include stopped servers.
Examples:
# List running MCP servers
thv list
# List all servers including stopped ones
thv list --all
# List servers in JSON format
thv list --format json`,
Arguments and Validation
Argument Specifications
Use Cobra's built-in validators when possible:
Args: cobra.ExactArgs(1), // Exactly one argument
Args: cobra.MinimumNArgs(1), // At least one argument
Args: cobra.MaximumNArgs(2), // At most two arguments
Args: cobra.RangeArgs(1, 3), // Between 1 and 3 arguments
For custom validation:
Args: func(cmd *cobra.Command, args []string) error {
if len(args) < 1 {
return fmt.Errorf("requires at least one argument")
}
// Additional validation...
return nil
},
PreRunE Validation
Use PreRunE for flag validation that should happen before the command runs:
func init() {
myCmd.PreRunE = chainPreRunE(
validateGroupFlag(),
ValidateFormat(&formatVar, FormatJSON, FormatText),
validateCustomLogic,
)
}
func validateCustomLogic(cmd *cobra.Command, args []string) error {
// Validation logic here
return nil
}
Reference: cmd/thv/app/flag_helpers.go (chainPreRunE pattern)
Flags and Arguments
Common Flag Patterns
Format Flag
Use the helper function for consistent format flags:
var outputFormat string
func init() {
AddFormatFlag(myCmd, &outputFormat, FormatJSON, FormatText)
myCmd.PreRunE = ValidateFormat(&outputFormat, FormatJSON, FormatText)
}
Reference: cmd/thv/app/flag_helpers.go
All Flag
For commands that can operate on all items:
var showAll bool
func init() {
AddAllFlag(myCmd, &showAll, false, "Show all items")
}
Group Flag
For filtering by group:
var groupName string
func init() {
AddGroupFlag(myCmd, &groupName, false)
myCmd.PreRunE = validateGroupFlag()
}
Flag Organization
var (
// Group related flags together
listAll bool
listFormat string
listLabelFilter []string
listGroupFilter string
)
func init() {
// Add flags in logical order
AddAllFlag(listCmd, &listAll, true, "Show all workloads")
AddFormatFlag(listCmd, &listFormat, FormatJSON, FormatText, "mcpservers")
listCmd.Flags().StringArrayVarP(&listLabelFilter, "label", "l", []string{},
"Filter workloads by labels (format: key=value)")
AddGroupFlag(listCmd, &listGroupFilter, false)
}
Mutually Exclusive Flags
Use Cobra's built-in mechanism:
func init() {
myCmd.Flags().BoolVar(&flagA, "flag-a", false, "Description")
myCmd.Flags().BoolVar(&flagB, "flag-b", false, "Description")
myCmd.MarkFlagsMutuallyExclusive("flag-a", "flag-b")
}
Hidden Flags
Hide flags that are for internal use or advanced scenarios:
func init() {
myCmd.Flags().StringVar(&internalFlag, "internal-flag", "", "Internal use")
if err := myCmd.Flags().MarkHidden("internal-flag"); err != nil {
logger.Warnf("Error hiding flag: %v", err)
}
}
Output and Formatting
User-Facing Output vs Logs
Distinguish between:
- User-facing output: Information the user requested (use
fmt.Println,fmt.Printf) - Operational logs: Diagnostic information (use
logger.Debugf,logger.Warnf, etc.)
// Good - user-facing output
fmt.Printf("Workload %s removed successfully\n", name)
// Good - operational log
logger.Debugf("Attempting to connect to runtime at %s", socketPath)
// Bad - don't use logger for user-facing output
logger.Infof("Workload %s removed successfully", name)
Format Support
Commands that output data should support both text and JSON formats:
func commandFunc(cmd *cobra.Command, args []string) error {
// ... get data ...
switch format {
case FormatJSON:
return printJSONOutput(data)
default:
printTextOutput(data)
return nil
}
}
JSON Output
func printJSONOutput(data interface{}) error {
// Ensure non-nil slices to avoid null in JSON
if data == nil {
data = []YourType{}
}
// Sort for deterministic output
sortData(data)
jsonData, err := json.MarshalIndent(data, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal JSON: %w", err)
}
fmt.Println(string(jsonData))
return nil
}
Text Output
Use text/tabwriter for aligned columns:
func printTextOutput(workloads []Workload) {
w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
// Print header
if _, err := fmt.Fprintln(w, "NAME\tSTATUS\tURL\tPORT"); err != nil {
logger.Warnf("Failed to write header: %v", err)
return
}
// Print rows
for _, item := range workloads {
if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%d\n",
item.Name, item.Status, item.URL, item.Port); err != nil {
logger.Debugf("Failed to write row: %v", err)
}
}
// Flush output
if err := w.Flush(); err != nil {
logger.Errorf("Failed to flush output: %v", err)
}
}
Reference: cmd/thv/app/list.go (printTextOutput, printJSONOutput)
Empty State Messages
Handle empty results gracefully:
if len(items) == 0 {
if filterApplied {
fmt.Printf("No items found matching filter '%s'\n", filter)
} else {
fmt.Println("No items found")
}
return nil
}
Visual Indicators
Use Unicode symbols sparingly and consistently:
⚠️for warnings or issues requiring attention- Use color only when writing to a TTY (check with
isattypackage)
status := string(workload.Status)
if workload.Status == runtime.WorkloadStatusUnauthenticated {
status = "⚠️ " + status
}
Error Messages
Constructing Error Messages
Follow the guidelines in docs/error-handling.md:
// Good - descriptive with context
return fmt.Errorf("failed to start workload %s: %w", name, err)
// Good - actionable error with hint
return fmt.Errorf("group '%s' does not exist. Hint: use 'thv group list' to see available groups", groupName)
// Avoid - vague error
return fmt.Errorf("operation failed")
// Avoid - exposing internal details
return fmt.Errorf("database query failed: SELECT * FROM workloads WHERE id = %d", id)
Error Message Guidelines
- Be specific: Explain what operation failed
- Provide context: Include relevant identifiers (names, IDs)
- Be actionable: Suggest how to fix the issue
- Guide users: Reference relevant commands or documentation
- Preserve error chains: Use
%wto wrap errors
Validation Error Messages
func validateArgs(cmd *cobra.Command, args []string) error {
if len(args) < 1 {
return fmt.Errorf(
"at least one workload name must be provided. " +
"Hint: use 'thv list' to see available workloads")
}
if hasFlag && len(args) > 0 {
return fmt.Errorf(
"no arguments should be provided when --all flag is set. " +
"Hint: remove the workload names or remove the flag")
}
return nil
}
Reference: cmd/thv/app/rm.go (validateRmArgs)
Common Error Patterns
// Not found errors
if errors.Is(err, runtime.ErrWorkloadNotFound) {
return fmt.Errorf("workload '%s' not found. Hint: use 'thv list' to see running workloads", name)
}
// Permission errors
if errors.Is(err, os.ErrPermission) {
return fmt.Errorf("permission denied accessing %s. Hint: check file permissions or run with appropriate privileges", path)
}
// Configuration errors
if err := config.Load(); err != nil {
return fmt.Errorf("failed to load configuration: %w. Hint: run 'thv config init' to create a new configuration", err)
}
User Feedback
Progress Indication
Show progress for long-running operations (> 2-3 seconds):
// For operations like image pulls
fmt.Printf("Pulling image %s...\n", imageName)
logger.Infof("Pulling image %s...", imageName)
// For operations with known progress
fmt.Printf("Processing %d of %d items...\n", current, total)
Confirmation Messages
For destructive operations, provide clear confirmation:
// Single item
fmt.Printf("Workload %s removed successfully\n", name)
// Multiple items
if len(names) == 1 {
fmt.Printf("Workload %s removed successfully\n", names[0])
} else {
fmt.Printf("Workloads %s removed successfully\n", strings.Join(names, ", "))
}
// Bulk operations
fmt.Printf("Successfully removed %d workload(s) from group '%s'\n", count, groupName)
Reference: cmd/thv/app/rm.go (confirmation messages)
Status Updates
For operations with multiple steps:
// Use DEBUG logging for steps
logger.Debugf("Checking container runtime...")
logger.Debugf("Starting container...")
logger.Debugf("Waiting for health check...")
// Only show to user if they use --debug flag
Shell Completion
Auto-completion Support
Provide completion functions for arguments:
var myCmd = &cobra.Command{
Use: "command [arg]",
ValidArgsFunction: completeMyArgs,
...
}
func completeMyArgs(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
// Only complete the first argument
if len(args) > 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
// Get available options
options, err := getAvailableOptions(cmd.Context())
if err != nil {
return nil, cobra.ShellCompDirectiveError
}
return options, cobra.ShellCompDirectiveNoFileComp
}
Reference: cmd/thv/app/common.go (completeMCPServerNames)
Completion for Common Patterns
// Workload names
ValidArgsFunction: completeMCPServerNames,
// File paths
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return nil, cobra.ShellCompDirectiveDefault // Allows file completion
},
// No completion
ValidArgsFunction: cobra.NoFileCompletions,
Testing CLI Commands
Testing Philosophy
CLI commands should be thin wrappers around business logic in pkg/. The CLI layer (cmd/thv/app/) is responsible only for:
- Parsing flags and arguments
- Formatting output (text/JSON)
- Calling business logic in
pkg/packages
Minimize unit tests for CLI code. Instead, rely heavily on end-to-end (E2E) tests.
Why E2E Tests Over Unit Tests?
- CLI is a thin layer: Most CLI code is glue code that calls into
pkg/. Unit testing this adds little value. - E2E tests verify real behavior: They test the actual user experience with the compiled binary.
- Better coverage with less code: One E2E test exercises the entire stack (CLI → pkg → runtime).
- Catch integration issues: E2E tests catch problems that unit tests miss (flag parsing, output formatting, error propagation).
Where to Put Business Logic
// ❌ Bad - Business logic in CLI command
func listCmdFunc(cmd *cobra.Command, args []string) error {
// Complex business logic here
containers, err := runtime.ListContainers()
if err != nil {
return err
}
var workloads []Workload
for _, c := range containers {
// Complex transformation logic
workload := transformContainerToWorkload(c)
workloads = append(workloads, workload)
}
// More complex filtering and processing...
printOutput(workloads)
return nil
}
// ✅ Good - Business logic in pkg/, CLI is thin
func listCmdFunc(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
// Call business logic from pkg/
manager, err := workloads.NewManager(ctx)
if err != nil {
return fmt.Errorf("failed to create workload manager: %w", err)
}
workloadList, err := manager.ListWorkloads(ctx, listAll, listLabelFilter...)
if err != nil {
return fmt.Errorf("failed to list workloads: %w", err)
}
// CLI only handles output formatting
switch listFormat {
case FormatJSON:
return printJSONOutput(workloadList)
default:
printTextOutput(workloadList)
return nil
}
}
When to Use Unit Tests in CLI
Use unit tests sparingly for CLI code, only for:
- Output formatting logic - Test JSON/text output functions
- Flag validation - Test custom argument validation functions
- Helper functions - Test utilities like
chainPreRunEor format validators
// Example: Testing output formatting
func TestPrintJSONOutput(t *testing.T) {
data := []core.Workload{{Name: "test", Status: "running"}}
// Capture stdout
oldStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
err := printJSONOutput(data)
w.Close()
os.Stdout = oldStdout
var buf bytes.Buffer
io.Copy(&buf, r)
// Verify valid JSON
var result []core.Workload
if err := json.Unmarshal(buf.Bytes(), &result); err != nil {
t.Errorf("invalid JSON output: %v", err)
}
// Verify content
if len(result) != 1 || result[0].Name != "test" {
t.Errorf("unexpected output: %v", result)
}
}
Reference: cmd/thv/app/common_test.go, cmd/thv/app/status_test.go
E2E Tests (Primary Testing Strategy)
End-to-end tests are in test/e2e/. These tests use the compiled binary and test complete user workflows:
var _ = Describe("CLI E2E", func() {
It("should run and list workloads", func() {
// Run command - tests full stack
cmd := exec.Command("thv", "run", "test-workload")
err := cmd.Run()
Expect(err).ToNot(HaveOccurred())
// List command - tests output formatting
cmd = exec.Command("thv", "list", "--format", "json")
output, err := cmd.Output()
Expect(err).ToNot(HaveOccurred())
// Verify JSON output
var workloads []Workload
err = json.Unmarshal(output, &workloads)
Expect(err).ToNot(HaveOccurred())
Expect(workloads).To(HaveLen(1))
Expect(workloads[0].Name).To(Equal("test-workload"))
})
It("should handle errors gracefully", func() {
// Test error handling
cmd := exec.Command("thv", "run", "nonexistent-workload")
output, err := cmd.CombinedOutput()
Expect(err).To(HaveOccurred())
Expect(string(output)).To(ContainSubstring("not found"))
Expect(string(output)).To(ContainSubstring("Hint:"))
})
})
Testing Business Logic in pkg/
Put business logic in pkg/ packages and test it thoroughly with unit tests:
// pkg/workloads/manager_test.go
func TestListWorkloads(t *testing.T) {
ctx := context.Background()
manager := NewManager(mockRuntime)
workloads, err := manager.ListWorkloads(ctx, false)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if len(workloads) != 2 {
t.Errorf("expected 2 workloads, got %d", len(workloads))
}
}
Testing Checklist
When adding a new CLI command:
- Business logic is in
pkg/packages (not incmd/thv/app/) - Unit tests exist for
pkg/business logic (thorough coverage) - E2E tests cover the CLI command (primary verification)
- Minimal unit tests for CLI-specific code (output formatting, validation)
- E2E tests verify:
- Successful command execution
- Error handling with helpful messages
- Both
--format jsonand--format textoutput - Flag combinations and edge cases
Adding New Commands
Step-by-Step Process
-
Create the command file
touch cmd/thv/app/mycommand.go -
Add SPDX header
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. // SPDX-License-Identifier: Apache-2.0 -
Define the command
var myCmd = &cobra.Command{ Use: "mycommand [args]", Short: "Brief description", Long: `Detailed description with examples`, Args: validateArgs, RunE: myCommandFunc, } -
Add flags in init()
func init() { myCmd.Flags().StringVar(&myFlag, "my-flag", "", "Description") myCmd.PreRunE = validateFlags } -
Implement the command function
func myCommandFunc(cmd *cobra.Command, args []string) error { ctx := cmd.Context() // Command implementation return nil } -
Register in commands.go
func NewRootCmd() *cobra.Command { // ... rootCmd.AddCommand(myCmd) // ... } -
Keep business logic in pkg/
// Move complex logic to pkg/ packages // CLI should only parse flags, call pkg/ functions, and format output -
Update CLI documentation
task docs -
Write E2E tests (primary testing)
# Add tests to test/e2e/ # Test the compiled binary with real workflows -
Write minimal unit tests (only for output formatting/validation)
// Only if testing output formatting or flag validation helpers // Most testing should be E2E
Checklist for New Commands
- Command has clear, descriptive name
- Short description is concise (< 80 chars)
- Long description includes examples
- Flags use consistent naming
- Validation is in PreRunE
- Supports --format flag (if outputting data)
- Silent on success
- Error messages are actionable
- Shell completion is provided
- Business logic is in
pkg/packages (not in CLI layer) - E2E tests are written (primary verification)
- Unit tests for output formatting/validation (if needed)
- Documentation is updated (task docs)
Related Documentation
- Logging Practices - Logging levels and when to use them
- Error Handling - Error construction and handling patterns
- CLAUDE.md - Build commands and project overview
- CONTRIBUTING.md - Commit message guidelines and PR process