Adding a New wsh Command to Wave Terminal
This guide explains how to add a new command to the wsh CLI tool.
wsh Command System Overview
Wave Terminal's wsh command provides CLI access to Wave Terminal features. The system uses:
-
Cobra Framework - CLI command structure and parsing
-
Command Files - Individual command implementations in cmd/wsh/cmd/wshcmd-*.go
-
RPC Client - Communication with Wave Terminal backend via RpcClient
-
Activity Tracking - Telemetry for command usage analytics
-
Documentation - User-facing docs in docs/docs/wsh-reference.mdx
Commands are registered in their init() functions and execute through the Cobra framework.
Step-by-Step Guide
Step 1: Create Command File
Create a new file in cmd/wsh/cmd/ named wshcmd-[commandname].go :
// Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0
package cmd
import ( "fmt"
"github.com/spf13/cobra"
"github.com/wavetermdev/waveterm/pkg/wshrpc"
"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient"
)
var myCommandCmd = &cobra.Command{
Use: "mycommand [args]",
Short: "Brief description of what this command does",
Long: Detailed description of the command. Can include multiple lines and examples of usage.,
RunE: myCommandRun,
PreRunE: preRunSetupRpcClient, // Include if command needs RPC
DisableFlagsInUseLine: true,
}
// Flag variables var ( myCommandFlagExample string myCommandFlagVerbose bool )
func init() { // Add command to root rootCmd.AddCommand(myCommandCmd)
// Define flags
myCommandCmd.Flags().StringVarP(&myCommandFlagExample, "example", "e", "", "example flag description")
myCommandCmd.Flags().BoolVarP(&myCommandFlagVerbose, "verbose", "v", false, "enable verbose output")
}
func myCommandRun(cmd *cobra.Command, args []string) (rtnErr error) { // Always track activity for telemetry defer func() { sendActivity("mycommand", rtnErr == nil) }()
// Validate arguments
if len(args) == 0 {
OutputHelpMessage(cmd)
return fmt.Errorf("requires at least one argument")
}
// Command implementation
fmt.Printf("Command executed successfully\n")
return nil
}
File Naming Convention:
-
Use wshcmd-[commandname].go format
-
Use lowercase, hyphenated names for multi-word commands
-
Examples: wshcmd-getvar.go , wshcmd-setmeta.go , wshcmd-ai.go
Step 2: Command Structure
Basic Command Structure
var myCommandCmd = &cobra.Command{
Use: "mycommand [required] [optional...]",
Short: "One-line description (shown in help)",
Long: Detailed multi-line description,
// Argument validation
Args: cobra.MinimumNArgs(1), // Or cobra.ExactArgs(1), cobra.NoArgs, etc.
// Execution function
RunE: myCommandRun,
// Pre-execution setup (if needed)
PreRunE: preRunSetupRpcClient, // Sets up RPC client for backend communication
// Example usage (optional)
Example: " wsh mycommand foo\n wsh mycommand --flag bar",
// Disable flag notation in usage line
DisableFlagsInUseLine: true,
}
Key Fields:
-
Use : Command name and argument pattern
-
Short : Brief description for command list
-
Long : Detailed description shown in help
-
Args : Argument validator (optional)
-
RunE : Main execution function (returns error)
-
PreRunE : Setup function that runs before RunE
-
Example : Usage examples (optional)
-
DisableFlagsInUseLine : Clean up help display
When to Use PreRunE
Include PreRunE: preRunSetupRpcClient if your command:
-
Communicates with the Wave Terminal backend
-
Needs access to RpcClient
-
Requires JWT authentication (WAVETERM_JWT env var)
-
Makes RPC calls via wshclient.*Command() functions
Don't include PreRunE for commands that:
-
Only manipulate local state
-
Don't need backend communication
-
Are purely informational/local operations
Step 3: Implement Command Logic
Command Function Pattern
func myCommandRun(cmd *cobra.Command, args []string) (rtnErr error) { // Step 1: Always track activity (for telemetry) defer func() { sendActivity("mycommand", rtnErr == nil) }()
// Step 2: Validate arguments and flags
if len(args) != 1 {
OutputHelpMessage(cmd)
return fmt.Errorf("requires exactly one argument")
}
// Step 3: Parse/prepare data
targetArg := args[0]
// Step 4: Make RPC call if needed
result, err := wshclient.SomeCommand(RpcClient, wshrpc.CommandSomeData{
Field: targetArg,
}, &wshrpc.RpcOpts{Timeout: 2000})
if err != nil {
return fmt.Errorf("executing command: %w", err)
}
// Step 5: Output results
fmt.Printf("Result: %s\n", result)
return nil
}
Important Patterns:
Activity Tracking: Always include deferred sendActivity() call
defer func() { sendActivity("commandname", rtnErr == nil) }()
Error Handling: Return errors, don't call os.Exit()
if err != nil { return fmt.Errorf("context: %w", err) }
Output: Use standard fmt package for output
fmt.Printf("Success message\n") fmt.Fprintf(os.Stderr, "Error message\n")
Help Messages: Show help when arguments are invalid
if len(args) == 0 { OutputHelpMessage(cmd) return fmt.Errorf("requires arguments") }
Exit Codes: Set custom exit code via WshExitCode
if notFound { WshExitCode = 1 return nil // Don't return error, just set exit code }
Step 4: Define Flags
Add flags in the init() function:
var ( // Declare flag variables at package level myCommandFlagString string myCommandFlagBool bool myCommandFlagInt int )
func init() { rootCmd.AddCommand(myCommandCmd)
// String flag with short version
myCommandCmd.Flags().StringVarP(&myCommandFlagString, "name", "n", "default", "description")
// Boolean flag
myCommandCmd.Flags().BoolVarP(&myCommandFlagBool, "verbose", "v", false, "enable verbose")
// Integer flag
myCommandCmd.Flags().IntVar(&myCommandFlagInt, "count", 10, "set count")
// Flag without short version
myCommandCmd.Flags().StringVar(&myCommandFlagString, "longname", "", "description")
}
Flag Types:
-
StringVar/StringVarP
-
String values
-
BoolVar/BoolVarP
-
Boolean flags
-
IntVar/IntVarP
-
Integer values
-
The P suffix versions include a short flag name
Flag Naming:
-
Use camelCase for variable names: myCommandFlagName
-
Use kebab-case for flag names: --flag-name
-
Prefix variable names with command name for clarity
Step 5: Working with Block Arguments
Many commands operate on blocks. Use the standard block resolution pattern:
func myCommandRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { sendActivity("mycommand", rtnErr == nil) }()
// Resolve block using the -b/--block flag
fullORef, err := resolveBlockArg()
if err != nil {
return err
}
// Use the blockid in RPC call
err = wshclient.SomeCommand(RpcClient, wshrpc.CommandSomeData{
BlockId: fullORef.OID,
}, &wshrpc.RpcOpts{Timeout: 2000})
if err != nil {
return fmt.Errorf("command failed: %w", err)
}
return nil
}
Block Resolution:
-
The -b/--block flag is defined globally in wshcmd-root.go
-
resolveBlockArg() resolves the block argument to a full ORef
-
Supports: this , tab , full UUIDs, 8-char prefixes, block numbers
-
Default is "this" (current block)
Alternative: Manual Block Resolution
// Get tab ID from environment tabId := os.Getenv("WAVETERM_TABID") if tabId == "" { return fmt.Errorf("WAVETERM_TABID not set") }
// Create route for tab-level operations route := wshutil.MakeTabRouteId(tabId)
// Use route in RPC call err := wshclient.SomeCommand(RpcClient, commandData, &wshrpc.RpcOpts{ Route: route, Timeout: 2000, })
Step 6: Making RPC Calls
Use the wshclient package to make RPC calls:
import ( "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" )
// Simple RPC call result, err := wshclient.GetMetaCommand(RpcClient, wshrpc.CommandGetMetaData{ ORef: *fullORef, }, &wshrpc.RpcOpts{Timeout: 2000}) if err != nil { return fmt.Errorf("getting metadata: %w", err) }
// RPC call with routing err := wshclient.SetMetaCommand(RpcClient, wshrpc.CommandSetMetaData{ ORef: *fullORef, Meta: metaMap, }, &wshrpc.RpcOpts{ Route: route, Timeout: 5000, }) if err != nil { return fmt.Errorf("setting metadata: %w", err) }
RPC Options:
-
Timeout : Request timeout in milliseconds (typically 2000-5000)
-
Route : Route ID for targeting specific components
-
Available routes: wshutil.ControlRoute , wshutil.MakeTabRouteId(tabId)
Step 7: Add Documentation
Add your command to docs/docs/wsh-reference.mdx :
mycommand
Brief description of what the command does.
wsh mycommand [args] [flags]
Detailed explanation of the command's purpose and behavior.
Flags:
-n, --name <value>- description of this flag-v, --verbose- enable verbose output-b, --block <blockid>- specify target block (default: current block)
Examples:
# Basic usage
wsh mycommand arg1
# With flags
wsh mycommand --name value arg1
# With block targeting
wsh mycommand -b 2 arg1
# Complex example
wsh mycommand -v --name "example" arg1 arg2
Additional notes, tips, or warnings about the command.
Documentation Guidelines:
-
Place in alphabetical order with other commands
-
Include command signature with argument pattern
-
List all flags with short and long versions
-
Provide practical examples (at least 3-5)
-
Explain common use cases and patterns
-
Add tips or warnings if relevant
-
Use --- separator between commands
Step 8: Test Your Command
Build and test the command:
Build wsh
task build:wsh
Or build everything
task build
Test the command
./bin/wsh/wsh mycommand --help ./bin/wsh/wsh mycommand arg1 arg2
Testing Checklist:
-
Help message displays correctly
-
Required arguments validated
-
Flags work as expected
-
Error messages are clear
-
Success cases work correctly
-
RPC calls complete successfully
-
Output is formatted correctly
Complete Examples
Example 1: Simple Command with No RPC
Use case: A command that prints Wave Terminal version info
Command File (cmd/wsh/cmd/wshcmd-version.go )
// Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0
package cmd
import ( "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/wavebase" )
var versionCmd = &cobra.Command{ Use: "version", Short: "Print Wave Terminal version", RunE: versionRun, }
func init() { rootCmd.AddCommand(versionCmd) }
func versionRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { sendActivity("version", rtnErr == nil) }()
fmt.Printf("Wave Terminal %s\n", wavebase.WaveVersion)
return nil
}
Documentation
version
Print the current Wave Terminal version.
wsh version
Examples:
# Print version
wsh version
Example 2: Command with Flags and RPC
Use case: A command to update block title
Command File (cmd/wsh/cmd/wshcmd-settitle.go )
// Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0
package cmd
import ( "fmt"
"github.com/spf13/cobra"
"github.com/wavetermdev/waveterm/pkg/wshrpc"
"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient"
)
var setTitleCmd = &cobra.Command{
Use: "settitle [title]",
Short: "Set block title",
Long: Set the title for the current or specified block.,
Args: cobra.ExactArgs(1),
RunE: setTitleRun,
PreRunE: preRunSetupRpcClient,
DisableFlagsInUseLine: true,
}
var setTitleIcon string
func init() { rootCmd.AddCommand(setTitleCmd) setTitleCmd.Flags().StringVarP(&setTitleIcon, "icon", "i", "", "set block icon") }
func setTitleRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { sendActivity("settitle", rtnErr == nil) }()
title := args[0]
// Resolve block
fullORef, err := resolveBlockArg()
if err != nil {
return err
}
// Build metadata map
meta := make(map[string]interface{})
meta["title"] = title
if setTitleIcon != "" {
meta["icon"] = setTitleIcon
}
// Make RPC call
err = wshclient.SetMetaCommand(RpcClient, wshrpc.CommandSetMetaData{
ORef: *fullORef,
Meta: meta,
}, &wshrpc.RpcOpts{Timeout: 2000})
if err != nil {
return fmt.Errorf("setting title: %w", err)
}
fmt.Printf("title updated\n")
return nil
}
Documentation
settitle
Set the title for a block.
wsh settitle [title]
Update the display title for the current or specified block. Optionally set an icon as well.
Flags:
-i, --icon <icon>- set block icon along with title-b, --block <blockid>- specify target block (default: current block)
Examples:
# Set title for current block
wsh settitle "My Terminal"
# Set title and icon
wsh settitle --icon "terminal" "Development Shell"
# Set title for specific block
wsh settitle -b 2 "Build Output"
Example 3: Subcommands
Use case: Command with multiple subcommands (like wsh conn )
Command File (cmd/wsh/cmd/wshcmd-mygroup.go )
// Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0
package cmd
import ( "fmt"
"github.com/spf13/cobra"
"github.com/wavetermdev/waveterm/pkg/wshrpc"
"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient"
)
var myGroupCmd = &cobra.Command{ Use: "mygroup", Short: "Manage something", }
var myGroupListCmd = &cobra.Command{ Use: "list", Short: "List items", RunE: myGroupListRun, PreRunE: preRunSetupRpcClient, }
var myGroupAddCmd = &cobra.Command{ Use: "add [name]", Short: "Add an item", Args: cobra.ExactArgs(1), RunE: myGroupAddRun, PreRunE: preRunSetupRpcClient, }
func init() { // Add parent command rootCmd.AddCommand(myGroupCmd)
// Add subcommands
myGroupCmd.AddCommand(myGroupListCmd)
myGroupCmd.AddCommand(myGroupAddCmd)
}
func myGroupListRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { sendActivity("mygroup:list", rtnErr == nil) }()
// Implementation
fmt.Printf("Listing items...\n")
return nil
}
func myGroupAddRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { sendActivity("mygroup:add", rtnErr == nil) }()
name := args[0]
fmt.Printf("Adding item: %s\n", name)
return nil
}
Documentation
mygroup
Manage something with subcommands.
list
List all items.
wsh mygroup list
add
Add a new item.
wsh mygroup add [name]
Examples:
# List items
wsh mygroup list
# Add an item
wsh mygroup add "new-item"
Common Patterns
Reading from Stdin
import "io"
func myCommandRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { sendActivity("mycommand", rtnErr == nil) }()
// Check if reading from stdin (using "-" convention)
var data []byte
var err error
if len(args) > 0 && args[0] == "-" {
data, err = io.ReadAll(os.Stdin)
if err != nil {
return fmt.Errorf("reading stdin: %w", err)
}
} else {
// Read from file or other source
data, err = os.ReadFile(args[0])
if err != nil {
return fmt.Errorf("reading file: %w", err)
}
}
// Process data
fmt.Printf("Read %d bytes\n", len(data))
return nil
}
JSON File Input
import ( "encoding/json" "io" )
func loadJSONFile(filepath string) (map[string]interface{}, error) { var data []byte var err error
if filepath == "-" {
data, err = io.ReadAll(os.Stdin)
if err != nil {
return nil, fmt.Errorf("reading stdin: %w", err)
}
} else {
data, err = os.ReadFile(filepath)
if err != nil {
return nil, fmt.Errorf("reading file: %w", err)
}
}
var result map[string]interface{}
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("parsing JSON: %w", err)
}
return result, nil
}
Conditional Output (TTY Detection)
func myCommandRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { sendActivity("mycommand", rtnErr == nil) }()
isTty := getIsTty()
// Output value
fmt.Printf("%s", value)
// Add newline only if TTY (for better piping experience)
if isTty {
fmt.Printf("\n")
}
return nil
}
Environment Variable Access
func myCommandRun(cmd *cobra.Command, args []string) (rtnErr error) { defer func() { sendActivity("mycommand", rtnErr == nil) }()
// Get block ID from environment
blockId := os.Getenv("WAVETERM_BLOCKID")
if blockId == "" {
return fmt.Errorf("WAVETERM_BLOCKID not set")
}
// Get tab ID from environment
tabId := os.Getenv("WAVETERM_TABID")
if tabId == "" {
return fmt.Errorf("WAVETERM_TABID not set")
}
fmt.Printf("Block: %s, Tab: %s\n", blockId, tabId)
return nil
}
Best Practices
Command Design
-
Single Responsibility: Each command should do one thing well
-
Composable: Design commands to work with pipes and other commands
-
Consistent: Follow existing wsh command patterns and conventions
-
Documented: Provide clear help text and examples
Error Handling
-
Context: Wrap errors with context using fmt.Errorf("context: %w", err)
-
User-Friendly: Make error messages clear and actionable
-
No Panics: Return errors instead of calling os.Exit() or panic()
-
Exit Codes: Use WshExitCode for custom exit codes
Output
-
Structured: Use consistent formatting for output
-
Quiet by Default: Only output what's necessary
-
Verbose Flag: Optionally provide -v for detailed output
-
Stderr for Errors: Use fmt.Fprintf(os.Stderr, ...) for error messages
Flags
-
Short Versions: Provide -x short versions for common flags
-
Sensible Defaults: Choose defaults that work for most users
-
Boolean Flags: Use for on/off options
-
String Flags: Use for values that need user input
RPC Calls
-
Timeouts: Always specify reasonable timeouts
-
Error Context: Wrap RPC errors with operation context
-
Retries: Don't retry automatically; let user retry command
-
Routes: Use appropriate routes for different operations
Common Pitfalls
- Forgetting Activity Tracking
Problem: Command usage not tracked in telemetry
Solution: Always include deferred sendActivity() call:
defer func() { sendActivity("commandname", rtnErr == nil) }()
- Using os.Exit() Instead of Returning Error
Problem: Breaks defer statements and cleanup
Solution: Return errors from RunE function:
// Bad if err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) os.Exit(1) }
// Good if err != nil { return fmt.Errorf("operation failed: %w", err) }
- Not Validating Arguments
Problem: Command crashes with nil pointer or index out of range
Solution: Validate arguments early and show help:
if len(args) == 0 { OutputHelpMessage(cmd) return fmt.Errorf("requires at least one argument") }
- Forgetting to Add to init()
Problem: Command not available when running wsh
Solution: Always add command in init() function:
func init() { rootCmd.AddCommand(myCommandCmd) }
- Inconsistent Output
Problem: Inconsistent use of output methods
Solution: Use standard fmt package functions:
// For stdout fmt.Printf("output\n")
// For stderr fmt.Fprintf(os.Stderr, "error message\n")
Quick Reference Checklist
When adding a new wsh command:
-
Create cmd/wsh/cmd/wshcmd-[commandname].go
-
Define command struct with Use, Short, Long descriptions
-
Add PreRunE: preRunSetupRpcClient if using RPC
-
Implement command function with activity tracking
-
Add command to rootCmd in init() function
-
Define flags in init() function if needed
-
Add documentation to docs/docs/wsh-reference.mdx
-
Build and test: task build:wsh
-
Test help: wsh [commandname] --help
-
Test all flag combinations
-
Test error cases
Related Files
-
Root Command: cmd/wsh/cmd/wshcmd-root.go
-
Main command setup and utilities
-
RPC Client: pkg/wshrpc/wshclient/
-
Client functions for RPC calls
-
RPC Types: pkg/wshrpc/wshrpctypes.go
-
RPC request/response data structures
-
Documentation: docs/docs/wsh-reference.mdx
-
User-facing command reference
-
Examples: cmd/wsh/cmd/wshcmd-*.go
-
Existing command implementations