|
| 1 | +# Creating a New CLI Command |
| 2 | + |
| 3 | +This skill documents the conventions and checklist for adding a new subcommand to verda-cli. |
| 4 | + |
| 5 | +## Project Structure |
| 6 | + |
| 7 | +``` |
| 8 | +internal/verda-cli/cmd/<domain>/ |
| 9 | + <domain>.go # Parent command (NewCmd<Domain>) |
| 10 | + list.go # List subcommand |
| 11 | + add.go / create.go # Create/add subcommand |
| 12 | + delete.go # Delete subcommand |
| 13 | + action.go # Interactive action picker |
| 14 | +``` |
| 15 | + |
| 16 | +## Checklist |
| 17 | + |
| 18 | +Every new command MUST follow these patterns: |
| 19 | + |
| 20 | +### 1. Command Definition (cobra) |
| 21 | + |
| 22 | +```go |
| 23 | +func NewCmd<Action>(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { |
| 24 | + opts := &<action>Options{} |
| 25 | + |
| 26 | + cmd := &cobra.Command{ |
| 27 | + Use: "<action>", |
| 28 | + Short: "One-line description", |
| 29 | + Long: cmdutil.LongDesc(` |
| 30 | + Multi-line description. |
| 31 | + `), |
| 32 | + Example: cmdutil.Examples(` |
| 33 | + verda <domain> <action> |
| 34 | + verda <domain> <action> --flag value |
| 35 | + `), |
| 36 | + Args: cobra.NoArgs, |
| 37 | + RunE: func(cmd *cobra.Command, args []string) error { |
| 38 | + return run<Action>(cmd, f, ioStreams, opts) |
| 39 | + }, |
| 40 | + } |
| 41 | + |
| 42 | + flags := cmd.Flags() |
| 43 | + // Add flags here |
| 44 | + |
| 45 | + return cmd |
| 46 | +} |
| 47 | +``` |
| 48 | + |
| 49 | +### 2. Debug Output (`--debug`) |
| 50 | + |
| 51 | +`--debug` is a global persistent flag inherited by all subcommands. Every command that calls the API MUST include debug output. |
| 52 | + |
| 53 | +**For mutations (create/add/delete/action)** -- log the request before execution: |
| 54 | + |
| 55 | +```go |
| 56 | +cmdutil.DebugJSON(ioStreams.ErrOut, f.Debug(), "Request payload:", req) |
| 57 | +``` |
| 58 | + |
| 59 | +**For list/read commands** -- log the API response: |
| 60 | + |
| 61 | +```go |
| 62 | +cmdutil.DebugJSON(ioStreams.ErrOut, f.Debug(), fmt.Sprintf("API response: %d item(s):", len(items)), items) |
| 63 | +``` |
| 64 | + |
| 65 | +**For actions** -- log the action context: |
| 66 | + |
| 67 | +```go |
| 68 | +cmdutil.DebugJSON(ioStreams.ErrOut, f.Debug(), fmt.Sprintf("Action: %s on resource:", action.Label), map[string]string{ |
| 69 | + "id": resource.ID, |
| 70 | + "name": resource.Name, |
| 71 | + "status": resource.Status, |
| 72 | +}) |
| 73 | +``` |
| 74 | + |
| 75 | +### 3. Spinner Pattern |
| 76 | + |
| 77 | +All API calls that may take time MUST show a spinner: |
| 78 | + |
| 79 | +```go |
| 80 | +var sp interface{ Stop(string) } |
| 81 | +if status := f.Status(); status != nil { |
| 82 | + sp, _ = status.Spinner(ctx, "Loading items...") |
| 83 | +} |
| 84 | +result, err := client.Items.List(ctx) |
| 85 | +if sp != nil { |
| 86 | + sp.Stop("") |
| 87 | +} |
| 88 | +if err != nil { |
| 89 | + return err |
| 90 | +} |
| 91 | +``` |
| 92 | + |
| 93 | +### 4. Timeout Context |
| 94 | + |
| 95 | +All API calls MUST use a timeout context: |
| 96 | + |
| 97 | +```go |
| 98 | +ctx, cancel := context.WithTimeout(cmd.Context(), f.Options().Timeout) |
| 99 | +defer cancel() |
| 100 | +``` |
| 101 | + |
| 102 | +### 5. Credentials Check |
| 103 | + |
| 104 | +Commands that need API access call `f.VerdaClient()` which returns a clear error if not authenticated: |
| 105 | + |
| 106 | +```go |
| 107 | +client, err := f.VerdaClient() |
| 108 | +if err != nil { |
| 109 | + return err |
| 110 | +} |
| 111 | +``` |
| 112 | + |
| 113 | +### 6. Interactive + Non-Interactive |
| 114 | + |
| 115 | +Commands should support both modes: |
| 116 | +- **Flags** for scripting/CI (non-interactive) |
| 117 | +- **Prompter** for interactive use when flags are missing |
| 118 | + |
| 119 | +```go |
| 120 | +name := opts.Name |
| 121 | +if name == "" { |
| 122 | + name, err = prompter.TextInput(ctx, "Item name") |
| 123 | + if err != nil { |
| 124 | + return nil // User cancelled (Esc/Ctrl+C) |
| 125 | + } |
| 126 | +} |
| 127 | +``` |
| 128 | + |
| 129 | +### 7. Output Conventions |
| 130 | + |
| 131 | +- Normal output goes to `ioStreams.Out` |
| 132 | +- Prompts, warnings, and debug go to `ioStreams.ErrOut` |
| 133 | +- Use lipgloss styles from `charm.land/lipgloss/v2`: |
| 134 | + - `lipgloss.Color("8")` -- dim/gray |
| 135 | + - `lipgloss.Color("1")` -- red/error |
| 136 | + - `lipgloss.Color("2")` -- green/price |
| 137 | + - `lipgloss.Color("3")` -- yellow/warning |
| 138 | + - `lipgloss.Color("14")` -- cyan/accent |
| 139 | +- Bold: `lipgloss.NewStyle().Bold(true)` |
| 140 | + |
| 141 | +### 8. Confirmation for Destructive Actions |
| 142 | + |
| 143 | +Delete and dangerous actions MUST confirm: |
| 144 | + |
| 145 | +```go |
| 146 | +confirmed, err := prompter.Confirm(ctx, fmt.Sprintf("Delete %s?", name)) |
| 147 | +if err != nil || !confirmed { |
| 148 | + _, _ = fmt.Fprintln(ioStreams.ErrOut, "Cancelled.") |
| 149 | + return nil |
| 150 | +} |
| 151 | +``` |
| 152 | + |
| 153 | +For dangerous actions, add warning styling: |
| 154 | + |
| 155 | +```go |
| 156 | +warnStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Bold(true) |
| 157 | +_, _ = fmt.Fprintf(ioStreams.ErrOut, "\n %s\n", warnStyle.Render("This action cannot be undone.")) |
| 158 | +``` |
| 159 | + |
| 160 | +### 9. Interactive Select with Cancel |
| 161 | + |
| 162 | +Always append a "Cancel" option and handle Esc: |
| 163 | + |
| 164 | +```go |
| 165 | +labels = append(labels, "Cancel") |
| 166 | +idx, err := prompter.Select(ctx, "Select item", labels) |
| 167 | +if err != nil { |
| 168 | + return nil // Esc/Ctrl+C |
| 169 | +} |
| 170 | +if idx == len(items) { // Cancel |
| 171 | + return nil |
| 172 | +} |
| 173 | +``` |
| 174 | + |
| 175 | +### 10. Register in Parent Command |
| 176 | + |
| 177 | +Add the new command to its parent in `<domain>.go`: |
| 178 | + |
| 179 | +```go |
| 180 | +cmd.AddCommand( |
| 181 | + NewCmd<Action>(f, ioStreams), |
| 182 | +) |
| 183 | +``` |
| 184 | + |
| 185 | +### 11. Register Domain in Root |
| 186 | + |
| 187 | +If creating a new domain, add to `cmd/cmd.go` in the appropriate command group. |
| 188 | + |
| 189 | +### 12. Long Lists |
| 190 | + |
| 191 | +For commands that return potentially long lists, use the pager: |
| 192 | + |
| 193 | +```go |
| 194 | +if status := f.Status(); status != nil { |
| 195 | + return status.Pager(cmd.Context(), content, tui.WithPagerTitle("Title")) |
| 196 | +} |
| 197 | +_, _ = fmt.Fprint(ioStreams.Out, content) |
| 198 | +``` |
| 199 | + |
| 200 | +The pager auto-detects: prints directly if content fits terminal, otherwise shows scrollable viewport. |
| 201 | + |
| 202 | +## Dependencies |
| 203 | + |
| 204 | +- `cmdutil "github/verda-cloud/verda-cli/internal/verda-cli/cmd/util"` -- Factory, IOStreams, DebugJSON, helpers |
| 205 | +- `"github.com/verda-cloud/verdacloud-sdk-go/pkg/verda"` -- SDK client and types |
| 206 | +- `"github.com/verda-cloud/verdagostack/pkg/tui"` -- Prompter, Status, pager options |
| 207 | +- `"charm.land/lipgloss/v2"` -- Terminal styling |
| 208 | +- `"github.com/spf13/cobra"` -- Command framework |
| 209 | + |
| 210 | +## Wizard Flows |
| 211 | + |
| 212 | +For complex multi-step creation flows, use the wizard engine: |
| 213 | + |
| 214 | +```go |
| 215 | +flow := &wizard.Flow{ |
| 216 | + Name: "resource-create", |
| 217 | + Layout: []wizard.ViewDef{ |
| 218 | + {ID: "progress", View: wizard.NewProgressView(wizard.WithProgressPercent())}, |
| 219 | + }, |
| 220 | + Steps: []wizard.Step{ ... }, |
| 221 | +} |
| 222 | +engine := wizard.NewEngine(f.Prompter(), f.Status(), wizard.WithOutput(ioStreams.ErrOut)) |
| 223 | +return engine.Run(ctx, flow) |
| 224 | +``` |
| 225 | + |
| 226 | +Key wizard patterns: |
| 227 | +- Steps that handle their own prompting inside Loader should have no-op Setter/Resetter |
| 228 | +- Use `clientFunc` for lazy API client resolution |
| 229 | +- Use `apiCache` to share data between steps |
| 230 | +- Use `withSpinner` helper for API calls inside step Loaders |
| 231 | + |
| 232 | +## Hostname Generation |
| 233 | + |
| 234 | +Use `cmdutil.GenerateHostname(locationCode)` for auto-generated hostnames (3 random words + location). |
| 235 | +Use `cmdutil.ValidateHostname(s)` for validation (letters, digits, hyphens, no leading/trailing hyphen). |
0 commit comments