Adding a New CLI Command
January 30, 2026 · View on GitHub
Step-by-step guide for adding new commands to the Nylas CLI.
Overview
This project uses Cobra for CLI commands and follows hexagonal architecture (ports and adapters pattern).
Layers:
- CLI (
internal/cli/<feature>/) - User interface layer - Port (
internal/ports/nylas.go) - Interface contracts - Adapter (
internal/adapters/nylas/) - API implementations - Domain (
internal/domain/) - Business entities
Quick Start
1. Define Domain Model
File: internal/domain/<feature>.go
package domain
// Widget represents a widget resource
type Widget struct {
ID string `json:"id"`
GrantID string `json:"grant_id"`
Name string `json:"name"`
Description string `json:"description"`
CreatedAt int64 `json:"created_at"`
}
// WidgetListParams for listing widgets
type WidgetListParams struct {
Limit int
Offset int
}
2. Add Port Interface
File: internal/ports/nylas.go
// Add to NylasClient interface
type NylasClient interface {
// ... existing methods
// Widget operations
ListWidgets(ctx context.Context, grantID string, params *domain.WidgetListParams) ([]*domain.Widget, error)
GetWidget(ctx context.Context, grantID, widgetID string) (*domain.Widget, error)
CreateWidget(ctx context.Context, grantID string, widget *domain.Widget) (*domain.Widget, error)
DeleteWidget(ctx context.Context, grantID, widgetID string) error
}
3. Implement Adapter
File: internal/adapters/nylas/widgets.go
package nylas
import (
"context"
"fmt"
"net/http"
"github.com/nylas/cli/internal/domain"
)
// ListWidgets implements ports.NylasClient
func (c *Client) ListWidgets(ctx context.Context, grantID string, params *domain.WidgetListParams) ([]*domain.Widget, error) {
url := fmt.Sprintf("%s/v3/grants/%s/widgets", c.baseURL, grantID)
// Build query params
query := make(map[string]string)
if params.Limit > 0 {
query["limit"] = fmt.Sprintf("%d", params.Limit)
}
req, err := c.newRequest(ctx, http.MethodGet, url, query, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
var response struct {
Data []*domain.Widget `json:"data"`
}
if err := c.do(req, &response); err != nil {
return nil, fmt.Errorf("failed to list widgets: %w", err)
}
return response.Data, nil
}
// GetWidget implements ports.NylasClient
func (c *Client) GetWidget(ctx context.Context, grantID, widgetID string) (*domain.Widget, error) {
url := fmt.Sprintf("%s/v3/grants/%s/widgets/%s", c.baseURL, grantID, widgetID)
req, err := c.newRequest(ctx, http.MethodGet, url, nil, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
var widget domain.Widget
if err := c.do(req, &widget); err != nil {
return nil, fmt.Errorf("failed to get widget: %w", err)
}
return &widget, nil
}
// CreateWidget implements ports.NylasClient
func (c *Client) CreateWidget(ctx context.Context, grantID string, widget *domain.Widget) (*domain.Widget, error) {
url := fmt.Sprintf("%s/v3/grants/%s/widgets", c.baseURL, grantID)
req, err := c.newRequest(ctx, http.MethodPost, url, nil, widget)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
var created domain.Widget
if err := c.do(req, &created); err != nil {
return nil, fmt.Errorf("failed to create widget: %w", err)
}
return &created, nil
}
// DeleteWidget implements ports.NylasClient
func (c *Client) DeleteWidget(ctx context.Context, grantID, widgetID string) error {
url := fmt.Sprintf("%s/v3/grants/%s/widgets/%s", c.baseURL, grantID, widgetID)
req, err := c.newRequest(ctx, http.MethodDelete, url, nil, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
if err := c.do(req, nil); err != nil {
return fmt.Errorf("failed to delete widget: %w", err)
}
return nil
}
4. Create CLI Commands
Directory structure:
internal/cli/widgets/
├── widgets.go # Root command
├── list.go # List subcommand
├── show.go # Show subcommand
├── create.go # Create subcommand
├── delete.go # Delete subcommand
└── helpers.go # Shared helpers
File: internal/cli/widgets/widgets.go
package widgets
import (
"github.com/spf13/cobra"
)
// NewWidgetCmd creates the widget command
func NewWidgetCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "widget",
Short: "Manage widgets",
Long: `Manage widgets including listing, creating, and deleting.`,
}
// Add subcommands
cmd.AddCommand(newListCmd())
cmd.AddCommand(newShowCmd())
cmd.AddCommand(newCreateCmd())
cmd.AddCommand(newDeleteCmd())
return cmd
}
File: internal/cli/widgets/list.go
package widgets
import (
"context"
"fmt"
"github.com/nylas/cli/internal/domain"
"github.com/nylas/cli/internal/ports"
"github.com/spf13/cobra"
)
type listOptions struct {
limit int
}
func newListCmd() *cobra.Command {
opts := &listOptions{}
cmd := &cobra.Command{
Use: "list [grant-id]",
Short: "List widgets",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runList(cmd.Context(), args, opts)
},
}
cmd.Flags().IntVar(&opts.limit, "limit", 10, "Maximum number of widgets to return")
return cmd
}
func runList(ctx context.Context, args []string, opts *listOptions) error {
// Get grant ID
grantID, err := getGrantID(args)
if err != nil {
return err
}
// Get client
client, err := getNylasClient()
if err != nil {
return fmt.Errorf("failed to get client: %w", err)
}
// List widgets
params := &domain.WidgetListParams{
Limit: opts.limit,
}
widgets, err := client.ListWidgets(ctx, grantID, params)
if err != nil {
return fmt.Errorf("failed to list widgets: %w", err)
}
// Display results
displayWidgets(widgets)
return nil
}
func displayWidgets(widgets []*domain.Widget) {
fmt.Printf("Found %d widget(s):\n\n", len(widgets))
for _, w := range widgets {
fmt.Printf("ID: %s\n", w.ID)
fmt.Printf("Name: %s\n", w.Name)
fmt.Printf("Description: %s\n", w.Description)
fmt.Println()
}
}
File: internal/cli/widgets/create.go
package widgets
import (
"context"
"fmt"
"github.com/nylas/cli/internal/domain"
"github.com/spf13/cobra"
)
type createOptions struct {
name string
description string
}
func newCreateCmd() *cobra.Command {
opts := &createOptions{}
cmd := &cobra.Command{
Use: "create [grant-id]",
Short: "Create a new widget",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runCreate(cmd.Context(), args, opts)
},
}
cmd.Flags().StringVar(&opts.name, "name", "", "Widget name")
cmd.Flags().StringVar(&opts.description, "description", "", "Widget description")
_ = cmd.MarkFlagRequired("name")
return cmd
}
func runCreate(ctx context.Context, args []string, opts *createOptions) error {
grantID, err := getGrantID(args)
if err != nil {
return err
}
client, err := getNylasClient()
if err != nil {
return fmt.Errorf("failed to get client: %w", err)
}
// Create widget
widget := &domain.Widget{
Name: opts.name,
Description: opts.description,
}
created, err := client.CreateWidget(ctx, grantID, widget)
if err != nil {
return fmt.Errorf("failed to create widget: %w", err)
}
fmt.Printf("✅ Widget created successfully!\n")
fmt.Printf("ID: %s\n", created.ID)
fmt.Printf("Name: %s\n", created.Name)
return nil
}
File: internal/cli/widgets/helpers.go
package widgets
import (
"fmt"
"os"
"github.com/nylas/cli/internal/adapters/nylas"
"github.com/nylas/cli/internal/config"
"github.com/nylas/cli/internal/ports"
)
// getGrantID gets grant ID from args or config
func getGrantID(args []string) (string, error) {
if len(args) > 0 {
return args[0], nil
}
cfg, err := config.Load()
if err != nil {
return "", fmt.Errorf("failed to load config: %w", err)
}
if cfg.GrantID == "" {
return "", fmt.Errorf("grant ID required")
}
return cfg.GrantID, nil
}
// getNylasClient creates a Nylas client
func getNylasClient() (ports.NylasClient, error) {
cfg, err := config.Load()
if err != nil {
return nil, fmt.Errorf("failed to load config: %w", err)
}
if cfg.APIKey == "" {
return nil, fmt.Errorf("API key not configured")
}
return nylas.NewClient(cfg.APIKey), nil
}
5. Register Command
File: cmd/nylas/main.go
import (
// ... existing imports
"github.com/nylas/cli/internal/cli/widgets"
)
func main() {
rootCmd := &cobra.Command{
Use: "nylas",
Short: "Nylas CLI",
}
// Register commands
rootCmd.AddCommand(auth.NewAuthCmd())
rootCmd.AddCommand(email.NewEmailCmd())
rootCmd.AddCommand(widgets.NewWidgetCmd()) // Add this line
// ... other commands
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}
6. Add Unit Tests
File: internal/cli/widgets/list_test.go
package widgets
import (
"context"
"testing"
"github.com/nylas/cli/internal/domain"
)
func TestListWidgets(t *testing.T) {
tests := []struct {
name string
opts *listOptions
wantErr bool
}{
{
name: "successful list",
opts: &listOptions{limit: 10},
wantErr: false,
},
{
name: "with custom limit",
opts: &listOptions{limit: 50},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Test implementation
})
}
}
7. Add Integration Tests
File: internal/cli/integration/widgets_test.go
//go:build integration
// +build integration
package integration
import (
"testing"
)
func TestWidgets(t *testing.T) {
skipIfMissingCreds(t)
t.Parallel()
t.Run("ListWidgets", func(t *testing.T) {
acquireRateLimit(t)
stdout, stderr, err := runCLIWithRateLimit(t, "widget", "list")
if err != nil {
t.Fatalf("Command failed: %v\nStderr: %s", err, stderr)
}
if len(stdout) == 0 {
t.Error("Expected output, got none")
}
})
t.Run("CreateWidget", func(t *testing.T) {
acquireRateLimit(t)
stdout, stderr, err := runCLIWithRateLimit(t,
"widget", "create",
"--name", "Test Widget",
"--description", "Integration test",
)
if err != nil {
t.Fatalf("Command failed: %v\nStderr: %s", err, stderr)
}
// Verify output contains success message
if !contains(stdout, "created successfully") {
t.Error("Expected success message not found")
}
// Cleanup
t.Cleanup(func() {
acquireRateLimit(t)
// Delete created widget
})
})
}
8. Update Documentation
File: docs/COMMANDS.md
Add widget command documentation:
### Widget Commands
```bash
nylas widget list # List widgets
nylas widget show <id> # Show widget details
nylas widget create --name "..." # Create widget
nylas widget delete <id> # Delete widget
Full guide: See docs/commands/widgets.md
**File:** `docs/commands/widgets.md`
Create detailed command guide with examples.
---
### 9. Run Quality Checks
```bash
# Format code
go fmt ./...
# Run linter
golangci-lint run --timeout=5m
# Run tests
go test ./... -short
# Run integration tests
make test-integration
# Full check
make ci
Best Practices
Command Design
- Use consistent naming:
nylas <resource> <action> - Provide aliases: Common shortcuts (e.g.,
lsforlist) - Clear help text: Useful descriptions and examples
- Sensible defaults: Make common use cases easy
- Progressive disclosure: Basic → advanced options
Error Handling
// ✅ Good - wrap errors with context
if err != nil {
return fmt.Errorf("failed to create widget: %w", err)
}
// ❌ Bad - lose error context
if err != nil {
return err
}
Flag Naming
// ✅ Good - descriptive, kebab-case
cmd.Flags().StringVar(&opts.sortBy, "sort-by", "name", "Sort widgets by field")
// ❌ Bad - unclear
cmd.Flags().StringVar(&opts.s, "s", "", "Sort")
Complete Example: Full CRUD Command
See internal/cli/email/ for a complete, production-ready example of CRUD operations.
More Resources
- Architecture: ARCHITECTURE.md
- Testing Guide: testing-guide.md
- Code Style: CONTRIBUTING.md
- Cobra Docs: https://github.com/spf13/cobra