From e29e584142d8baf1dc3b087489c8cbae8b7169e0 Mon Sep 17 00:00:00 2001 From: isink17 <39876158+isink17@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:29:27 +0200 Subject: [PATCH] cli: add per-command help via registry (--help/-h and help ) --- internal/cli/app.go | 107 ++++++++++++++++++++++++++++++++++----- internal/cli/app_test.go | 60 ++++++++++++++++++++++ internal/cli/commands.go | 79 ++++++++++++++++++++++++++++- 3 files changed, 230 insertions(+), 16 deletions(-) diff --git a/internal/cli/app.go b/internal/cli/app.go index 1f28856..008171b 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -50,8 +50,8 @@ func (s *stringListFlag) Set(value string) error { func Run(ctx context.Context, args []string, stdout, stderr io.Writer) error { startupVersionCheck(ctx, stderr) - if len(args) == 0 { - printUsage(stdout) + if len(args) == 0 || isRootHelpFlag(args[0]) { + printRootHelp(stdout) return nil } @@ -67,7 +67,38 @@ func Run(ctx context.Context, args []string, stdout, stderr io.Writer) error { return fmt.Errorf("unknown command %q", args[0]) } - return cmd.run(ctx, globalCfg, stdout, stderr, args[1:]) + // Per-command help: `codegraph --help|-h`. + if hasHelpFlag(args[1:]) { + printCommandHelp(stdout, cmd) + return nil + } + + if err := cmd.run(ctx, globalCfg, stdout, stderr, args[1:]); err != nil { + // Treat user-invoked flag help as success (handlers may return flag.ErrHelp). + if errors.Is(err, flag.ErrHelp) { + return nil + } + return err + } + return nil +} + +func isRootHelpFlag(arg string) bool { + switch arg { + case "-h", "--help": + return true + default: + return false + } +} + +func hasHelpFlag(args []string) bool { + for _, a := range args { + if isRootHelpFlag(a) { + return true + } + } + return false } func runDoctor(stdout io.Writer, args []string) error { @@ -1133,18 +1164,66 @@ func runAffectedTests(ctx context.Context, cfg config.Config, stdout io.Writer, } func printUsage(w io.Writer) { - fmt.Fprintln(w, "codegraph commands:") - for _, cmd := range commandList { - lines := cmd.usageLines - if len(lines) == 0 { - lines = []string{" " + cmd.name} - } - for i, line := range lines { - if i == 0 && cmd.description != "" { - fmt.Fprintf(w, "%s - %s\n", line, cmd.description) - continue - } + printRootHelp(w) +} + +func printRootHelp(w io.Writer) { + fmt.Fprintf(w, "%s - local-first code context engine and MCP server\n\n", appname.BinaryName) + fmt.Fprintln(w, "Usage:") + fmt.Fprintf(w, " %s [args]\n", appname.BinaryName) + fmt.Fprintf(w, " %s --help\n", appname.BinaryName) + fmt.Fprintf(w, " %s help\n\n", appname.BinaryName) + + fmt.Fprintln(w, "Commands:") + for _, cmd := range commands() { + synopsis := cmd.name + if len(cmd.usageLines) > 0 { + synopsis = strings.TrimSpace(cmd.usageLines[0]) + } + if cmd.description != "" { + fmt.Fprintf(w, " %s - %s\n", synopsis, cmd.description) + } else { + fmt.Fprintf(w, " %s\n", synopsis) + } + } + + fmt.Fprintln(w, "\nExamples:") + fmt.Fprintf(w, " %s help index\n", appname.BinaryName) + fmt.Fprintf(w, " %s index .\n", appname.BinaryName) + fmt.Fprintf(w, " %s stats .\n", appname.BinaryName) + fmt.Fprintf(w, " %s serve --repo-root .\n", appname.BinaryName) +} + +func printCommandHelp(w io.Writer, cmd *command) { + fmt.Fprintf(w, "%s %s\n", appname.BinaryName, cmd.name) + if cmd.description != "" { + fmt.Fprintf(w, "%s\n", cmd.description) + } + + fmt.Fprintln(w, "\nUsage:") + if len(cmd.usageLines) > 0 { + for _, line := range cmd.usageLines { fmt.Fprintln(w, line) } + } else { + fmt.Fprintf(w, " %s %s\n", appname.BinaryName, cmd.name) + } + + if len(cmd.flags) > 0 { + fmt.Fprintln(w, "\nFlags:") + for _, f := range cmd.flags { + if f.description != "" { + fmt.Fprintf(w, " %s - %s\n", f.name, f.description) + } else { + fmt.Fprintf(w, " %s\n", f.name) + } + } + } + + if len(cmd.examples) > 0 { + fmt.Fprintln(w, "\nExamples:") + for _, ex := range cmd.examples { + fmt.Fprintf(w, " %s\n", ex) + } } } diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go index 184b541..2e3848e 100644 --- a/internal/cli/app_test.go +++ b/internal/cli/app_test.go @@ -232,6 +232,66 @@ func TestRunDoctorFix(t *testing.T) { } } +func TestRunRootHelp(t *testing.T) { + prev := startupVersionCheck + startupVersionCheck = func(context.Context, io.Writer) {} + t.Cleanup(func() { + startupVersionCheck = prev + }) + + for _, args := range [][]string{ + {}, + {"--help"}, + {"-h"}, + {"help"}, + } { + t.Run(strings.Join(append([]string{"root"}, args...), "_"), func(t *testing.T) { + var out bytes.Buffer + var errOut bytes.Buffer + if err := Run(context.Background(), args, &out, &errOut); err != nil { + t.Fatalf("Run(%v) error = %v", args, err) + } + if got := out.String(); !strings.Contains(got, "Usage:") || !strings.Contains(got, "Commands:") { + t.Fatalf("help output missing sections, output:\n%s", got) + } + }) + } +} + +func TestRunHelpCommandWithSubcommand(t *testing.T) { + prev := startupVersionCheck + startupVersionCheck = func(context.Context, io.Writer) {} + t.Cleanup(func() { + startupVersionCheck = prev + }) + + var out bytes.Buffer + var errOut bytes.Buffer + if err := Run(context.Background(), []string{"help", "index"}, &out, &errOut); err != nil { + t.Fatalf("Run(help index) error = %v", err) + } + if got := out.String(); !strings.Contains(got, "Usage:") || !strings.Contains(got, "index ") { + t.Fatalf("help index output unexpected, output:\n%s", got) + } +} + +func TestRunCommandHelpFlag(t *testing.T) { + prev := startupVersionCheck + startupVersionCheck = func(context.Context, io.Writer) {} + t.Cleanup(func() { + startupVersionCheck = prev + }) + + var out bytes.Buffer + var errOut bytes.Buffer + if err := Run(context.Background(), []string{"find-symbol", ".", "--help"}, &out, &errOut); err != nil { + t.Fatalf("Run(find-symbol --help) error = %v", err) + } + if got := out.String(); !strings.Contains(got, "Usage:") || !strings.Contains(got, "find-symbol ") { + t.Fatalf("find-symbol --help output unexpected, output:\n%s", got) + } +} + func TestParseBenchmarkMetrics(t *testing.T) { output := ` goos: windows diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 4a0ef76..57c492e 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "sync" "github.com/isink17/codegraph/internal/config" ) @@ -13,19 +14,40 @@ type command struct { aliases []string description string usageLines []string + flags []commandFlag + examples []string run func(context.Context, config.Config, io.Writer, io.Writer, []string) error } +type commandFlag struct { + name string + description string +} + var ( - commandList = newCommandList() - commandByName = newCommandRegistry(commandList) + commandInitOnce sync.Once + commandList []*command + commandByName map[string]*command ) func lookupCommand(name string) (*command, bool) { + ensureCommandsInit() c, ok := commandByName[name] return c, ok } +func commands() []*command { + ensureCommandsInit() + return commandList +} + +func ensureCommandsInit() { + commandInitOnce.Do(func() { + commandList = newCommandList() + commandByName = newCommandRegistry(commandList) + }) +} + func newCommandRegistry(cmds []*command) map[string]*command { reg := map[string]*command{} @@ -61,10 +83,34 @@ func registerCommand(reg map[string]*command, c *command) { func newCommandList() []*command { return []*command{ + { + name: "help", + description: "show help", + usageLines: []string{" help [command]"}, + examples: []string{ + "codegraph help", + "codegraph help index", + }, + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + if len(args) == 0 { + printRootHelp(stdout) + return nil + } + cmd, ok := lookupCommand(args[0]) + if !ok { + return fmt.Errorf("unknown command %q", args[0]) + } + printCommandHelp(stdout, cmd) + return nil + }, + }, { name: "install", description: "install codegraph", usageLines: []string{" install"}, + examples: []string{ + "codegraph install", + }, run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { return runInstall(stdout) }, @@ -73,6 +119,13 @@ func newCommandList() []*command { name: "index", description: "index a repository", usageLines: []string{" index "}, + flags: []commandFlag{ + {name: "--jsonl", description: "stream line-delimited JSON events"}, + }, + examples: []string{ + "codegraph index .", + "codegraph index . --jsonl", + }, run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { return runIndex(ctx, cfg, stdout, args, false) }, @@ -100,6 +153,13 @@ func newCommandList() []*command { name: "find-symbol", description: "find symbols by name", usageLines: []string{" find-symbol "}, + flags: []commandFlag{ + {name: "--limit", description: "limit results"}, + {name: "--offset", description: "offset into result set"}, + }, + examples: []string{ + "codegraph find-symbol . HelloWorld", + }, run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { return runQueryCommand(ctx, cfg, stdout, "find-symbol", args) }, @@ -108,6 +168,14 @@ func newCommandList() []*command { name: "callers", description: "find callers of a symbol", usageLines: []string{" callers --symbol "}, + flags: []commandFlag{ + {name: "--symbol", description: "symbol name to query (required)"}, + {name: "--limit", description: "limit results"}, + {name: "--offset", description: "offset into result set"}, + }, + examples: []string{ + "codegraph callers . --symbol HelloWorld", + }, run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { return runQueryCommand(ctx, cfg, stdout, "callers", args) }, @@ -197,6 +265,13 @@ func newCommandList() []*command { name: "clean", description: "clean index data", usageLines: []string{" clean [repo-path] [--vacuum]"}, + flags: []commandFlag{ + {name: "--vacuum", description: "VACUUM the database after cleanup"}, + }, + examples: []string{ + "codegraph clean .", + "codegraph clean . --vacuum", + }, run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { return runClean(ctx, cfg, stdout, args) },