Skip to content

Commit 7f4686e

Browse files
authored
feat(template): add search, get --raw, and help --all (#170)
## Summary - Add `template get --raw` flag to export raw YAML spec from `https://zeabur.com/templates/{code}.yaml`, so users can save, modify, and redeploy templates - Add `template search` command to filter templates by keyword (name/description) and sort by deployment count - Add `help --all` flag to print all commands and flags at once, making it easy for AI agents to discover available CLI features - Fix `template get` interactive prompt not being skipped when `--code` flag is already provided - Add `CLAUDE.md` with project conventions for AI-assisted development ## Usage ```bash # Export raw YAML to a file zeabur template get -c GSQQIJ --raw > my-template.yaml # Search templates by keyword zeabur template search wordpress # Show all commands and flags zeabur help --all ``` ## Test plan - [ ] `zeabur template get -c <code>` — shows table as before - [ ] `zeabur template get -c <code> --raw` — outputs raw YAML - [ ] `zeabur template search <keyword>` — filters and sorts by deployment count - [ ] `zeabur help --all` — prints all commands with flags - [ ] `zeabur help` — default help still works 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added `--raw` to view templates as streamed raw YAML. * Added template search to find and list templates by keyword. * Replaced default help with a custom help command and new `--all` option to print all commands and flags. * **Usability** * Template Code prompt now only appears when no code is provided. * **Documentation** * Added developer notes documenting project conventions and workflows. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
2 parents 7d8d444 + 91579e6 commit 7f4686e

6 files changed

Lines changed: 241 additions & 9 deletions

File tree

CLAUDE.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Zeabur CLI - Development Notes
2+
3+
## Build & Test
4+
- Build: `go build ./...`
5+
- Run: `go run ./cmd/main.go <command>`
6+
- Test: `go test ./...`
7+
8+
## Project Structure
9+
- `cmd/main.go` — entry point
10+
- `internal/cmd/<command>/` — each CLI command in its own package
11+
- `internal/cmdutil/` — shared command utilities (Factory, auth checks, spinner config)
12+
- `pkg/api/` — GraphQL API client
13+
- `pkg/model/` — data models (GraphQL struct tags)
14+
- `internal/cmd/root/root.go` — root command, registers all subcommands
15+
16+
## Important: Keep `help --all` in sync
17+
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.
18+
19+
However, when adding a **new subcommand**, you must:
20+
1. Create the command package under `internal/cmd/<parent>/<new>/`
21+
2. Register it in the parent command file (e.g., `internal/cmd/template/template.go`)
22+
23+
## Conventions
24+
- Each subcommand lives in its own package: `internal/cmd/<parent>/<sub>/<sub>.go`
25+
- Commands support both interactive and non-interactive modes; if a flag is provided, skip the interactive prompt
26+
- Use `cmdutil.SpinnerCharSet`, `cmdutil.SpinnerInterval`, `cmdutil.SpinnerColor` for spinners
27+
- Models in `pkg/model/` use `graphql:"fieldName"` struct tags — only add fields that exist in the backend GraphQL schema
28+
- Backend GraphQL schema lives in `../backend/internal/gateway/graphql/`

internal/cmd/help/help.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package help
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"github.com/spf13/cobra"
8+
"github.com/spf13/pflag"
9+
)
10+
11+
func NewCmdHelp(rootCmd *cobra.Command) *cobra.Command {
12+
var all bool
13+
14+
cmd := &cobra.Command{
15+
Use: "help [command]",
16+
Short: "Help about any command",
17+
RunE: func(cmd *cobra.Command, args []string) error {
18+
if all {
19+
printAllCommands(rootCmd, "")
20+
return nil
21+
}
22+
23+
// default: find the target command and show its help
24+
target, _, err := rootCmd.Find(args)
25+
if err != nil {
26+
return err
27+
}
28+
return target.Help()
29+
},
30+
}
31+
32+
cmd.Flags().BoolVar(&all, "all", false, "Show all commands with their flags")
33+
34+
return cmd
35+
}
36+
37+
func printAllCommands(cmd *cobra.Command, prefix string) {
38+
fullName := prefix + cmd.Name()
39+
40+
if cmd.Runnable() || len(cmd.Commands()) == 0 {
41+
fmt.Printf("%s - %s\n", fullName, cmd.Short)
42+
printFlags(cmd, fullName)
43+
}
44+
45+
for _, child := range cmd.Commands() {
46+
if child.Hidden || child.Name() == "help" {
47+
continue
48+
}
49+
printAllCommands(child, fullName+" ")
50+
}
51+
}
52+
53+
func printFlags(cmd *cobra.Command, fullName string) {
54+
var flags []string
55+
56+
cmd.LocalFlags().VisitAll(func(f *pflag.Flag) {
57+
if f.Hidden {
58+
return
59+
}
60+
entry := " --" + f.Name
61+
if f.Shorthand != "" {
62+
entry = " -" + f.Shorthand + ", --" + f.Name
63+
}
64+
if f.DefValue != "" && f.DefValue != "false" {
65+
entry += fmt.Sprintf(" (default: %s)", f.DefValue)
66+
}
67+
entry += " " + f.Usage
68+
flags = append(flags, entry)
69+
})
70+
71+
if len(flags) > 0 {
72+
fmt.Println(strings.Join(flags, "\n"))
73+
fmt.Println()
74+
}
75+
}

internal/cmd/root/root.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010

1111
authCmd "github.com/zeabur/cli/internal/cmd/auth"
1212
completionCmd "github.com/zeabur/cli/internal/cmd/completion"
13+
helpCmd "github.com/zeabur/cli/internal/cmd/help"
1314
contextCmd "github.com/zeabur/cli/internal/cmd/context"
1415
deployCmd "github.com/zeabur/cli/internal/cmd/deploy"
1516
deploymentCmd "github.com/zeabur/cli/internal/cmd/deployment"
@@ -129,5 +130,8 @@ func NewCmdRoot(f *cmdutil.Factory, version, commit, date string) (*cobra.Comman
129130
cmd.AddCommand(completionCmd.NewCmdCompletion(f))
130131
cmd.AddCommand(variableCmd.NewCmdVariable(f))
131132

133+
// replace default help command with our custom one that supports --all
134+
cmd.SetHelpCommand(helpCmd.NewCmdHelp(cmd))
135+
132136
return cmd, nil
133137
}

internal/cmd/template/get/get.go

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ package get
33
import (
44
"context"
55
"fmt"
6+
"io"
7+
"net/http"
8+
"net/url"
9+
"os"
10+
"time"
611

712
"github.com/briandowns/spinner"
813
"github.com/spf13/cobra"
@@ -12,6 +17,7 @@ import (
1217

1318
type Options struct {
1419
code string
20+
raw bool
1521
}
1622

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

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

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

4047
func runGetInteractive(f *cmdutil.Factory, opts Options) error {
41-
code, err := f.Prompter.Input("Template Code: ", "")
42-
if err != nil {
43-
return err
48+
if opts.code == "" {
49+
code, err := f.Prompter.Input("Template Code: ", "")
50+
if err != nil {
51+
return err
52+
}
53+
opts.code = code
4454
}
4555

46-
opts.code = code
47-
48-
err = getTemplate(f, opts)
49-
if err != nil {
56+
if err := paramCheck(opts); err != nil {
5057
return err
5158
}
52-
53-
return nil
59+
return getTemplate(f, opts)
5460
}
5561

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

7076
func getTemplate(f *cmdutil.Factory, opts Options) error {
77+
if opts.raw {
78+
return getTemplateRaw(opts.code)
79+
}
80+
7181
s := spinner.New(cmdutil.SpinnerCharSet, cmdutil.SpinnerInterval,
7282
spinner.WithColor(cmdutil.SpinnerColor),
7383
spinner.WithSuffix(" Fetching template..."),
@@ -88,6 +98,28 @@ func getTemplate(f *cmdutil.Factory, opts Options) error {
8898
return nil
8999
}
90100

101+
func getTemplateRaw(code string) error {
102+
u := "https://zeabur.com/templates/" + url.PathEscape(code) + ".yaml"
103+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
104+
defer cancel()
105+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
106+
if err != nil {
107+
return fmt.Errorf("failed to build request: %w", err)
108+
}
109+
resp, err := http.DefaultClient.Do(req)
110+
if err != nil {
111+
return fmt.Errorf("failed to fetch template YAML: %w", err)
112+
}
113+
defer resp.Body.Close()
114+
115+
if resp.StatusCode != http.StatusOK {
116+
return fmt.Errorf("template not found (HTTP %d)", resp.StatusCode)
117+
}
118+
119+
_, err = io.Copy(os.Stdout, resp.Body)
120+
return err
121+
}
122+
91123
func paramCheck(opts Options) error {
92124
if opts.code == "" {
93125
return fmt.Errorf("template code is required")
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package search
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"sort"
7+
"strconv"
8+
"strings"
9+
10+
"github.com/briandowns/spinner"
11+
"github.com/spf13/cobra"
12+
13+
"github.com/zeabur/cli/internal/cmdutil"
14+
"github.com/zeabur/cli/pkg/model"
15+
)
16+
17+
type Options struct {
18+
keyword string
19+
}
20+
21+
func NewCmdSearch(f *cmdutil.Factory) *cobra.Command {
22+
opts := Options{}
23+
24+
cmd := &cobra.Command{
25+
Use: "search [keyword]",
26+
Short: "Search templates by keyword",
27+
Args: cobra.MaximumNArgs(1),
28+
RunE: func(cmd *cobra.Command, args []string) error {
29+
if len(args) > 0 {
30+
opts.keyword = args[0]
31+
}
32+
return runSearch(f, opts)
33+
},
34+
}
35+
36+
return cmd
37+
}
38+
39+
func runSearch(f *cmdutil.Factory, opts Options) error {
40+
if opts.keyword == "" {
41+
if f.Interactive {
42+
keyword, err := f.Prompter.Input("Search keyword: ", "")
43+
if err != nil {
44+
return err
45+
}
46+
opts.keyword = keyword
47+
} else {
48+
return fmt.Errorf("keyword is required")
49+
}
50+
}
51+
52+
s := spinner.New(cmdutil.SpinnerCharSet, cmdutil.SpinnerInterval,
53+
spinner.WithColor(cmdutil.SpinnerColor),
54+
spinner.WithSuffix(" Searching templates..."),
55+
)
56+
s.Start()
57+
allTemplates, err := f.ApiClient.ListAllTemplates(context.Background())
58+
if err != nil {
59+
s.Stop()
60+
return err
61+
}
62+
s.Stop()
63+
64+
keyword := strings.ToLower(opts.keyword)
65+
var matched model.Templates
66+
for _, t := range allTemplates {
67+
name := strings.ToLower(t.Name)
68+
desc := strings.ToLower(t.Description)
69+
if strings.Contains(name, keyword) || strings.Contains(desc, keyword) {
70+
matched = append(matched, t)
71+
}
72+
}
73+
74+
sort.Slice(matched, func(i, j int) bool {
75+
return matched[i].DeploymentCnt > matched[j].DeploymentCnt
76+
})
77+
78+
if len(matched) == 0 {
79+
fmt.Println("No templates found")
80+
return nil
81+
}
82+
83+
header := []string{"Code", "Name", "Description", "Deployments"}
84+
rows := make([][]string, 0, len(matched))
85+
for _, t := range matched {
86+
rows = append(rows, []string{t.Code, t.Name, t.Description, strconv.Itoa(t.DeploymentCnt)})
87+
}
88+
f.Printer.Table(header, rows)
89+
90+
return nil
91+
}

internal/cmd/template/template.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
templateDeployCmd "github.com/zeabur/cli/internal/cmd/template/deploy"
1111
templateGetCmd "github.com/zeabur/cli/internal/cmd/template/get"
1212
templateListCmd "github.com/zeabur/cli/internal/cmd/template/list"
13+
templateSearchCmd "github.com/zeabur/cli/internal/cmd/template/search"
1314
templateUpdateCmd "github.com/zeabur/cli/internal/cmd/template/update"
1415
)
1516

@@ -22,6 +23,7 @@ func NewCmdTemplate(f *cmdutil.Factory) *cobra.Command {
2223
cmd.AddCommand(templateListCmd.NewCmdList(f))
2324
cmd.AddCommand(templateDeployCmd.NewCmdDeploy(f))
2425
cmd.AddCommand(templateGetCmd.NewCmdGet(f))
26+
cmd.AddCommand(templateSearchCmd.NewCmdSearch(f))
2527
cmd.AddCommand(templateDeleteCmd.NewCmdDelete(f))
2628
cmd.AddCommand(templateCreateCmd.NewCmdCreate(f))
2729
cmd.AddCommand(templateUpdateCmd.NewCmdUpdate(f))

0 commit comments

Comments
 (0)