This document provides development standards and architectural patterns for the shelly-cli codebase. The first half covers practical coding standards; the second half provides reference patterns derived from audits of gh, kubectl, docker, jira-cli, gh-dash, and k9s.
- Command Development Standards
- Factory Usage
- IOStreams Usage
- Error Handling
- Context Propagation
- Import Organization
- Anti-Patterns to Avoid
- Migration Checklist
- Reference Implementations
- Factory Pattern (gh/kubectl)
- IOStreams Pattern (gh)
- Command Utilities (gh/kubectl/jira-cli)
- Directory Structure
- TUI Architecture (gh-dash/BubbleTea)
- Multi-Writer Output Pattern
- Concurrency Patterns
- Testing Patterns
These standards apply to all code in the shelly-cli repository.
Standard: All commands with options MUST embed Factory *cmdutil.Factory in the Options struct.
// β
Correct - Factory embedded in Options
type Options struct {
// 1. Embedded flag groups (alphabetical)
flags.ConfirmFlags
flags.OutputFlags
// 2. Factory (always present)
Factory *cmdutil.Factory
// 3. Command-specific fields (alphabetical)
Device string
ID int
}
func NewCommand(f *cmdutil.Factory) *cobra.Command {
opts := &Options{Factory: f}
cmd := &cobra.Command{
Use: "example <device>",
RunE: func(cmd *cobra.Command, args []string) error {
opts.Device = args[0]
return run(cmd.Context(), opts) // Only pass opts
},
}
return cmd
}
func run(ctx context.Context, opts *Options) error {
ios := opts.Factory.IOStreams() // Access from opts
svc := opts.Factory.ShellyService()
// ...
}
// β Incorrect - Factory passed separately
type Options struct {
Device string
ID int
}
func run(ctx context.Context, f *cmdutil.Factory, opts *Options) error { // DON'T DO THIS
ios := f.IOStreams()
// ...
}Rationale:
- Consistent pattern across all commands (71 files already follow this)
- Simplifies
run()function signatures - Options struct becomes self-contained with all dependencies
- Easier to test - mock factory can be injected into Options
Standard: All command constructors must be named NewCommand.
// β
Correct
func NewCommand(f *cmdutil.Factory) *cobra.Command {
return &cobra.Command{...}
}
// β Incorrect
func NewCmd(f *cmdutil.Factory) *cobra.Command {
return &cobra.Command{...}
}Rationale: Consistency with Cobra conventions and better IDE autocomplete.
Required: All command constructors MUST accept *cmdutil.Factory as the first parameter and embed it in Options.
// β
Correct - Factory in Options
func NewCommand(f *cmdutil.Factory) *cobra.Command {
opts := &Options{Factory: f}
return &cobra.Command{
Use: "example <device>",
Short: "Example command",
RunE: func(cmd *cobra.Command, args []string) error {
opts.Device = args[0]
return run(cmd.Context(), opts) // Pass opts, NOT f
},
}
}
// β Incorrect - No factory parameter
func NewCommand() *cobra.Command {
return &cobra.Command{...}
}
// β Incorrect - Factory passed separately to run()
func run(ctx context.Context, f *cmdutil.Factory, device string) error { // DON'T DO THIS
// ...
}Rationale:
- Enables dependency injection for testing
- Provides consistent access to IOStreams, Config, and ShellyService
- Prevents direct instantiation anti-pattern (
iostreams.System(),shelly.NewService())
Parent commands create the factory once and pass it to all children.
// Parent command
func NewCommand(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "device",
Short: "Device operations",
}
// Pass factory to all subcommands
cmd.AddCommand(info.NewCommand(f))
cmd.AddCommand(status.NewCommand(f))
cmd.AddCommand(reboot.NewCommand(f))
return cmd
}Never create a new factory in child commands - always use the one passed from parent.
The factory provides three core dependencies:
- IOStreams - Terminal I/O with progress indicators, colors, prompts
- Config - CLI configuration (devices, aliases, groups, scenes)
- ShellyService - Business logic for device operations
func run(ctx context.Context, opts *Options) error {
// Get dependencies from factory via opts
ios := opts.Factory.IOStreams()
svc := opts.Factory.ShellyService()
// Use dependencies
ios.StartProgress("Processing...")
err := svc.DeviceReboot(ctx, opts.Device, 0)
ios.StopProgress()
if err != nil {
return err
}
ios.Success("Device rebooted")
return nil
}Q: Why doesn't the factory provide a raw Shelly HTTP client?
A: Shelly clients are device-specific (require IP/hostname). The factory provides the ShellyService which handles device resolution from names/IPs.
Q: Why doesn't the factory have an embedded context? A: Contexts are request-scoped (one per command execution), while the factory is application-scoped (singleton). Mixing lifetimes breaks cancellation semantics.
Q: Why must factory be used in ALL commands?
A: Consistency and testability. Direct instantiation (iostreams.System()) bypasses dependency injection and makes testing difficult.
Always use factory IOStreams methods, never package-level functions.
// β
Correct - Instance methods via Options
func run(ctx context.Context, opts *Options) error {
ios := opts.Factory.IOStreams()
ios.StartProgress("Processing...")
// ... work ...
ios.StopProgress()
ios.Success("Operation completed")
return nil
}
// β Incorrect - Package functions
func run(ctx context.Context, device string) error {
spin := iostreams.NewSpinner("Processing...")
spin.Start()
// ... work ...
spin.Stop()
iostreams.Success("Operation completed") // β Can't be mocked in tests
return nil
}Use StartProgress/StopProgress instead of creating spinners directly.
// β
Correct
ios.StartProgress("Rebooting device...")
err := svc.DeviceReboot(ctx, device, delay)
ios.StopProgress()
// β Incorrect - Old pattern
spin := iostreams.NewSpinner("Rebooting device...")
spin.Start()
err := svc.DeviceReboot(ctx, device, delay)
spin.Stop()- Progress:
StartProgress(msg),StopProgress() - Output:
Printf(),Println(),Title(),Info(),Warning(),Error() - Success/Failure:
Success(),NoResults(),Added() - Prompts:
Confirm(),Prompt() - Debug:
DebugErr()
Use separate declaration for readability and debugging.
// β
Correct
err := svc.DeviceReboot(ctx, device, delay)
ios.StopProgress()
if err != nil {
return fmt.Errorf("failed to reboot device: %w", err)
}
// β Avoid - Inline pattern (except for simple parsing)
if err := svc.DeviceReboot(ctx, device, delay); err != nil {
return fmt.Errorf("failed to reboot device: %w", err)
}Exception: Inline error handling is acceptable for fmt.Sscanf and simple parsing operations.
Always wrap errors with context using %w verb for error chains.
return fmt.Errorf("failed to reboot device: %w", err)Root Command (creates signal-aware context)
β
cmd.Context() passed to RunE
β
run(ctx, opts)
β
Service calls (svc.DeviceReboot(ctx, ...))
- Root command creates context with
signal.NotifyContextfor Ctrl+C handling - All commands use
cmd.Context(), nevercontext.Background() - Command timeouts wrap the passed context:
ctx, cancel := context.WithTimeout(ctx, shelly.DefaultTimeout) - Always defer cancel() to prevent context leaks
func run(ctx context.Context, opts *Options) error {
// Wrap context with timeout
ctx, cancel := context.WithTimeout(ctx, shelly.DefaultTimeout)
defer cancel()
svc := opts.Factory.ShellyService()
return svc.DeviceReboot(ctx, opts.Device, 0) // β
Context propagates
}Imports must be organized in three groups with blank lines between:
import (
// 1. Standard library
"context"
"fmt"
"strings"
// 2. Third-party packages
"github.com/spf13/cobra"
"github.com/spf13/viper"
// 3. Internal packages
"github.com/tj-smith47/shelly-cli/internal/cmdutil"
"github.com/tj-smith47/shelly-cli/internal/iostreams"
"github.com/tj-smith47/shelly-cli/internal/shelly"
)Enforcement: golangci-lint with gci linter enforces this automatically.
// β Never do this
func run(ctx context.Context, device string) error {
ios := iostreams.System() // Bypasses factory
svc := shelly.NewService() // Bypasses factory
// ...
}
// β
Always use factory via Options
func run(ctx context.Context, opts *Options) error {
ios := opts.Factory.IOStreams()
svc := opts.Factory.ShellyService()
// ...
}// β Never create context.Background() in commands
func run(device string) error {
ctx := context.Background() // Breaks Ctrl+C handling
// ...
}
// β
Always use passed context
func run(ctx context.Context, opts *Options) error {
// ctx comes from cmd.Context()
}// β Avoid package functions
iostreams.Success("Done")
iostreams.Warning("Watch out")
// β
Use instance methods
ios := f.IOStreams()
ios.Success("Done")
ios.Warning("Watch out")// β Old pattern - manual spinner
spin := iostreams.NewSpinner("Processing...")
spin.Start()
// work
spin.Stop()
// β
New pattern - factory IOStreams
ios := f.IOStreams()
ios.StartProgress("Processing...")
// work
ios.StopProgress()When creating a new command or updating an existing one:
- Command constructor named
NewCommand(f *cmdutil.Factory) - Factory passed to all subcommands
- Dependencies accessed via factory (
f.IOStreams(),f.ShellyService(),f.Config()) - Context from
cmd.Context(), notcontext.Background() - Progress indicators use
ios.StartProgress()/StopProgress() - No package-level iostreams calls
- Imports organized in gci format (stdlib, third-party, internal)
- Errors wrapped with
%wfor context - Helper functions used where applicable (DRY principle)
Well-architected examples to study:
internal/cmd/energy/status/status.go- Factory pattern, auto-detection logicinternal/cmd/backup/create/create.go- Complex operations, multiple dependenciesinternal/cmd/scene/activate/activate.go- Batch operations with errgroupinternal/cmd/discover/ble/ble.go- Context-aware discovery
Helper usage examples:
internal/cmd/light/on/on.go- RunSimple helperinternal/cmd/light/status/status.go- RunStatus helperinternal/cmd/batch/command/command.go- RunBatch helper
The following sections document patterns from industry-standard CLI tools that guide the shelly-cli implementation.
Source: gh CLI (pkg/cmdutil/factory.go), kubectl
The Factory pattern provides centralized dependency injection for commands. Instead of creating dependencies directly in each command, the Factory provides them lazily on demand.
- Testability: Replace real dependencies with mocks
- Lazy Loading: Dependencies created only when needed
- Consistency: Single source for all dependencies
- Plugin Support: Plugins can receive the same dependencies
// internal/cmdutil/factory.go
package cmdutil
import (
"github.com/tj-smith47/shelly-cli/internal/config"
"github.com/tj-smith47/shelly-cli/internal/iostreams"
"github.com/tj-smith47/shelly-cli/internal/shelly"
)
// Factory provides dependencies to commands
type Factory struct {
// Lazy initializers - called on first access
IOStreams func() *iostreams.IOStreams
Config func() (*config.Config, error)
ShellyService func() *shelly.Service
Browser func() browser.Browser
// Cached instances (set after first call)
ioStreams *iostreams.IOStreams
cfg *config.Config
shellyService *shelly.Service
browserInst browser.Browser
}
// Factory also provides helper methods for common operations:
// - WithTimeout/WithDefaultTimeout - context timeout management
// - GetDevice/GetGroup/GetAlias - config accessor helpers
// - ResolveAddress/ResolveDevice - device name resolution
// - ExpandTargets - batch operation target expansion
// - ConfirmAction - user confirmation
// - OutputFormat/IsJSONOutput/IsYAMLOutput - output format helpers
// - Logger - structured logging access
// NewFactory creates a Factory with production dependencies
func NewFactory() *Factory {
f := &Factory{}
f.IOStreams = func() *iostreams.IOStreams {
if f.ioStreams == nil {
f.ioStreams = iostreams.System()
}
return f.ioStreams
}
f.Config = func() (*config.Config, error) {
if f.cfg == nil {
cfg, err := config.Load()
if err != nil {
return nil, err
}
f.cfg = cfg
}
return f.cfg, nil
}
f.ShellyService = func() *shelly.Service {
if f.shellyService == nil {
f.shellyService = shelly.NewService()
}
return f.shellyService
}
return f
}// internal/cmd/switch/on/on.go
type Options struct {
Factory *cmdutil.Factory
Device string
SwitchID int
}
func NewCommand(f *cmdutil.Factory) *cobra.Command {
opts := &Options{Factory: f}
cmd := &cobra.Command{
Use: "on <device>",
Aliases: []string{"enable"},
Short: "Turn switch on",
Example: ` shelly switch on living-room
shelly switch on kitchen --id 1`,
RunE: func(cmd *cobra.Command, args []string) error {
opts.Device = args[0]
return run(cmd.Context(), opts)
},
}
cmd.Flags().IntVarP(&opts.SwitchID, "id", "i", 0, "Switch ID")
return cmd
}
func run(ctx context.Context, opts *Options) error {
ios := opts.Factory.IOStreams()
svc := opts.Factory.ShellyService()
ios.StartProgress("Turning switch on...")
err := svc.SwitchOn(ctx, opts.Device, opts.SwitchID)
ios.StopProgress()
if err != nil {
return fmt.Errorf("failed to turn switch on: %w", err)
}
ios.Success("Switch %d turned on", opts.SwitchID)
return nil
}Source: gh CLI (pkg/iostreams/iostreams.go)
IOStreams provides a unified abstraction for terminal I/O, enabling consistent handling of color, TTY detection, progress indicators, and paging.
- Testability: Capture output in tests
- TTY Detection: Adjust output based on terminal capabilities
- Color Management: Respect NO_COLOR, FORCE_COLOR, etc.
- Progress Indicators: Unified spinner/progress handling
- Paging: Automatic paging for long output
// internal/iostreams/iostreams.go
package iostreams
import (
"io"
"os"
"github.com/briandowns/spinner"
"github.com/mattn/go-isatty"
)
// IOStreams holds I/O streams and terminal state
type IOStreams struct {
In io.Reader
Out io.Writer
ErrOut io.Writer
// Terminal state (detected once)
isStdinTTY bool
isStdoutTTY bool
isStderrTTY bool
// Color settings
colorEnabled bool
colorForced bool
// Progress indicator
progressIndicator *spinner.Spinner
}
// System creates IOStreams connected to stdin/stdout/stderr
func System() *IOStreams {
ios := &IOStreams{
In: os.Stdin,
Out: os.Stdout,
ErrOut: os.Stderr,
}
// Detect TTY
if f, ok := os.Stdin.(*os.File); ok {
ios.isStdinTTY = isatty.IsTerminal(f.Fd())
}
if f, ok := os.Stdout.(*os.File); ok {
ios.isStdoutTTY = isatty.IsTerminal(f.Fd())
}
if f, ok := os.Stderr.(*os.File); ok {
ios.isStderrTTY = isatty.IsTerminal(f.Fd())
}
// Determine color settings
ios.colorEnabled = ios.isStdoutTTY && !isColorDisabled()
return ios
}
func isColorDisabled() bool {
// Check NO_COLOR (https://no-color.org/)
if _, ok := os.LookupEnv("NO_COLOR"); ok {
return true
}
// Check SHELLY_NO_COLOR
if _, ok := os.LookupEnv("SHELLY_NO_COLOR"); ok {
return true
}
return false
}
// IsStdoutTTY returns true if stdout is a terminal
func (s *IOStreams) IsStdoutTTY() bool {
return s.isStdoutTTY
}
// ColorEnabled returns true if color output is enabled
func (s *IOStreams) ColorEnabled() bool {
return s.colorEnabled
}
// StartProgress starts a spinner with the given message
func (s *IOStreams) StartProgress(msg string) {
if !s.isStdoutTTY {
// No spinner for non-TTY, just print message
fmt.Fprintln(s.ErrOut, msg)
return
}
s.progressIndicator = spinner.New(spinner.CharSets[14], 100*time.Millisecond)
s.progressIndicator.Suffix = " " + msg
s.progressIndicator.Writer = s.ErrOut
s.progressIndicator.Start()
}
// StopProgress stops the current spinner
func (s *IOStreams) StopProgress() {
if s.progressIndicator != nil {
s.progressIndicator.Stop()
s.progressIndicator = nil
}
}// internal/testutil/iostreams.go
func NewTestIOStreams() (*IOStreams, *bytes.Buffer, *bytes.Buffer) {
stdin := &bytes.Buffer{}
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
ios := &IOStreams{
In: stdin,
Out: stdout,
ErrOut: stderr,
colorEnabled: false, // Disable color in tests
}
return ios, stdout, stderr
}Source: gh (pkg/cmdutil/), kubectl, jira-cli (internal/cmdutil/, internal/cmdcommon/)
Shared utilities reduce duplication across commands.
// internal/cmdutil/runner.go
package cmdutil
import (
"context"
"fmt"
"golang.org/x/sync/errgroup"
"github.com/tj-smith47/shelly-cli/internal/iostreams"
"github.com/tj-smith47/shelly-cli/internal/shelly"
)
// ComponentAction is a function that operates on a device component
type ComponentAction func(ctx context.Context, svc *shelly.Service, device string, id int) error
// RunWithSpinner executes an action with a progress spinner
func RunWithSpinner(ctx context.Context, ios *iostreams.IOStreams, msg string, action func(context.Context) error) error {
ios.StartProgress(msg)
err := action(ctx)
ios.StopProgress()
return err
}
// RunBatch executes an action on multiple devices concurrently
func RunBatch(ctx context.Context, ios *iostreams.IOStreams, targets []string, concurrent int, action ComponentAction) error {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(concurrent)
svc := shelly.NewService()
for _, target := range targets {
t := target
g.Go(func() error {
if err := action(ctx, svc, t, 0); err != nil {
// Log error but continue with other devices
fmt.Fprintf(ios.ErrOut, "Error on %s: %v\n", t, err)
return nil // Don't fail the whole batch
}
return nil
})
}
return g.Wait()
}// internal/cmdutil/output.go
package cmdutil
import (
"io"
"github.com/tj-smith47/shelly-cli/internal/output"
)
// PrintResult outputs data in the specified format
func PrintResult(w io.Writer, format string, data any, tableFn func(io.Writer, any)) error {
switch format {
case "json":
return output.JSON(w, data)
case "yaml":
return output.YAML(w, data)
case "template":
// Template handled separately with template string
return nil
default:
tableFn(w, data)
return nil
}
}// internal/cmdutil/flags.go
package cmdutil
import (
"time"
"github.com/spf13/cobra"
)
// AddComponentIDFlag adds the standard component ID flag
func AddComponentIDFlag(cmd *cobra.Command, target *int, componentName string) {
cmd.Flags().IntVarP(target, "id", "i", 0, fmt.Sprintf("%s ID (default 0)", componentName))
}
// AddOutputFlag adds the standard output format flag
func AddOutputFlag(cmd *cobra.Command) {
cmd.Flags().StringP("output", "o", "table", "Output format (table, json, yaml, template)")
}
// AddTimeoutFlag adds a timeout flag
func AddTimeoutFlag(cmd *cobra.Command, target *time.Duration, defaultValue time.Duration) {
cmd.Flags().DurationVar(target, "timeout", defaultValue, "Operation timeout")
}
// AddConcurrencyFlag adds a concurrency flag for batch operations
func AddConcurrencyFlag(cmd *cobra.Command, target *int) {
cmd.Flags().IntVarP(target, "parallel", "p", 10, "Number of parallel operations")
}Source: gh (pkg/cmd/), docker (cli/command/), jira-cli (internal/cmd/)
The internal/cmd/ directory contains ONLY command definitions. All shared utilities, helpers, and infrastructure live elsewhere.
internal/
βββ cmd/ # ONLY command definitions
β βββ root.go
β βββ switch/
β β βββ switch.go # Parent command
β β βββ on/
β β β βββ on.go # `shelly switch on`
β β βββ off/
β β β βββ off.go # `shelly switch off`
β β βββ status/
β β βββ status.go # `shelly switch status`
β βββ ...
β
βββ cmdutil/ # Command utilities (NOT under cmd/)
β βββ factory.go # Dependency injection factory
β βββ runner.go # RunWithSpinner, RunBatch helpers
β βββ flags.go # Flag helpers (AddTimeoutFlag, etc.)
β
βββ iostreams/ # I/O abstraction (NOT under cmd/)
β βββ iostreams.go # IOStreams struct and methods
β βββ color.go # Color detection and handling
β βββ progress.go # Progress indicator management
β
βββ browser/ # Cross-platform URL opening
βββ config/ # Configuration management
βββ helpers/ # Device discovery and conversion helpers
βββ model/ # Domain models
βββ output/ # Output formatters (JSON, YAML, table)
β βββ format.go # Format routing (WantsStructured, FormatOutput)
β βββ table.go # Table formatting
βββ shelly/ # Business logic service layer
β βββ shelly.go # Core service
β βββ quick.go # Quick commands (QuickOn/Off/Toggle)
β βββ devicedata.go # Device data collection
β βββ ... # Component-specific services
βββ theme/ # Theming (bubbletint integration)
Each command directory contains:
// internal/cmd/switch/on/on.go
package on
import (
"context"
"fmt"
"github.com/spf13/cobra"
"github.com/tj-smith47/shelly-cli/internal/cmdutil"
)
// Options holds the command options with Factory embedded.
type Options struct {
Factory *cmdutil.Factory
Device string
SwitchID int
}
// NewCommand creates the switch on command
func NewCommand(f *cmdutil.Factory) *cobra.Command {
opts := &Options{Factory: f}
cmd := &cobra.Command{
Use: "on <device>",
Aliases: []string{"enable"},
Short: "Turn switch on",
Long: `Turn on a switch component on the specified device.`,
Example: ` shelly switch on living-room
shelly switch on kitchen --id 1`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
opts.Device = args[0]
return run(cmd.Context(), opts)
},
}
cmd.Flags().IntVarP(&opts.SwitchID, "id", "i", 0, "Switch ID")
return cmd
}
func run(ctx context.Context, opts *Options) error {
ios := opts.Factory.IOStreams()
svc := opts.Factory.ShellyService()
return cmdutil.RunWithSpinner(ctx, ios, "Turning switch on...", func(ctx context.Context) error {
if err := svc.SwitchOn(ctx, opts.Device, opts.SwitchID); err != nil {
return fmt.Errorf("failed to turn switch on: %w", err)
}
ios.Success("Switch %d turned on", opts.SwitchID)
return nil
})
}Source: gh-dash (dlvhdr/gh-dash), BubbleTea (charmbracelet/bubbletea)
The TUI uses the Elm Architecture via BubbleTea:
- Model: Application state
- Init: Initial command (data fetching)
- Update: Handle messages, return new model + commands
- View: Render model to string
Each TUI component follows the same pattern:
internal/tui/components/devicelist/
βββ model.go # Model struct and constructor
βββ view.go # View() string method
βββ update.go # Update(msg) method
βββ keys.go # Component-specific key bindings
βββ styles.go # Component styles
// internal/tui/components/devicelist/model.go
package devicelist
import (
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"github.com/tj-smith47/shelly-cli/internal/model"
)
// Model holds the device list state
type Model struct {
table table.Model
devices []model.Device
loading bool
err error
width int
height int
}
// New creates a new device list model
func New() Model {
columns := []table.Column{
{Title: "Name", Width: 20},
{Title: "IP", Width: 15},
{Title: "Type", Width: 15},
{Title: "Status", Width: 10},
}
t := table.New(
table.WithColumns(columns),
table.WithFocused(true),
)
return Model{
table: t,
loading: true,
}
}
// Init returns the initial command
func (m Model) Init() tea.Cmd {
return fetchDevices()
}// internal/tui/components/devicelist/update.go
package devicelist
import (
tea "github.com/charmbracelet/bubbletea"
)
// DevicesLoadedMsg signals that devices were loaded
type DevicesLoadedMsg struct {
Devices []model.Device
Err error
}
// Update handles messages
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "enter":
// Handle selection
return m, nil
}
case DevicesLoadedMsg:
m.loading = false
if msg.Err != nil {
m.err = msg.Err
return m, nil
}
m.devices = msg.Devices
m.table.SetRows(devicesToRows(m.devices))
return m, nil
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.table.SetWidth(msg.Width)
m.table.SetHeight(msg.Height - 4) // Leave room for status
return m, nil
}
var cmd tea.Cmd
m.table, cmd = m.table.Update(msg)
return m, cmd
}// internal/tui/components/devicelist/view.go
package devicelist
import (
"github.com/charmbracelet/lipgloss"
)
// View renders the component
func (m Model) View() string {
if m.loading {
return "Loading devices..."
}
if m.err != nil {
return lipgloss.NewStyle().
Foreground(lipgloss.Color("9")).
Render("Error: " + m.err.Error())
}
return m.table.View()
}// internal/tui/data/devices.go
package data
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/tj-smith47/shelly-cli/internal/shelly"
)
// FetchDevices returns a command that fetches devices
func FetchDevices() tea.Cmd {
return func() tea.Msg {
svc := shelly.NewService()
devices, err := svc.ListDevices()
return DevicesLoadedMsg{
Devices: devices,
Err: err,
}
}
}Source: Docker CLI (docker build, docker compose up)
Docker's build output shows multiple concurrent operations with per-line progress updates. Each layer/service gets its own line that updates in place. This pattern is ideal for:
- Batch device operations
- Subnet scanning
- Firmware updates across multiple devices
- Scene activation
- Visual Clarity: See all operations at once
- Real-time Feedback: Each target shows its own status
- Professional UX: Modern CLI expectation for concurrent ops
// internal/iostreams/multiwriter.go
package iostreams
import (
"fmt"
"io"
"sync"
"github.com/charmbracelet/lipgloss"
)
// MultiWriter manages multiple concurrent output lines
type MultiWriter struct {
mu sync.Mutex
out io.Writer
lines map[string]*Line
order []string // Preserve insertion order
isTTY bool
}
// Line represents a single output line that can be updated
type Line struct {
ID string
Status Status
Message string
}
type Status int
const (
StatusPending Status = iota
StatusRunning
StatusSuccess
StatusError
)
// NewMultiWriter creates a multi-line writer
func NewMultiWriter(out io.Writer, isTTY bool) *MultiWriter {
return &MultiWriter{
out: out,
lines: make(map[string]*Line),
isTTY: isTTY,
}
}
// AddLine adds a new tracked line
func (m *MultiWriter) AddLine(id, message string) {
m.mu.Lock()
defer m.mu.Unlock()
m.lines[id] = &Line{
ID: id,
Status: StatusPending,
Message: message,
}
m.order = append(m.order, id)
}
// UpdateLine updates an existing line
func (m *MultiWriter) UpdateLine(id string, status Status, message string) {
m.mu.Lock()
defer m.mu.Unlock()
if line, ok := m.lines[id]; ok {
line.Status = status
line.Message = message
}
m.render()
}
// render redraws all lines (TTY only)
func (m *MultiWriter) render() {
if !m.isTTY {
return
}
// Move cursor up to start of our output
if len(m.order) > 1 {
fmt.Fprintf(m.out, "\033[%dA", len(m.order)-1)
}
for _, id := range m.order {
line := m.lines[id]
fmt.Fprintf(m.out, "\033[2K") // Clear line
icon := m.statusIcon(line.Status)
style := m.statusStyle(line.Status)
fmt.Fprintf(m.out, "%s %s: %s\n",
icon,
style.Render(line.ID),
line.Message,
)
}
}
func (m *MultiWriter) statusIcon(s Status) string {
switch s {
case StatusPending:
return "β"
case StatusRunning:
return "β" // Or use spinner
case StatusSuccess:
return "β"
case StatusError:
return "β"
default:
return "?"
}
}
func (m *MultiWriter) statusStyle(s Status) lipgloss.Style {
switch s {
case StatusSuccess:
return lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // Green
case StatusError:
return lipgloss.NewStyle().Foreground(lipgloss.Color("1")) // Red
case StatusRunning:
return lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // Yellow
default:
return lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // Gray
}
}
// Finalize prints final state (for non-TTY or completion)
func (m *MultiWriter) Finalize() {
m.mu.Lock()
defer m.mu.Unlock()
if !m.isTTY {
// Non-TTY: print each line once at the end
for _, id := range m.order {
line := m.lines[id]
icon := m.statusIcon(line.Status)
fmt.Fprintf(m.out, "%s %s: %s\n", icon, line.ID, line.Message)
}
}
}// internal/cmd/batch/on/on.go
func run(ctx context.Context, ios *iostreams.IOStreams, targets []string, switchID int) error {
mw := iostreams.NewMultiWriter(ios.Out, ios.IsStdoutTTY())
// Add all lines upfront
for _, target := range targets {
mw.AddLine(target, "pending")
}
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(10)
for _, target := range targets {
t := target
g.Go(func() error {
mw.UpdateLine(t, iostreams.StatusRunning, "turning on...")
err := svc.SwitchOn(ctx, t, switchID)
if err != nil {
mw.UpdateLine(t, iostreams.StatusError, err.Error())
return nil // Don't fail whole batch
}
mw.UpdateLine(t, iostreams.StatusSuccess, "on")
return nil
})
}
g.Wait()
mw.Finalize()
return nil
}β living-room-light: on
β bedroom-switch: turning on...
β kitchen-dimmer: on
β garage-relay: pending
β basement-plug: connection timeout
| Command | Current | With Multi-Writer |
|---|---|---|
batch on/off/toggle |
Sequential success/error messages | Per-device progress lines |
discover scan |
Single spinner | Per-IP status with progress |
firmware update --all |
Single spinner | Per-device update progress |
scene activate |
Sequential messages | Per-device activation status |
provision bulk |
Unknown | Per-device provisioning progress |
Source: gh, kubectl, best practices
Use WaitGroup.Go() when goroutines don't return errors or errors are handled via channels/shared state:
// WRONG (pre-Go 1.25 pattern - DO NOT USE)
var wg sync.WaitGroup
for _, target := range targets {
wg.Add(1)
go func(device string) {
defer wg.Done()
// work...
}(target)
}
// CORRECT (Go 1.25+)
var wg sync.WaitGroup
for _, target := range targets {
wg.Go(func() {
// work with target
})
}
wg.Wait()Use errgroup when you need error handling, context cancellation on first error, or concurrency limits:
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(concurrent)
for _, target := range targets {
g.Go(func() error {
// work with target
return nil
})
}
if err := g.Wait(); err != nil {
return err
}When to use which:
WaitGroup.Go(): Simple parallel work, errors handled separately (channels, mutex-protected state)errgroup: Need to propagate errors, cancel on first failure, or limit concurrency
Always pass context through the call chain:
// Get context from Cobra command
func (cmd *cobra.Command) RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context() // Use this, NOT context.Background()
return run(ctx, args[0])
}
// Pass context to all operations
func run(ctx context.Context, device string) error {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
return client.Call(ctx, device, "method", nil)
}For TUI components, use the experimental teatest package from Charm:
// Example TUI test using teatest
// See: https://github.com/charmbracelet/x/tree/main/exp/teatest
import (
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/x/exp/teatest"
)
func TestDeviceListView(t *testing.T) {
m := devicelist.New()
tm := teatest.NewTestModel(t, m, teatest.WithInitialTermSize(80, 24))
// Wait for initial render
teatest.WaitFor(t, tm.Output(), func(bts []byte) bool {
return bytes.Contains(bts, []byte("Loading"))
})
// Send devices loaded message
tm.Send(devicelist.DevicesLoadedMsg{
Devices: []model.Device{{Name: "test", IP: "192.168.1.1"}},
})
// Verify table renders
teatest.WaitFor(t, tm.Output(), func(bts []byte) bool {
return bytes.Contains(bts, []byte("test"))
})
// Test keyboard navigation
tm.Send(tea.KeyMsg{Type: tea.KeyDown})
// Verify quit
tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})
tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second))
}func TestSwitchOn(t *testing.T) {
tests := []struct {
name string
device string
switchID int
mockResp any
mockErr error
wantErr bool
wantOut string
}{
{
name: "success",
device: "test-device",
switchID: 0,
mockResp: map[string]any{"was_on": false},
wantOut: "Switch 0 turned on\n",
},
{
name: "device not found",
device: "unknown",
switchID: 0,
mockErr: client.ErrDeviceNotFound,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ios, stdout, _ := testutil.NewTestIOStreams()
f := testutil.NewTestFactory(t)
f.MockClient.SetResponse("Switch.Set", tt.mockResp, tt.mockErr)
cmd := on.NewCommand(f)
cmd.SetArgs([]string{tt.device, "--id", strconv.Itoa(tt.switchID)})
err := cmd.Execute()
if tt.wantErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.wantOut, stdout.String())
})
}
}// internal/testutil/factory.go
func NewTestFactory(t *testing.T) *cmdutil.Factory {
t.Helper()
ios, _, _ := NewTestIOStreams()
mockClient := NewMockClient()
return &cmdutil.Factory{
IOStreams: func() *iostreams.IOStreams {
return ios
},
Config: func() (*config.Config, error) {
return &config.Config{}, nil
},
ShellyClient: func(device string) (*client.Client, error) {
return mockClient, nil
},
}
}- gh CLI: https://github.com/cli/cli
- kubectl: https://github.com/kubernetes/kubectl
- docker CLI: https://github.com/docker/cli
- jira-cli: https://github.com/ankitpokhrel/jira-cli
- BubbleTea: https://github.com/charmbracelet/bubbletea
- Bubbles (components): https://github.com/charmbracelet/bubbles
- Lipgloss (styling): https://github.com/charmbracelet/lipgloss
- Glamour (markdown): https://github.com/charmbracelet/glamour
- bubbletint (themes): https://github.com/lrstanley/bubbletint
- teatest (TUI testing): https://github.com/charmbracelet/x/tree/main/exp/teatest