Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Zeabur CLI - Development Notes

## Build & Test
- Build: `go build ./...`
- Run: `go run ./cmd/main.go <command>`
- Test: `go test ./...`

## Project Structure
- `cmd/main.go` — entry point
- `internal/cmd/<command>/` — each CLI command in its own package
- `internal/cmdutil/` — shared command utilities (Factory, auth checks, spinner config)
- `pkg/api/` — GraphQL API client
- `pkg/model/` — data models (GraphQL struct tags)
- `internal/cmd/root/root.go` — root command, registers all subcommands

## Important: Keep `help --all` in sync
When adding or modifying CLI commands, flags, or subcommands, the output of `zeabur help --all` automatically reflects changes (it walks the Cobra command tree at runtime). No manual update is needed for the help output itself.

However, when adding a **new subcommand**, you must:
1. Create the command package under `internal/cmd/<parent>/<new>/`
2. Register it in the parent command file (e.g., `internal/cmd/template/template.go`)

## Conventions
- Each subcommand lives in its own package: `internal/cmd/<parent>/<sub>/<sub>.go`
- Commands support both interactive and non-interactive modes; if a flag is provided, skip the interactive prompt
- Use `cmdutil.SpinnerCharSet`, `cmdutil.SpinnerInterval`, `cmdutil.SpinnerColor` for spinners
- Models in `pkg/model/` use `graphql:"fieldName"` struct tags — only add fields that exist in the backend GraphQL schema
- Backend GraphQL schema lives in `../backend/internal/gateway/graphql/`
75 changes: 75 additions & 0 deletions internal/cmd/help/help.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package help

import (
"fmt"
"strings"

"github.com/spf13/cobra"
"github.com/spf13/pflag"
)

func NewCmdHelp(rootCmd *cobra.Command) *cobra.Command {
var all bool

cmd := &cobra.Command{
Use: "help [command]",
Short: "Help about any command",
RunE: func(cmd *cobra.Command, args []string) error {
if all {
printAllCommands(rootCmd, "")
return nil
}

// default: find the target command and show its help
target, _, err := rootCmd.Find(args)
if err != nil {
return err
}
return target.Help()
},
}

cmd.Flags().BoolVar(&all, "all", false, "Show all commands with their flags")

return cmd
}

func printAllCommands(cmd *cobra.Command, prefix string) {
fullName := prefix + cmd.Name()

if cmd.Runnable() || len(cmd.Commands()) == 0 {
fmt.Printf("%s - %s\n", fullName, cmd.Short)
printFlags(cmd, fullName)
}

for _, child := range cmd.Commands() {
if child.Hidden || child.Name() == "help" {
continue
}
printAllCommands(child, fullName+" ")
}
}

func printFlags(cmd *cobra.Command, fullName string) {
var flags []string

cmd.LocalFlags().VisitAll(func(f *pflag.Flag) {
if f.Hidden {
return
}
entry := " --" + f.Name
if f.Shorthand != "" {
entry = " -" + f.Shorthand + ", --" + f.Name
}
if f.DefValue != "" && f.DefValue != "false" {
entry += fmt.Sprintf(" (default: %s)", f.DefValue)
}
entry += " " + f.Usage
flags = append(flags, entry)
})

if len(flags) > 0 {
fmt.Println(strings.Join(flags, "\n"))
fmt.Println()
}
}
4 changes: 4 additions & 0 deletions internal/cmd/root/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (

authCmd "github.com/zeabur/cli/internal/cmd/auth"
completionCmd "github.com/zeabur/cli/internal/cmd/completion"
helpCmd "github.com/zeabur/cli/internal/cmd/help"
contextCmd "github.com/zeabur/cli/internal/cmd/context"
deployCmd "github.com/zeabur/cli/internal/cmd/deploy"
deploymentCmd "github.com/zeabur/cli/internal/cmd/deployment"
Expand Down Expand Up @@ -129,5 +130,8 @@ func NewCmdRoot(f *cmdutil.Factory, version, commit, date string) (*cobra.Comman
cmd.AddCommand(completionCmd.NewCmdCompletion(f))
cmd.AddCommand(variableCmd.NewCmdVariable(f))

// replace default help command with our custom one that supports --all
cmd.SetHelpCommand(helpCmd.NewCmdHelp(cmd))

return cmd, nil
}
50 changes: 41 additions & 9 deletions internal/cmd/template/get/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ package get
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"time"

"github.com/briandowns/spinner"
"github.com/spf13/cobra"
Expand All @@ -12,6 +17,7 @@ import (

type Options struct {
code string
raw bool
}

func NewCmdGet(f *cmdutil.Factory) *cobra.Command {
Expand All @@ -26,6 +32,7 @@ func NewCmdGet(f *cmdutil.Factory) *cobra.Command {
}

cmd.Flags().StringVarP(&opts.code, "code", "c", "", "Template code")
cmd.Flags().BoolVar(&opts.raw, "raw", false, "Output raw YAML spec")

return cmd
}
Expand All @@ -38,19 +45,18 @@ func runGet(f *cmdutil.Factory, opts Options) error {
}

func runGetInteractive(f *cmdutil.Factory, opts Options) error {
code, err := f.Prompter.Input("Template Code: ", "")
if err != nil {
return err
if opts.code == "" {
code, err := f.Prompter.Input("Template Code: ", "")
if err != nil {
return err
}
opts.code = code
}

opts.code = code

err = getTemplate(f, opts)
if err != nil {
if err := paramCheck(opts); err != nil {
return err
}

return nil
return getTemplate(f, opts)
}

func runGetNonInteractive(f *cmdutil.Factory, opts Options) error {
Expand All @@ -68,6 +74,10 @@ func runGetNonInteractive(f *cmdutil.Factory, opts Options) error {
}

func getTemplate(f *cmdutil.Factory, opts Options) error {
if opts.raw {
return getTemplateRaw(opts.code)
}

s := spinner.New(cmdutil.SpinnerCharSet, cmdutil.SpinnerInterval,
spinner.WithColor(cmdutil.SpinnerColor),
spinner.WithSuffix(" Fetching template..."),
Expand All @@ -88,6 +98,28 @@ func getTemplate(f *cmdutil.Factory, opts Options) error {
return nil
}

func getTemplateRaw(code string) error {
u := "https://zeabur.com/templates/" + url.PathEscape(code) + ".yaml"
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return fmt.Errorf("failed to build request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("failed to fetch template YAML: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return fmt.Errorf("template not found (HTTP %d)", resp.StatusCode)
}

_, err = io.Copy(os.Stdout, resp.Body)
return err
}

func paramCheck(opts Options) error {
if opts.code == "" {
return fmt.Errorf("template code is required")
Expand Down
91 changes: 91 additions & 0 deletions internal/cmd/template/search/search.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package search

import (
"context"
"fmt"
"sort"
"strconv"
"strings"

"github.com/briandowns/spinner"
"github.com/spf13/cobra"

"github.com/zeabur/cli/internal/cmdutil"
"github.com/zeabur/cli/pkg/model"
)

type Options struct {
keyword string
}

func NewCmdSearch(f *cmdutil.Factory) *cobra.Command {
opts := Options{}

cmd := &cobra.Command{
Use: "search [keyword]",
Short: "Search templates by keyword",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) > 0 {
opts.keyword = args[0]
}
return runSearch(f, opts)
},
}

return cmd
}

func runSearch(f *cmdutil.Factory, opts Options) error {
if opts.keyword == "" {
if f.Interactive {
keyword, err := f.Prompter.Input("Search keyword: ", "")
if err != nil {
return err
}
opts.keyword = keyword
} else {
return fmt.Errorf("keyword is required")
}
}
Comment on lines +39 to +50
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Validate empty keyword after interactive prompt.

In interactive mode, if the user presses Enter without typing a keyword, the search proceeds with an empty string which matches all templates (since strings.Contains(x, "") is always true). This is inconsistent with non-interactive mode which requires a keyword.

Consider adding validation after the prompt, similar to how template get handles it:

💡 Suggested fix
 func runSearch(f *cmdutil.Factory, opts Options) error {
 	if opts.keyword == "" {
 		if f.Interactive {
 			keyword, err := f.Prompter.Input("Search keyword: ", "")
 			if err != nil {
 				return err
 			}
 			opts.keyword = keyword
+			if opts.keyword == "" {
+				return fmt.Errorf("keyword is required")
+			}
 		} else {
 			return fmt.Errorf("keyword is required")
 		}
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func runSearch(f *cmdutil.Factory, opts Options) error {
if opts.keyword == "" {
if f.Interactive {
keyword, err := f.Prompter.Input("Search keyword: ", "")
if err != nil {
return err
}
opts.keyword = keyword
} else {
return fmt.Errorf("keyword is required")
}
}
func runSearch(f *cmdutil.Factory, opts Options) error {
if opts.keyword == "" {
if f.Interactive {
keyword, err := f.Prompter.Input("Search keyword: ", "")
if err != nil {
return err
}
opts.keyword = keyword
if opts.keyword == "" {
return fmt.Errorf("keyword is required")
}
} else {
return fmt.Errorf("keyword is required")
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/cmd/template/search/search.go` around lines 39 - 50, runSearch
currently accepts an empty keyword when in interactive mode because it doesn't
validate the value returned by f.Prompter.Input; after calling f.Prompter.Input
in runSearch, check if the returned keyword (opts.keyword) is empty and return
an error (same message used in non-interactive path, e.g. "keyword is required")
instead of proceeding—update the branch that sets opts.keyword from
f.Prompter.Input in runSearch to validate and return an error when the user
presses Enter with no input.


s := spinner.New(cmdutil.SpinnerCharSet, cmdutil.SpinnerInterval,
spinner.WithColor(cmdutil.SpinnerColor),
spinner.WithSuffix(" Searching templates..."),
)
s.Start()
allTemplates, err := f.ApiClient.ListAllTemplates(context.Background())
if err != nil {
s.Stop()
return err
}
s.Stop()

keyword := strings.ToLower(opts.keyword)
var matched model.Templates
for _, t := range allTemplates {
name := strings.ToLower(t.Name)
desc := strings.ToLower(t.Description)
if strings.Contains(name, keyword) || strings.Contains(desc, keyword) {
matched = append(matched, t)
}
}

sort.Slice(matched, func(i, j int) bool {
return matched[i].DeploymentCnt > matched[j].DeploymentCnt
})

if len(matched) == 0 {
fmt.Println("No templates found")
return nil
}

header := []string{"Code", "Name", "Description", "Deployments"}
rows := make([][]string, 0, len(matched))
for _, t := range matched {
rows = append(rows, []string{t.Code, t.Name, t.Description, strconv.Itoa(t.DeploymentCnt)})
}
f.Printer.Table(header, rows)

return nil
}
2 changes: 2 additions & 0 deletions internal/cmd/template/template.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
templateDeployCmd "github.com/zeabur/cli/internal/cmd/template/deploy"
templateGetCmd "github.com/zeabur/cli/internal/cmd/template/get"
templateListCmd "github.com/zeabur/cli/internal/cmd/template/list"
templateSearchCmd "github.com/zeabur/cli/internal/cmd/template/search"
templateUpdateCmd "github.com/zeabur/cli/internal/cmd/template/update"
)

Expand All @@ -22,6 +23,7 @@ func NewCmdTemplate(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(templateListCmd.NewCmdList(f))
cmd.AddCommand(templateDeployCmd.NewCmdDeploy(f))
cmd.AddCommand(templateGetCmd.NewCmdGet(f))
cmd.AddCommand(templateSearchCmd.NewCmdSearch(f))
cmd.AddCommand(templateDeleteCmd.NewCmdDelete(f))
cmd.AddCommand(templateCreateCmd.NewCmdCreate(f))
cmd.AddCommand(templateUpdateCmd.NewCmdUpdate(f))
Expand Down