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
107 changes: 93 additions & 14 deletions internal/cli/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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 <command> --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
}
Comment on lines +95 to 102

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The hasHelpFlag function should respect the standard -- end-of-flags marker. Currently, it will trigger help even if -h or --help appears after a -- argument, which is typically used to indicate that subsequent arguments should be treated as positional rather than flags.

func hasHelpFlag(args []string) bool {
  for _, a := range args {
    if a == "--" {
      return false
    }
    if isRootHelpFlag(a) {
      return true
    }
  }
  return false
}


func runDoctor(stdout io.Writer, args []string) error {
Expand Down Expand Up @@ -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 <command> [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)
Comment on lines +1197 to +1198

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The command help output does not display command aliases. Since the command struct includes an aliases field, it would be helpful to show them to the user in the per-command help screen.

Suggested change
func printCommandHelp(w io.Writer, cmd *command) {
fmt.Fprintf(w, "%s %s\n", appname.BinaryName, cmd.name)
func printCommandHelp(w io.Writer, cmd *command) {
fmt.Fprintf(w, "%s %s", appname.BinaryName, cmd.name)
if len(cmd.aliases) > 0 {
fmt.Fprintf(w, " (%s)", strings.Join(cmd.aliases, ", "))
}
fmt.Fprintln(w)

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)
}
}
}
60 changes: 60 additions & 0 deletions internal/cli/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <repo-path>") {
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 <repo-path> <query>") {
t.Fatalf("find-symbol --help output unexpected, output:\n%s", got)
}
}

func TestParseBenchmarkMetrics(t *testing.T) {
output := `
goos: windows
Expand Down
79 changes: 77 additions & 2 deletions internal/cli/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"io"
"sync"

"github.com/isink17/codegraph/internal/config"
)
Expand All @@ -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{}

Expand Down Expand Up @@ -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)
},
Expand All @@ -73,6 +119,13 @@ func newCommandList() []*command {
name: "index",
description: "index a repository",
usageLines: []string{" index <repo-path>"},
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)
},
Expand Down Expand Up @@ -100,6 +153,13 @@ func newCommandList() []*command {
name: "find-symbol",
description: "find symbols by name",
usageLines: []string{" find-symbol <repo-path> <query>"},
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)
},
Expand All @@ -108,6 +168,14 @@ func newCommandList() []*command {
name: "callers",
description: "find callers of a symbol",
usageLines: []string{" callers <repo-path> --symbol <name>"},
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)
},
Expand Down Expand Up @@ -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)
},
Expand Down
Loading