Development Guide On this page 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.
Table of Contents# Development Standards# Command Development Standards Factory Usage IOStreams Usage Error Handling Context Propagation Import Organization Anti-Patterns to Avoid Migration Checklist Reference Implementations Reference Patterns# 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 Development Standards These standards apply to all code in the shelly-cli repository.
Command Development Standards# Options Struct Pattern# Standard : All commands with options MUST embed Factory *cmdutil.Factory in the Options struct.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// ✅ 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 Constructor Naming# Standard : All command constructors must be named NewCommand.
1
2
3
4
5
6
7
8
9
// ✅ 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.
Factory Parameter# Required : All command constructors MUST accept *cmdutil.Factory as the first parameter and embed it in Options.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ✅ 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-Child Command Structure# Parent commands create the factory once and pass it to all children.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 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.
Factory Usage# What the Factory Provides# The factory provides three core dependencies:
IOStreams - Terminal I/O with progress indicators, colors, promptsConfig - CLI configuration (devices, aliases, groups, scenes)ShellyService - Business logic for device operationsAccessing Dependencies# 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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
}
Why These Design Choices# 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.
IOStreams Usage# Standard Pattern# Always use factory IOStreams methods, never package-level functions.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// ✅ 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
}
Progress Indicators# Use StartProgress/StopProgress instead of creating spinners directly.
1
2
3
4
5
6
7
8
9
10
// ✅ 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 ()
Available IOStreams Methods# Progress : StartProgress(msg), StopProgress()Output : Printf(), Println(), Title(), Info(), Warning(), Error()Success/Failure : Success(), NoResults(), Added()Prompts : Confirm(), Prompt()Debug : DebugErr()Error Handling# Standard Pattern# Use separate declaration for readability and debugging.
1
2
3
4
5
6
7
8
9
10
11
// ✅ 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.
Error Wrapping# Always wrap errors with context using %w verb for error chains.
1
return fmt.Errorf ("failed to reboot device: %w" , err)
Context Propagation# Context Flow# 1
2
3
4
5
6
7
Root Command (creates signal - aware context)
↓
cmd. Context() passed to RunE
↓
run(ctx, opts)
↓
Service calls (svc. DeviceReboot(ctx, ... ))
Rules# Root command creates context with signal.NotifyContext for Ctrl+C handlingAll commands use cmd.Context(), never context.Background()Command timeouts wrap the passed context: ctx, cancel := context.WithTimeout(ctx, shelly.DefaultTimeout)Always defer cancel() to prevent context leaks1
2
3
4
5
6
7
8
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
}
Import Organization# gci-Compliant Ordering# Imports must be organized in three groups with blank lines between:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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.
Anti-Patterns to Avoid# 1. Direct Instantiation# 1
2
3
4
5
6
7
8
9
10
11
12
13
// ❌ 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 ()
// ...
}
2. Creating Context in Commands# 1
2
3
4
5
6
7
8
9
10
// ❌ 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()
}
3. Package-Level IOStreams Calls# 1
2
3
4
5
6
7
8
// ❌ Avoid package functions
iostreams.Success ("Done" )
iostreams.Warning ("Watch out" )
// ✅ Use instance methods
ios := f.IOStreams ()
ios.Success ("Done" )
ios.Warning ("Watch out" )
4. Manual Spinner Management# 1
2
3
4
5
6
7
8
9
10
11
// ❌ 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 ()
Migration Checklist# When creating a new command or updating an existing one:
Reference Implementations# 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 discoveryHelper usage examples :
internal/cmd/light/on/on.go - RunSimple helperinternal/cmd/light/status/status.go - RunStatus helperinternal/cmd/batch/command/command.go - RunBatch helperReference Patterns The following sections document patterns from industry-standard CLI tools that guide the shelly-cli implementation.
Factory Pattern# 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.
Why Use Factory?# Testability : Replace real dependencies with mocksLazy Loading : Dependencies created only when neededConsistency : Single source for all dependenciesPlugin Support : Plugins can receive the same dependenciesImplementation# 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
// 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
}
Usage in Commands# 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// 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
}
IOStreams Pattern# 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.
Why Use IOStreams?# Testability : Capture output in testsTTY Detection : Adjust output based on terminal capabilitiesColor Management : Respect NO_COLOR, FORCE_COLOR, etc.Progress Indicators : Unified spinner/progress handlingPaging : Automatic paging for long outputImplementation# 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
// 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
}
}
Test Helper# 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 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
}
Command Utilities# Source: gh (pkg/cmdutil/), kubectl, jira-cli (internal/cmdutil/, internal/cmdcommon/)
Shared utilities reduce duplication across commands.
Generic Command Runner# 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// 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 ()
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 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
}
}
Shared Flag Helpers# 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 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" )
}
Directory Structure# Source: gh (pkg/cmd/), docker (cli/command/), jira-cli (internal/cmd/)
Key Principle: Commands Only Under internal/cmd/# The internal/cmd/ directory contains ONLY command definitions. All shared utilities, helpers, and infrastructure live elsewhere.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
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)
Command Structure Pattern# Each command directory contains:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// 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
})
}
TUI Architecture# Source: gh-dash (dlvhdr/gh-dash), BubbleTea (charmbracelet/bubbletea)
The TUI uses the Elm Architecture via BubbleTea:
Model : Application stateInit : Initial command (data fetching)Update : Handle messages, return new model + commandsView : Render model to stringComponent Structure# Each TUI component follows the same pattern:
1
2
3
4
5
6
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
Component Implementation# 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// 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 ()
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// 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
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 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 ()
}
Async Data Fetching# 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 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,
}
}
}
Multi-Writer Output Pattern# 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 Why Multi-Writer?# Visual Clarity : See all operations at onceReal-time Feedback : Each target shows its own statusProfessional UX : Modern CLI expectation for concurrent opsImplementation with lipgloss# 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
// 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)
}
}
}
Usage in Batch Operations# 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 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
}
Example Output# 1
2
3
4
5
✓ living-room-light: on
◐ bedroom-switch: turning on...
✓ kitchen-dimmer: on
○ garage-relay: pending
✗ basement-plug: connection timeout
Opportunities in shelly-cli# Command Current With Multi-Writer batch on/off/toggleSequential success/error messages Per-device progress lines discover scanSingle spinner Per-IP status with progress firmware update --allSingle spinner Per-device update progress scene activateSequential messages Per-device activation status provision bulkUnknown Per-device provisioning progress
Concurrency Patterns# Source: gh, kubectl, best practices
WaitGroup.Go() (Go 1.25+) - Fire and Forget# Use WaitGroup.Go() when goroutines don’t return errors or errors are handled via channels/shared state:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 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 ()
errgroup - Error Propagation & Concurrency Limits# Use errgroup when you need error handling, context cancellation on first error, or concurrency limits:
1
2
3
4
5
6
7
8
9
10
11
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 concurrencyContext Propagation# Always pass context through the call chain:
1
2
3
4
5
6
7
8
9
10
11
12
13
// 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 )
}
Testing Patterns# TUI Testing with teatest# For TUI components, use the experimental teatest package from Charm:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 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))
}
Table-Driven Tests# 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
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 ())
})
}
}
Mock Factory# 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 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
},
}
}
References# CLI Architecture# TUI Frameworks# TUI Examples# Testing#