add-module
Add Module Skill
Automates creation of new modules in the CLY project following established patterns.
Module Isolation Principle (CRITICAL)
The Portability Test: Before creating a module, ask: "If I copy this module folder to a new repo, how hard would it be to make it work?"
The answer should be: trivially easy.
Requirements for Isolation
- Self-contained: All module logic lives within its directory
- Minimal dependencies: Only depend on stdlib, Bubbletea/Bubbles/Lipgloss, and Cobra
- No cross-module imports: Modules NEVER import from other modules
- Single registration point: Only touch parent's
cmd.gofor registration - Own types: Define types locally, don't reach into other packages
What a Module Can Import
✓ Standard library (fmt, strings, etc.)
✓ github.com/charmbracelet/bubbletea
✓ github.com/charmbracelet/bubbles/*
✓ github.com/charmbracelet/lipgloss
✓ github.com/spf13/cobra
✓ github.com/yurifrl/cly/pkg/* (shared utilities - see below)
✗ github.com/yurifrl/cly/modules/* (NEVER)
Avoiding Duplication (The Balance)
Isolation doesn't mean blind copy-paste. Use pkg/ for genuinely shared code:
| Location | Purpose | Example |
|---|---|---|
pkg/style/ |
Shared Lipgloss styles | Colors, borders, common styles |
pkg/keys/ |
Common keybindings | Quit keys, navigation patterns |
pkg/tui/ |
TUI utilities | Screen helpers, common components |
Rule of Three: Only extract to pkg/ when 3+ modules need the same code.
Duplication is OK when:
- Variations exist between modules
- Extraction would create tight coupling
Extract to pkg/ when:
- Exact same code in 3+ places
- Code is substantial and stable
- Changes should propagate everywhere
Module Directory = Complete Unit
modules/demo/spinner/
├── cmd.go # Registration only
├── spinner.go # All logic here
└── (optional) # Helpers if needed, but keep in same package
Copy this folder → paste in new project → change import path → works.
When to Use This Skill
- User wants to add a new demo module showcasing a Bubbletea component
- User wants to create a new utility command
- User mentions "create a module", "add a command", "new demo"
Module Types
Demo Modules (modules/demo/<name>/)
Purpose: Showcase Charm UI components and patterns
Examples: chat, spinner, table, list-simple (48 total)
Parent: Registered under demo namespace
Utility Modules (modules/<name>/)
Purpose: Provide real functionality Examples: uuid (UUID generator) Parent: Registered directly under root command
Step-by-Step Workflow
Determine Module Type
Ask user if unclear:
- "Is this a demo (showcase component) or utility (real functionality)?"
Find Reference (for demos)
- Check if component exists in
references/bubbletea/examples/<name>/ - Read the reference implementation
- Note initialization code in main() function
Create Directory Structure
# For demo:
mkdir -p modules/demo/<name>
# For utility:
mkdir -p modules/<name>
Create cmd.go (Command Registration)
Template for demos:
package <packagename>
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/spf13/cobra"
)
func Register(parent *cobra.Command) {
cmd := &cobra.Command{
Use: "<name>",
Short: "<description>",
RunE: run,
}
parent.AddCommand(cmd)
}
func run(cmd *cobra.Command, args []string) error {
p := tea.NewProgram(initialModel())
if _, err := p.Run(); err != nil {
return err
}
return nil
}
Add tea.Program options if needed:
tea.WithAltScreen()- For fullscreen demostea.WithMouseAllMotion()- For mouse trackingtea.WithReportFocus()- For focus/blur events
Create Implementation File
Extract from reference:
- Copy type definitions (model struct, custom types)
- Copy Init(), Update(), View() methods
- Create initialModel() from main() function's initialization code
- Remove unused imports (fmt, os, log often unused after main() removal)
Template:
package <packagename>
import (
tea "github.com/charmbracelet/bubbletea"
// Component imports as needed
)
type model struct {
// State fields
}
func initialModel() model {
// Initialization from reference's main()
return model{}
}
func (m model) Init() tea.Cmd {
return nil
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
}
}
return m, nil
}
func (m model) View() string {
return "Your UI\n"
}
Register Module
For demos - Edit modules/demo/cmd.go:
import (
yourmodule "github.com/yurifrl/cly/modules/demo/your-module"
)
func init() {
// ... existing registrations
yourmodule.Register(DemoCmd)
}
For utilities - Edit cmd/root.go:
import (
"github.com/yurifrl/cly/modules/yourutil"
)
func init() {
// ... existing registrations
yourutil.Register(RootCmd)
}
Validation Checklist
- Compiles:
go build - Shows in help:
go run main.go --helporgo run main.go demo --help - Runs:
go run main.go <command>(orgo run main.go demo <name>) - Quits cleanly with 'q' or Ctrl+C
- No unused imports
Common Patterns
Package Naming
- Directory with hyphens:
list-simple/ - Package name with underscores:
package list_simple - Import alias:
listsimple "github.com/yurifrl/cly/modules/demo/list-simple"
Extracting initialModel()
In reference main():
func main() {
s := spinner.New()
s.Spinner = spinner.Dot
m := model{spinner: s}
tea.NewProgram(m).Run()
}
Extract to:
func initialModel() model {
s := spinner.New()
s.Spinner = spinner.Dot
return model{spinner: s}
}
Helper Functions
If reference has helpers (like getPackages(), filter(), etc.), copy them to the implementation file.
Examples to Reference
Simple: modules/demo/spinner/ - Basic component
Complex: modules/demo/list-fancy/ - Multiple files (delegate.go, randomitems.go)
Utility: modules/uuid/ - Real functionality with list UI
Advanced: modules/demo/chat/ - Multiple components (textarea + viewport)
Quick Reference Commands
# Test compilation
go build
# View help
go run main.go --help
go run main.go demo --help
# Run demo
go run main.go demo <name>
# Run utility
go run main.go <name>
# Clean dependencies
go mod tidy
Best Practices
Start from reference - All 48 Bubbletea examples available in references/ Copy existing module - Fastest way to get structure right Test incrementally - Build and run after each file Clean imports early - Remove fmt/os/log before testing Follow naming - Hyphens in names, underscores in packages
Troubleshooting
Build Errors
- "undefined: initialModel" → Function not created or private (make sure it's
initialModel, notInitialModel) - "unused import" → Remove it from imports
- "package name mismatch" → Check hyphens vs underscores
Runtime Errors
- "could not open TTY" → Normal in non-interactive shells, try in terminal
- Component not responding → Check Update() delegates to component's Update()
- Can't quit → Verify KeyMsg handling for "q" and "ctrl+c"
Module Template
Use this template to add new commands quickly.
Module Categories
Demo Modules (UI Component Showcases)
Location: modules/demo/<name>/
Purpose: Demonstrate Charm components and patterns
Examples: chat, spinner, table, list-simple
When to use: Showcasing UI components, TUI patterns, Bubbletea features
Utility Modules (Real Functionality)
Location: modules/<name>/
Purpose: Provide actual utility commands
Examples: uuid (UUID generator)
When to use: Commands users will actually use for work
Quick Steps
For Demo Modules
- Find reference:
references/bubbletea/examples/<component>/ - Copy pattern:
cp -r modules/demo/spinner modules/demo/<newname> - Adapt implementation from reference
- Register in
modules/demo/cmd.goinit() - Test
For Utility Modules
- Copy pattern:
cp -r modules/uuid modules/<newname> - Implement functionality
- Register in
cmd/root.goinit() - Test
Demo Module Template
File: modules/demo/<name>/cmd.go
package <packagename> // Use underscores for hyphens: list_simple for list-simple
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/spf13/cobra"
)
func Register(parent *cobra.Command) {
cmd := &cobra.Command{
Use: "<name>",
Short: "<short description>",
Long: "<detailed description>",
RunE: run,
}
parent.AddCommand(cmd)
}
func run(cmd *cobra.Command, args []string) error {
p := tea.NewProgram(initialModel())
if _, err := p.Run(); err != nil {
return err
}
return nil
}
File: modules/demo/<name>/<name>.go
package <packagename>
import (
tea "github.com/charmbracelet/bubbletea"
// Add component imports as needed:
// "github.com/charmbracelet/bubbles/spinner"
// "github.com/charmbracelet/bubbles/list"
// "github.com/charmbracelet/bubbles/table"
// "github.com/charmbracelet/lipgloss"
)
type model struct {
// Component state
quitting bool
err error
}
func initialModel() model {
// Initialize your model here
// Extract this from reference example's main() function
return model{}
}
func (m model) Init() tea.Cmd {
return nil
// Or return component's Init: spinner.Tick, textarea.Blink, etc.
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "q", "ctrl+c":
m.quitting = true
return m, tea.Quit
}
}
return m, nil
}
func (m model) View() string {
if m.quitting {
return "Goodbye!\n"
}
return "Your UI here\nPress q to quit\n"
}
Registration Patterns
Demo Module Registration
File: modules/demo/cmd.go
import (
// ...
yourmodule "github.com/yurifrl/cly/modules/demo/your-module"
)
func init() {
// ...
yourmodule.Register(DemoCmd)
}
Utility Module Registration
File: cmd/root.go
import (
// ...
"github.com/yurifrl/cly/modules/yourutil"
)
func init() {
uuid.Register(RootCmd)
demo.Register(RootCmd)
yourutil.Register(RootCmd) // Add here
}
Bubbletea Program Options
Some demos require special options when creating the tea.Program:
AltScreen (Fullscreen Mode)
func run(cmd *cobra.Command, args []string) error {
p := tea.NewProgram(initialModel(), tea.WithAltScreen())
_, err := p.Run()
return err
}
Use when: Demo should use alternate screen buffer (fullscreen, eyes, cellbuffer)
Examples: modules/demo/fullscreen/, modules/demo/eyes/
Mouse Support
p := tea.NewProgram(initialModel(), tea.WithMouseAllMotion())
Use when: Demo needs mouse tracking
Example: modules/demo/mouse/
Focus Reporting
p := tea.NewProgram(initialModel(), tea.WithReportFocus())
Use when: Demo needs to know when terminal gains/loses focus
Example: modules/demo/focus-blur/
Input Filtering
p := tea.NewProgram(initialModel(), tea.WithFilter(filterFunc))
Use when: Need to intercept/modify messages before Update()
Example: modules/demo/prevent-quit/
Reference Examples (48 Available)
All 48 Bubbletea examples are in references/bubbletea/examples/ and modules/demo/:
Core Components
| Demo | Shows | Reference |
|---|---|---|
spinner |
Animated loading | references/bubbletea/examples/spinner |
list-simple |
Selection lists | references/bubbletea/examples/list-simple |
table |
Data tables | references/bubbletea/examples/table |
textinput |
Single-line input | references/bubbletea/examples/textinput |
textarea |
Multi-line input | references/bubbletea/examples/textarea |
progress-static |
Progress bars | references/bubbletea/examples/progress-static |
Advanced
| Demo | Shows | Reference |
|---|---|---|
chat |
Textarea + Viewport | references/bubbletea/examples/chat |
file-picker |
File selection | references/bubbletea/examples/file-picker |
credit-card-form |
Complex forms | references/bubbletea/examples/credit-card-form |
split-editors |
Multiple panes | references/bubbletea/examples/split-editors |
All 48 examples are available - explore modules/demo/ for implementations.
Adapting Reference Examples
Step-by-Step Process
Find reference: references/bubbletea/examples/<component>/main.go
Read main() function: This has initialization code
Extract to initialModel(): Move setup from main() to initialModel()
Copy Model implementation: Copy type definitions, Init(), Update(), View()
Clean imports: Remove fmt, os, log if unused
Create cmd.go: Use template above with Register() function
Example: Adapting Spinner
Reference: references/bubbletea/examples/spinner/main.go
Extract this from main():
s := spinner.New()
s.Spinner = spinner.Dot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
return model{spinner: s}
Becomes initialModel():
func initialModel() model {
s := spinner.New()
s.Spinner = spinner.Dot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
return model{spinner: s}
}
Naming Conventions
Command Names
- Lowercase only
- Hyphens for multi-word:
list-simple,credit-card-form,altscreen-toggle
Package Names
- Lowercase, no hyphens
- Use underscores:
list_simple,credit_card_form,altscreen_toggle - Go converts hyphens automatically during import
File Names
cmd.go- Always this name (command registration)<name>.go- Main implementation (e.g.,spinner.go,list-simple.go)- Additional files:
delegate.go,helpers.go,types.go(if needed)
Checklist
Before Implementation
- Decided: demo or utility module?
- Found reference example (if demo)
- Command name chosen (lowercase, hyphens if multi-word)
During Implementation
- Created directory in correct location
- Created cmd.go with Register() function
- Created implementation file with initialModel()
- Package name matches conventions
- Imports are clean (no unused)
After Implementation
- Registered in parent cmd.go init()
- Import added to parent cmd.go
- Compiles:
go build - Appears in help:
go run main.go --helporgo run main.go demo --help - Runs:
go run main.go <command> - Quits cleanly with 'q' or Ctrl+C
Tips
Start with existing demos - 48 working examples to learn from
Copy working code - Don't reinvent, adapt from references
Test frequently - Build and run after each change
Keep it simple - Single file until complexity demands splitting
Use shared styles - Import pkg/style for consistent theming
Follow the pattern - Look at 3-4 similar modules before starting
Troubleshooting
"undefined: initialModel"
- Make sure initialModel() function exists in implementation file
- Check it's exported (lowercase 'i' makes it package-private)
"package name mismatch"
- Directory name with hyphens → package name with underscores
- Example:
list-simple/→package list_simple
"unused import"
- Remove
fmt,os,logif not actually used - Check your View() and Update() functions
- Common after removing main() function
"command not showing in help"
- Verify Register() called in parent's init()
- Check import path is correct
- Run
go mod tidy
Advanced Patterns
Multiple Files (Complex Modules)
modules/demo/list-fancy/
├── cmd.go # Command registration
├── list-fancy.go # Model and main logic
├── delegate.go # Custom item delegate
└── randomitems.go # Helper functions
With Flags
var demoType string
func Register(parent *cobra.Command) {
cmd := &cobra.Command{
Use: "demo",
Short: "Demo with flag",
RunE: run,
}
cmd.Flags().StringVarP(&demoType, "type", "t", "default", "Demo type")
parent.AddCommand(cmd)
}
func run(cmd *cobra.Command, args []string) error {
// Use demoType variable in initialModel()
p := tea.NewProgram(initialModel(demoType))
_, err := p.Run()
return err
}
With Required Args
func Register(parent *cobra.Command) {
cmd := &cobra.Command{
Use: "download <url>",
Short: "Download with progress",
Args: cobra.ExactArgs(1), // Require 1 argument
RunE: run,
}
parent.AddCommand(cmd)
}
func run(cmd *cobra.Command, args []string) error {
url := args[0]
p := tea.NewProgram(initialModel(url))
_, err := p.Run()
return err
}
More from yurifrl/cly
charm-stack
Build terminal UIs with Bubbletea v2, Bubbles v2, Lipgloss v2, and Huh v2. Use when creating TUI applications, interactive forms, styled terminal output, or when user mentions Bubbletea, Bubbles, Lipgloss, Huh, Charm, or TUI development.
55cobra-modularity
Build modular CLI applications with Cobra framework. Use when structuring CLI commands, implementing modular command architecture, handling flags and arguments, or when user mentions Cobra, CLI modularity, command registration, or spf13/cobra.
15go-specialist
Go language consultant providing guidance on best practices, testing with testify, concurrency patterns, error handling, and technology stack recommendations. Use when answering Go questions, reviewing Go code, or advising on Go implementation approaches.
13cli-config
Manage CLI application configuration with Cobra and Viper. Use when implementing config files, environment variables, flags binding, or when user mentions Viper, configuration management, config files, or CLI settings.
10testing
Write tests using TDD principles with integration tests as default and minimal mocking. Use when writing code, fixing bugs, or when user mentions tests, TDD, unit tests, integration tests, or testing strategy.
5find-skills
Helps users discover and install agent skills when they ask questions like "how do I do X", "find a skill for X", "is there a skill that can...", or express interest in extending capabilities. This skill should be used when the user is looking for functionality that might exist as an installable skill.
2