From eae92203ed1f4246541f57d707754b17d6ce34f7 Mon Sep 17 00:00:00 2001 From: yuaanlin Date: Wed, 18 Feb 2026 12:03:20 +0800 Subject: [PATCH 1/5] feat(template): add --raw flag to output YAML spec Allow users to export raw YAML spec from a template so they can save it locally, modify it, and redeploy with `template deploy`. Co-Authored-By: Claude Opus 4.6 --- internal/cmd/template/get/get.go | 4 ++++ pkg/model/template.go | 1 + 2 files changed, 5 insertions(+) diff --git a/internal/cmd/template/get/get.go b/internal/cmd/template/get/get.go index e8e35c4..5550981 100644 --- a/internal/cmd/template/get/get.go +++ b/internal/cmd/template/get/get.go @@ -12,6 +12,7 @@ import ( type Options struct { code string + raw bool } func NewCmdGet(f *cmdutil.Factory) *cobra.Command { @@ -26,6 +27,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 } @@ -81,6 +83,8 @@ func getTemplate(f *cmdutil.Factory, opts Options) error { if template == nil || template.Code == "" { fmt.Println("Template not found") + } else if opts.raw { + fmt.Print(template.RawSpecYaml) } else { f.Printer.Table([]string{"Code", "Name", "Description"}, [][]string{{template.Code, template.Name, template.Description}}) } diff --git a/pkg/model/template.go b/pkg/model/template.go index f11c685..6fd8b9d 100644 --- a/pkg/model/template.go +++ b/pkg/model/template.go @@ -15,6 +15,7 @@ type Template struct { PreviewURL string `graphql:"previewURL"` Readme string `graphql:"readme"` Tags []string `graphql:"tags"` + RawSpecYaml string `graphql:"rawSpecYaml"` } type TemplateConnection struct { From efd2b7a34a5db0b324af0693241d1f82b5d4b22a Mon Sep 17 00:00:00 2001 From: yuaanlin Date: Wed, 18 Feb 2026 12:15:46 +0800 Subject: [PATCH 2/5] fix(template): fetch raw YAML from URL instead of GraphQL The Template GraphQL type doesn't have a rawSpecYaml field. Fetch raw YAML from https://zeabur.com/templates/{code}.yaml instead. Also skip interactive prompt when --code flag is already provided. Co-Authored-By: Claude Opus 4.6 --- internal/cmd/template/get/get.go | 42 ++++++++++++++++++++++---------- pkg/model/template.go | 1 - 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/internal/cmd/template/get/get.go b/internal/cmd/template/get/get.go index 5550981..459c2f8 100644 --- a/internal/cmd/template/get/get.go +++ b/internal/cmd/template/get/get.go @@ -3,6 +3,9 @@ package get import ( "context" "fmt" + "io" + "net/http" + "os" "github.com/briandowns/spinner" "github.com/spf13/cobra" @@ -40,19 +43,15 @@ 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 - } - - opts.code = code - - err = getTemplate(f, opts) - if err != nil { - return err + if opts.code == "" { + code, err := f.Prompter.Input("Template Code: ", "") + if err != nil { + return err + } + opts.code = code } - return nil + return getTemplate(f, opts) } func runGetNonInteractive(f *cmdutil.Factory, opts Options) error { @@ -70,6 +69,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..."), @@ -83,8 +86,6 @@ func getTemplate(f *cmdutil.Factory, opts Options) error { if template == nil || template.Code == "" { fmt.Println("Template not found") - } else if opts.raw { - fmt.Print(template.RawSpecYaml) } else { f.Printer.Table([]string{"Code", "Name", "Description"}, [][]string{{template.Code, template.Name, template.Description}}) } @@ -92,6 +93,21 @@ func getTemplate(f *cmdutil.Factory, opts Options) error { return nil } +func getTemplateRaw(code string) error { + resp, err := http.Get("https://zeabur.com/templates/" + code + ".yaml") + 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") diff --git a/pkg/model/template.go b/pkg/model/template.go index 6fd8b9d..f11c685 100644 --- a/pkg/model/template.go +++ b/pkg/model/template.go @@ -15,7 +15,6 @@ type Template struct { PreviewURL string `graphql:"previewURL"` Readme string `graphql:"readme"` Tags []string `graphql:"tags"` - RawSpecYaml string `graphql:"rawSpecYaml"` } type TemplateConnection struct { From 248de2c791d6364e9e327cb1ea3c4fd0f5a62bf1 Mon Sep 17 00:00:00 2001 From: yuaanlin Date: Wed, 18 Feb 2026 12:26:59 +0800 Subject: [PATCH 3/5] feat: add template search, help --all, and CLAUDE.md - Add `template search` command to filter templates by keyword and sort by deployment count - Add `help --all` flag to print all commands and flags at once - Add CLAUDE.md with project conventions for AI-assisted development - Fix interactive prompt skipping when --code flag is provided Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 28 ++++++++ internal/cmd/help/help.go | 76 +++++++++++++++++++++ internal/cmd/root/root.go | 4 ++ internal/cmd/template/search/search.go | 93 ++++++++++++++++++++++++++ internal/cmd/template/template.go | 2 + 5 files changed, 203 insertions(+) create mode 100644 CLAUDE.md create mode 100644 internal/cmd/help/help.go create mode 100644 internal/cmd/template/search/search.go diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d0b9e50 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,28 @@ +# Zeabur CLI - Development Notes + +## Build & Test +- Build: `go build ./...` +- Run: `go run ./cmd/main.go ` +- Test: `go test ./...` + +## Project Structure +- `cmd/main.go` — entry point +- `internal/cmd//` — 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///` +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///.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/` diff --git a/internal/cmd/help/help.go b/internal/cmd/help/help.go new file mode 100644 index 0000000..243e0cb --- /dev/null +++ b/internal/cmd/help/help.go @@ -0,0 +1,76 @@ +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() +} diff --git a/internal/cmd/root/root.go b/internal/cmd/root/root.go index 197d660..55f2dc0 100644 --- a/internal/cmd/root/root.go +++ b/internal/cmd/root/root.go @@ -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" @@ -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 } diff --git a/internal/cmd/template/search/search.go b/internal/cmd/template/search/search.go new file mode 100644 index 0000000..87bea8e --- /dev/null +++ b/internal/cmd/template/search/search.go @@ -0,0 +1,93 @@ +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) + }, + } + + cmd.Flags().StringVarP(&opts.keyword, "keyword", "k", "", "Keyword to search in template name and description") + + 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") + } + } + + 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 +} diff --git a/internal/cmd/template/template.go b/internal/cmd/template/template.go index 4a68bdc..f19557b 100644 --- a/internal/cmd/template/template.go +++ b/internal/cmd/template/template.go @@ -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" ) @@ -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)) From e7c6218f9380a4f5faec55bedf7c6325b62d1387 Mon Sep 17 00:00:00 2001 From: yuaanlin Date: Wed, 18 Feb 2026 12:29:53 +0800 Subject: [PATCH 4/5] fix(template): add timeout and URL escape for raw YAML fetch Co-Authored-By: Claude Opus 4.6 --- internal/cmd/template/get/get.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/internal/cmd/template/get/get.go b/internal/cmd/template/get/get.go index 459c2f8..0bc7f6b 100644 --- a/internal/cmd/template/get/get.go +++ b/internal/cmd/template/get/get.go @@ -5,7 +5,9 @@ import ( "fmt" "io" "net/http" + "net/url" "os" + "time" "github.com/briandowns/spinner" "github.com/spf13/cobra" @@ -94,7 +96,14 @@ func getTemplate(f *cmdutil.Factory, opts Options) error { } func getTemplateRaw(code string) error { - resp, err := http.Get("https://zeabur.com/templates/" + code + ".yaml") + 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) } From 91579e602c2298433890c5cb432cd5157cfcbc18 Mon Sep 17 00:00:00 2001 From: yuaanlin Date: Wed, 18 Feb 2026 13:02:45 +0800 Subject: [PATCH 5/5] fix: address review comments - Validate empty input in interactive mode for template get - Only print blank line after flags in help --all - Remove --keyword flag from search, use only positional argument Co-Authored-By: Claude Opus 4.6 --- internal/cmd/help/help.go | 3 +-- internal/cmd/template/get/get.go | 3 +++ internal/cmd/template/search/search.go | 2 -- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/cmd/help/help.go b/internal/cmd/help/help.go index 243e0cb..af89dbf 100644 --- a/internal/cmd/help/help.go +++ b/internal/cmd/help/help.go @@ -70,7 +70,6 @@ func printFlags(cmd *cobra.Command, fullName string) { if len(flags) > 0 { fmt.Println(strings.Join(flags, "\n")) + fmt.Println() } - - fmt.Println() } diff --git a/internal/cmd/template/get/get.go b/internal/cmd/template/get/get.go index 0bc7f6b..f0330fb 100644 --- a/internal/cmd/template/get/get.go +++ b/internal/cmd/template/get/get.go @@ -53,6 +53,9 @@ func runGetInteractive(f *cmdutil.Factory, opts Options) error { opts.code = code } + if err := paramCheck(opts); err != nil { + return err + } return getTemplate(f, opts) } diff --git a/internal/cmd/template/search/search.go b/internal/cmd/template/search/search.go index 87bea8e..beab292 100644 --- a/internal/cmd/template/search/search.go +++ b/internal/cmd/template/search/search.go @@ -33,8 +33,6 @@ func NewCmdSearch(f *cmdutil.Factory) *cobra.Command { }, } - cmd.Flags().StringVarP(&opts.keyword, "keyword", "k", "", "Keyword to search in template name and description") - return cmd }