Skip to content

Commit 9c2ebcb

Browse files
obayclaude
andcommitted
feat: add interactive TUI prompts for missing arguments
- Add interactive Bubble Tea prompts when commands are run without required arguments - New prompt package with TextInput, Select, and SearchSelect components - Commands now support interactive mode: workout/routine/folder get/update/delete, exercise get/search, stats progress, config set, and completion - Add workout events command for listing workout events - Add routine delete and folder update/delete commands - Improve argument validation with cmdutil.RequireArgs helper - Fix exercise get to always show Custom: Yes/No field 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent a795159 commit 9c2ebcb

File tree

27 files changed

+1721
-62
lines changed

27 files changed

+1721
-62
lines changed

cmd/completion/completion.go

Lines changed: 58 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,23 @@
11
package completion
22

33
import (
4+
"fmt"
45
"os"
56

67
"github.com/spf13/cobra"
8+
9+
"github.com/obay/hevycli/internal/cmdutil"
10+
"github.com/obay/hevycli/internal/tui/prompt"
711
)
812

913
// Cmd is the completion command
1014
var Cmd = &cobra.Command{
11-
Use: "completion [bash|zsh|fish|powershell]",
15+
Use: "completion <shell>",
1216
Short: "Generate shell completion scripts",
1317
Long: `Generate shell completion scripts for hevycli.
1418
19+
Supported shells: bash, zsh, fish, powershell
20+
1521
To load completions:
1622
1723
Bash:
@@ -48,18 +54,57 @@ PowerShell:
4854
`,
4955
DisableFlagsInUseLine: true,
5056
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
51-
Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs),
52-
RunE: func(cmd *cobra.Command, args []string) error {
53-
switch args[0] {
54-
case "bash":
55-
return cmd.Root().GenBashCompletion(os.Stdout)
56-
case "zsh":
57-
return cmd.Root().GenZshCompletion(os.Stdout)
58-
case "fish":
59-
return cmd.Root().GenFishCompletion(os.Stdout, true)
60-
case "powershell":
61-
return cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout)
57+
Args: func(cmd *cobra.Command, args []string) error {
58+
if len(args) == 0 {
59+
// Allow interactive mode to handle it
60+
if cmdutil.IsInteractive() {
61+
return nil
62+
}
63+
return fmt.Errorf("please specify a shell: bash, zsh, fish, or powershell\n\nExample: hevycli completion bash")
64+
}
65+
if len(args) > 1 {
66+
return fmt.Errorf("only one shell can be specified at a time")
6267
}
63-
return nil
68+
for _, valid := range []string{"bash", "zsh", "fish", "powershell"} {
69+
if args[0] == valid {
70+
return nil
71+
}
72+
}
73+
return fmt.Errorf("unknown shell %q\n\nSupported shells: bash, zsh, fish, powershell", args[0])
6474
},
75+
RunE: runCompletion,
76+
}
77+
78+
func runCompletion(cmd *cobra.Command, args []string) error {
79+
var shell string
80+
81+
if len(args) == 0 {
82+
// Interactive mode - prompt for shell selection
83+
options := []prompt.SelectOption{
84+
{ID: "bash", Title: "Bash", Description: "GNU Bourne-Again SHell"},
85+
{ID: "zsh", Title: "Zsh", Description: "Z shell (macOS default)"},
86+
{ID: "fish", Title: "Fish", Description: "Friendly Interactive SHell"},
87+
{ID: "powershell", Title: "PowerShell", Description: "Microsoft PowerShell"},
88+
}
89+
90+
selected, err := prompt.Select("Select your shell", options, "Choose the shell for completion scripts")
91+
if err != nil {
92+
return err
93+
}
94+
shell = selected.ID
95+
} else {
96+
shell = args[0]
97+
}
98+
99+
switch shell {
100+
case "bash":
101+
return cmd.Root().GenBashCompletion(os.Stdout)
102+
case "zsh":
103+
return cmd.Root().GenZshCompletion(os.Stdout)
104+
case "fish":
105+
return cmd.Root().GenFishCompletion(os.Stdout, true)
106+
case "powershell":
107+
return cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout)
108+
}
109+
return nil
65110
}

cmd/config/set.go

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ import (
77
"github.com/spf13/cobra"
88

99
"github.com/obay/hevycli/internal/api"
10+
"github.com/obay/hevycli/internal/cmdutil"
1011
"github.com/obay/hevycli/internal/config"
12+
"github.com/obay/hevycli/internal/tui/prompt"
1113
)
1214

1315
var validateKey bool
@@ -30,7 +32,7 @@ Examples:
3032
hevycli config set units imperial
3133
hevycli config set default-output json
3234
hevycli config set color false`,
33-
Args: cobra.ExactArgs(2),
35+
Args: cmdutil.RequireArgs(2, "<key> <value>"),
3436
RunE: runSet,
3537
}
3638

@@ -40,8 +42,81 @@ func init() {
4042
}
4143

4244
func runSet(cmd *cobra.Command, args []string) error {
43-
key := strings.ToLower(args[0])
44-
value := args[1]
45+
var key, value string
46+
47+
if len(args) >= 2 {
48+
key = strings.ToLower(args[0])
49+
value = args[1]
50+
} else {
51+
// Interactive mode - let user select key and enter value
52+
keyOptions := []prompt.SelectOption{
53+
{ID: "api-key", Title: "API Key", Description: "Your Hevy API key"},
54+
{ID: "default-output", Title: "Default Output", Description: "Output format (json, table, plain)"},
55+
{ID: "units", Title: "Units", Description: "Measurement units (metric, imperial)"},
56+
{ID: "color", Title: "Color", Description: "Enable/disable colored output"},
57+
{ID: "date-format", Title: "Date Format", Description: "Date format (Go format string)"},
58+
{ID: "time-format", Title: "Time Format", Description: "Time format (Go format string)"},
59+
}
60+
61+
selected, err := prompt.Select("Select configuration key", keyOptions, "Choose a setting to configure...")
62+
if err != nil {
63+
return err
64+
}
65+
key = selected.ID
66+
67+
// Get value based on key type
68+
switch key {
69+
case "default-output":
70+
outputOptions := []prompt.SelectOption{
71+
{ID: "table", Title: "Table", Description: "Formatted table output (default)"},
72+
{ID: "json", Title: "JSON", Description: "Raw JSON output"},
73+
{ID: "plain", Title: "Plain", Description: "Plain text output"},
74+
}
75+
selectedOutput, err := prompt.Select("Select output format", outputOptions, "Choose a format...")
76+
if err != nil {
77+
return err
78+
}
79+
value = selectedOutput.ID
80+
81+
case "units":
82+
unitsOptions := []prompt.SelectOption{
83+
{ID: "metric", Title: "Metric", Description: "Kilograms and kilometers"},
84+
{ID: "imperial", Title: "Imperial", Description: "Pounds and miles"},
85+
}
86+
selectedUnits, err := prompt.Select("Select measurement units", unitsOptions, "Choose units...")
87+
if err != nil {
88+
return err
89+
}
90+
value = selectedUnits.ID
91+
92+
case "color":
93+
colorOptions := []prompt.SelectOption{
94+
{ID: "true", Title: "Enabled", Description: "Show colored output"},
95+
{ID: "false", Title: "Disabled", Description: "Plain monochrome output"},
96+
}
97+
selectedColor, err := prompt.Select("Enable colored output?", colorOptions, "Choose...")
98+
if err != nil {
99+
return err
100+
}
101+
value = selectedColor.ID
102+
103+
default:
104+
// Text input for other keys
105+
placeholder := "Enter value..."
106+
if key == "api-key" {
107+
placeholder = "Enter your Hevy API key..."
108+
} else if key == "date-format" {
109+
placeholder = "e.g., 2006-01-02"
110+
} else if key == "time-format" {
111+
placeholder = "e.g., 15:04"
112+
}
113+
114+
value, err = prompt.TextInput("Enter "+key, placeholder, "enter to confirm")
115+
if err != nil {
116+
return err
117+
}
118+
}
119+
}
45120

46121
// Load existing config or create default
47122
cfg, err := config.Load("")

cmd/exercise/get.go

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ import (
88
"github.com/spf13/cobra"
99

1010
"github.com/obay/hevycli/internal/api"
11+
"github.com/obay/hevycli/internal/cmdutil"
1112
"github.com/obay/hevycli/internal/config"
1213
"github.com/obay/hevycli/internal/output"
14+
"github.com/obay/hevycli/internal/tui/prompt"
1315
)
1416

1517
var getCmd = &cobra.Command{
@@ -20,13 +22,11 @@ var getCmd = &cobra.Command{
2022
Examples:
2123
hevycli exercise get ABC123 # Get exercise by ID
2224
hevycli exercise get ABC123 -o json # Output as JSON`,
23-
Args: cobra.ExactArgs(1),
25+
Args: cmdutil.RequireArgs(1, "<exercise-id>"),
2426
RunE: runGet,
2527
}
2628

2729
func runGet(cmd *cobra.Command, args []string) error {
28-
exerciseID := args[0]
29-
3030
cfg, err := config.Load("")
3131
if err != nil {
3232
return fmt.Errorf("failed to load config: %w", err)
@@ -39,6 +39,46 @@ func runGet(cmd *cobra.Command, args []string) error {
3939

4040
client := api.NewClient(apiKey)
4141

42+
var exerciseID string
43+
if len(args) > 0 {
44+
exerciseID = args[0]
45+
} else {
46+
// Interactive mode - let user search and select an exercise
47+
selected, err := prompt.SearchSelect(prompt.SearchSelectConfig{
48+
Title: "Select an Exercise",
49+
Placeholder: "Search exercises...",
50+
Help: "Type to filter by exercise name",
51+
LoadFunc: func() ([]prompt.SelectOption, error) {
52+
var allExercises []api.ExerciseTemplate
53+
page := 1
54+
for {
55+
resp, err := client.GetExerciseTemplates(page, 10)
56+
if err != nil {
57+
return nil, err
58+
}
59+
allExercises = append(allExercises, resp.ExerciseTemplates...)
60+
if page >= resp.PageCount || len(allExercises) > 500 {
61+
break
62+
}
63+
page++
64+
}
65+
options := make([]prompt.SelectOption, len(allExercises))
66+
for i, ex := range allExercises {
67+
options[i] = prompt.SelectOption{
68+
ID: ex.ID,
69+
Title: ex.Title,
70+
Description: ex.PrimaryMuscleGroup + " • " + ex.Equipment,
71+
}
72+
}
73+
return options, nil
74+
},
75+
})
76+
if err != nil {
77+
return err
78+
}
79+
exerciseID = selected.ID
80+
}
81+
4282
// Get exercise template by fetching from the list
4383
// The API may not have a direct /exercise_templates/{id} endpoint that returns full data
4484
// So we search through the list
@@ -112,5 +152,7 @@ func printExerciseDetails(ex *api.ExerciseTemplate, cfg *config.Config) {
112152

113153
if ex.IsCustom {
114154
fmt.Println("Custom: Yes")
155+
} else {
156+
fmt.Println("Custom: No")
115157
}
116158
}

cmd/exercise/search.go

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ import (
88
"github.com/spf13/cobra"
99

1010
"github.com/obay/hevycli/internal/api"
11+
"github.com/obay/hevycli/internal/cmdutil"
1112
"github.com/obay/hevycli/internal/config"
1213
"github.com/obay/hevycli/internal/output"
14+
"github.com/obay/hevycli/internal/tui/prompt"
1315
)
1416

1517
var (
@@ -28,7 +30,7 @@ Examples:
2830
hevycli exercise search "press" --muscle chest # Filter by muscle
2931
hevycli exercise search "" --equipment barbell # Filter by equipment only
3032
hevycli exercise search "curl" -o json # Output as JSON`,
31-
Args: cobra.ExactArgs(1),
33+
Args: cmdutil.RequireArgs(1, "<query>"),
3234
RunE: runSearch,
3335
}
3436

@@ -39,7 +41,22 @@ func init() {
3941
}
4042

4143
func runSearch(cmd *cobra.Command, args []string) error {
42-
query := strings.ToLower(args[0])
44+
var query string
45+
if len(args) > 0 {
46+
query = strings.ToLower(args[0])
47+
} else {
48+
// Interactive mode - prompt for search query
49+
var err error
50+
query, err = prompt.TextInput(
51+
"Search Exercises",
52+
"Enter search term...",
53+
"enter to search",
54+
)
55+
if err != nil {
56+
return err
57+
}
58+
query = strings.ToLower(query)
59+
}
4360

4461
cfg, err := config.Load("")
4562
if err != nil {

cmd/folder/create.go

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ import (
77
"github.com/spf13/cobra"
88

99
"github.com/obay/hevycli/internal/api"
10+
"github.com/obay/hevycli/internal/cmdutil"
1011
"github.com/obay/hevycli/internal/config"
1112
"github.com/obay/hevycli/internal/output"
13+
"github.com/obay/hevycli/internal/tui/prompt"
1214
)
1315

1416
var createCmd = &cobra.Command{
@@ -19,7 +21,7 @@ var createCmd = &cobra.Command{
1921
Examples:
2022
hevycli folder create "Push Pull" # Create a folder
2123
hevycli folder create "My Routines" -o json # Output as JSON`,
22-
Args: cobra.ExactArgs(1),
24+
Args: cmdutil.RequireArgs(1, "<title>"),
2325
RunE: runFolderCreate,
2426
}
2527

@@ -28,7 +30,24 @@ func init() {
2830
}
2931

3032
func runFolderCreate(cmd *cobra.Command, args []string) error {
31-
title := args[0]
33+
var title string
34+
if len(args) > 0 {
35+
title = args[0]
36+
} else {
37+
// Interactive mode - prompt for folder title
38+
var err error
39+
title, err = prompt.TextInput(
40+
"Create Folder",
41+
"Enter folder name...",
42+
"enter to confirm",
43+
)
44+
if err != nil {
45+
return err
46+
}
47+
if title == "" {
48+
return fmt.Errorf("folder title cannot be empty")
49+
}
50+
}
3251

3352
cfg, err := config.Load("")
3453
if err != nil {

0 commit comments

Comments
 (0)