From c883a274c612000e4743171796d5521b6e74c4f8 Mon Sep 17 00:00:00 2001 From: AnnatarHe Date: Sat, 3 Jan 2026 23:18:20 +0800 Subject: [PATCH 1/5] feat(cli): add rg/grep command for searching synced commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add new `shelltime rg` command (with `grep` alias) to search server-synced commands via GraphQL API. Supports table/JSON output and multiple filters: - shell, hostname, username - exit code, main command, session ID - time range (since/until) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- README.md | 1 + cmd/cli/main.go | 1 + commands/rg.go | 328 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 330 insertions(+) create mode 100644 commands/rg.go diff --git a/README.md b/README.md index 51a406f..5e54c82 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ shelltime daemon install # Optional: background sync for <8ms latency | Command | Description | |---------|-------------| | `shelltime sync` | Sync pending commands to server | +| `shelltime rg "pattern"` | Search synced commands (alias: `grep`) | | `shelltime q "prompt"` | AI-powered command suggestions | | `shelltime doctor` | Diagnose installation issues | | `shelltime web` | Open dashboard in browser | diff --git a/cmd/cli/main.go b/cmd/cli/main.go index daede09..252c0d3 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -109,6 +109,7 @@ func main() { commands.CCCommand, commands.CodexCommand, commands.SchemaCommand, + commands.RgCommand, } err = app.Run(os.Args) if err != nil { diff --git a/commands/rg.go b/commands/rg.go new file mode 100644 index 0000000..aecf40d --- /dev/null +++ b/commands/rg.go @@ -0,0 +1,328 @@ +package commands + +import ( + "encoding/json" + "fmt" + "os" + "strconv" + "time" + + "github.com/gookit/color" + "github.com/malamtime/cli/model" + "github.com/olekukonko/tablewriter" + "github.com/urfave/cli/v2" + "go.opentelemetry.io/otel/trace" +) + +// CommandEdge represents a single command from the server +type CommandEdge struct { + ID int `json:"id"` + Shell string `json:"shell"` + SessionID float64 `json:"sessionId"` + Command string `json:"command"` + MainCommand string `json:"mainCommand"` + Hostname string `json:"hostname"` + Username string `json:"username"` + Time float64 `json:"time"` + EndTime float64 `json:"endTime"` + Result int `json:"result"` + IsEncrypted bool `json:"isEncrypted"` + OriginalCommand string `json:"originalCommand"` +} + +// FetchCommandsData wraps the GraphQL data response +type FetchCommandsData struct { + FetchCommands struct { + Count int `json:"count"` + Edges []CommandEdge `json:"edges"` + } `json:"fetchCommands"` +} + +// FetchCommandsResponse is the complete GraphQL response +type FetchCommandsResponse = model.GraphQLResponse[FetchCommandsData] + +var RgCommand *cli.Command = &cli.Command{ + Name: "rg", + Aliases: []string{"grep"}, + Usage: "Search server-synced commands", + ArgsUsage: "", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "format", + Aliases: []string{"f"}, + Value: "table", + Usage: "output format (table/json)", + }, + &cli.IntFlag{ + Name: "limit", + Aliases: []string{"l"}, + Value: 50, + Usage: "maximum number of results", + }, + &cli.IntFlag{ + Name: "offset", + Aliases: []string{"o"}, + Value: 0, + Usage: "skip this many results (pagination)", + }, + &cli.StringFlag{ + Name: "shell", + Aliases: []string{"s"}, + Usage: "filter by shell (bash, zsh, fish)", + }, + &cli.StringFlag{ + Name: "hostname", + Aliases: []string{"H"}, + Usage: "filter by hostname", + }, + &cli.StringFlag{ + Name: "username", + Aliases: []string{"u"}, + Usage: "filter by username", + }, + &cli.IntFlag{ + Name: "result", + Aliases: []string{"r"}, + Value: -1, + Usage: "filter by exit code (-1 means any)", + }, + &cli.StringFlag{ + Name: "main-command", + Aliases: []string{"m"}, + Usage: "filter by main command (e.g., git, npm)", + }, + &cli.Int64Flag{ + Name: "session", + Value: -1, + Usage: "filter by session ID (-1 means any)", + }, + &cli.StringFlag{ + Name: "since", + Usage: "filter commands since time (RFC3339 format)", + }, + &cli.StringFlag{ + Name: "until", + Usage: "filter commands until time (RFC3339 format)", + }, + }, + Action: commandRg, + OnUsageError: func(cCtx *cli.Context, err error, isSubcommand bool) error { + color.Red.Println(err.Error()) + return nil + }, +} + +func commandRg(c *cli.Context) error { + ctx, span := commandTracer.Start(c.Context, "rg", trace.WithSpanKind(trace.SpanKindClient)) + defer span.End() + + SetupLogger(os.ExpandEnv("$HOME/" + model.COMMAND_BASE_STORAGE_FOLDER)) + + // Validate format + format := c.String("format") + if format != "table" && format != "json" { + return fmt.Errorf("unsupported format: %s. Use 'table' or 'json'", format) + } + + // Get search text from args + searchText := c.Args().First() + if searchText == "" { + return fmt.Errorf("search text is required. Usage: shelltime rg ") + } + + // Read config to get endpoint and token + cfg, err := configService.ReadConfigFile(ctx) + if err != nil { + return fmt.Errorf("failed to read config: %w", err) + } + + if cfg.Token == "" { + return fmt.Errorf("not authenticated. Please run 'shelltime auth' first") + } + + endpoint := model.Endpoint{ + APIEndpoint: cfg.APIEndpoint, + Token: cfg.Token, + } + + // Build filter + filter := buildCommandFilter(c, searchText) + + // Build pagination + pagination := map[string]interface{}{ + "limit": c.Int("limit"), + "offset": c.Int("offset"), + } + + // Build variables + variables := map[string]interface{}{ + "pagination": pagination, + "filter": filter, + } + + // GraphQL query + query := `query fetchCommands($pagination: InputPagination!, $filter: CommandFilter!) { + fetchCommands(pagination: $pagination, filter: $filter) { + count + edges { + id + shell + sessionId + command + mainCommand + hostname + username + time + endTime + result + isEncrypted + originalCommand + } + } + }` + + var result FetchCommandsResponse + err = model.SendGraphQLRequest(model.GraphQLRequestOptions[FetchCommandsResponse]{ + Context: ctx, + Endpoint: endpoint, + Query: query, + Variables: variables, + Response: &result, + Timeout: time.Second * 30, + }) + if err != nil { + return fmt.Errorf("failed to fetch commands: %w", err) + } + + commands := result.Data.FetchCommands.Edges + totalCount := result.Data.FetchCommands.Count + + if len(commands) == 0 { + color.Yellow.Println("No commands found matching your search") + return nil + } + + // Output based on format + if format == "json" { + return outputRgJSON(commands, totalCount) + } + return outputRgTable(commands, totalCount, c.Int("limit"), c.Int("offset")) +} + +func buildCommandFilter(c *cli.Context, searchText string) map[string]interface{} { + filter := map[string]interface{}{ + "shell": []string{}, + "sessionId": []float64{}, + "mainCommand": []string{}, + "hostname": []string{}, + "username": []string{}, + "ip": []string{}, + "result": []int{}, + "time": []float64{}, + "command": searchText, + } + + // Add optional filters if provided + if shell := c.String("shell"); shell != "" { + filter["shell"] = []string{shell} + } + + if hostname := c.String("hostname"); hostname != "" { + filter["hostname"] = []string{hostname} + } + + if username := c.String("username"); username != "" { + filter["username"] = []string{username} + } + + if result := c.Int("result"); result >= 0 { + filter["result"] = []int{result} + } + + if mainCmd := c.String("main-command"); mainCmd != "" { + filter["mainCommand"] = []string{mainCmd} + } + + if session := c.Int64("session"); session >= 0 { + filter["sessionId"] = []float64{float64(session)} + } + + // Handle time filters + var timeFilters []float64 + if since := c.String("since"); since != "" { + t, err := time.Parse(time.RFC3339, since) + if err == nil { + timeFilters = append(timeFilters, float64(t.UnixMilli())) + } + } + if until := c.String("until"); until != "" { + t, err := time.Parse(time.RFC3339, until) + if err == nil { + timeFilters = append(timeFilters, float64(t.UnixMilli())) + } + } + if len(timeFilters) > 0 { + filter["time"] = timeFilters + } + + return filter +} + +func outputRgJSON(commands []CommandEdge, totalCount int) error { + output := struct { + TotalCount int `json:"totalCount"` + Commands []CommandEdge `json:"commands"` + }{ + TotalCount: totalCount, + Commands: commands, + } + + jsonData, err := json.MarshalIndent(output, "", " ") + if err != nil { + return err + } + fmt.Println(string(jsonData)) + return nil +} + +func outputRgTable(commands []CommandEdge, totalCount, limit, offset int) error { + w := tablewriter.NewWriter(os.Stdout) + w.Header([]string{"COMMAND", "SHELL", "TIME", "END TIME", "DURATION(ms)", "STATUS", "USER", "HOST"}) + + for _, cmd := range commands { + // Use originalCommand if encrypted and available + displayCommand := cmd.Command + if cmd.IsEncrypted && cmd.OriginalCommand != "" { + displayCommand = cmd.OriginalCommand + } + + // Convert milliseconds to time + startTime := time.UnixMilli(int64(cmd.Time)) + endTime := time.UnixMilli(int64(cmd.EndTime)) + duration := int64(cmd.EndTime - cmd.Time) + + w.Append([]string{ + displayCommand, + cmd.Shell, + startTime.Format(time.RFC3339), + endTime.Format(time.RFC3339), + strconv.FormatInt(duration, 10), + strconv.Itoa(cmd.Result), + cmd.Username, + cmd.Hostname, + }) + } + + w.Render() + + // Show result count summary + showing := len(commands) + if totalCount > showing { + color.Gray.Printf("\nShowing %d of %d total results (offset: %d)\n", showing, totalCount, offset) + if offset+limit < totalCount { + color.Gray.Printf("Use --offset %d to see more results\n", offset+limit) + } + } + + return nil +} From 523ca672bcad508d065e478099adb7a26564bef0 Mon Sep 17 00:00:00 2001 From: AnnatarHe Date: Sat, 3 Jan 2026 23:37:51 +0800 Subject: [PATCH 2/5] refactor(cli): split grep command API into model package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move API types and FetchCommandsFromServer to model/command_search.go - Rename commands/rg.go to commands/grep.go - Remove sessionId from filter and output - Support flexible date formats for --since/--until (2024, 2024-01, 2024-01-15) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- cmd/cli/main.go | 2 +- commands/{rg.go => grep.go} | 199 +++++++++++++++--------------------- model/command_search.go | 97 ++++++++++++++++++ 3 files changed, 179 insertions(+), 119 deletions(-) rename commands/{rg.go => grep.go} (53%) create mode 100644 model/command_search.go diff --git a/cmd/cli/main.go b/cmd/cli/main.go index 252c0d3..d9ffc8b 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -109,7 +109,7 @@ func main() { commands.CCCommand, commands.CodexCommand, commands.SchemaCommand, - commands.RgCommand, + commands.GrepCommand, } err = app.Run(os.Args) if err != nil { diff --git a/commands/rg.go b/commands/grep.go similarity index 53% rename from commands/rg.go rename to commands/grep.go index aecf40d..1dc25a1 100644 --- a/commands/rg.go +++ b/commands/grep.go @@ -14,34 +14,7 @@ import ( "go.opentelemetry.io/otel/trace" ) -// CommandEdge represents a single command from the server -type CommandEdge struct { - ID int `json:"id"` - Shell string `json:"shell"` - SessionID float64 `json:"sessionId"` - Command string `json:"command"` - MainCommand string `json:"mainCommand"` - Hostname string `json:"hostname"` - Username string `json:"username"` - Time float64 `json:"time"` - EndTime float64 `json:"endTime"` - Result int `json:"result"` - IsEncrypted bool `json:"isEncrypted"` - OriginalCommand string `json:"originalCommand"` -} - -// FetchCommandsData wraps the GraphQL data response -type FetchCommandsData struct { - FetchCommands struct { - Count int `json:"count"` - Edges []CommandEdge `json:"edges"` - } `json:"fetchCommands"` -} - -// FetchCommandsResponse is the complete GraphQL response -type FetchCommandsResponse = model.GraphQLResponse[FetchCommandsData] - -var RgCommand *cli.Command = &cli.Command{ +var GrepCommand *cli.Command = &cli.Command{ Name: "rg", Aliases: []string{"grep"}, Usage: "Search server-synced commands", @@ -91,29 +64,24 @@ var RgCommand *cli.Command = &cli.Command{ Aliases: []string{"m"}, Usage: "filter by main command (e.g., git, npm)", }, - &cli.Int64Flag{ - Name: "session", - Value: -1, - Usage: "filter by session ID (-1 means any)", - }, &cli.StringFlag{ Name: "since", - Usage: "filter commands since time (RFC3339 format)", + Usage: "filter commands since date (2024, 2024-01, or 2024-01-15)", }, &cli.StringFlag{ Name: "until", - Usage: "filter commands until time (RFC3339 format)", + Usage: "filter commands until date (2024, 2024-01, or 2024-01-15)", }, }, - Action: commandRg, + Action: commandGrep, OnUsageError: func(cCtx *cli.Context, err error, isSubcommand bool) error { color.Red.Println(err.Error()) return nil }, } -func commandRg(c *cli.Context) error { - ctx, span := commandTracer.Start(c.Context, "rg", trace.WithSpanKind(trace.SpanKindClient)) +func commandGrep(c *cli.Context) error { + ctx, span := commandTracer.Start(c.Context, "grep", trace.WithSpanKind(trace.SpanKindClient)) defer span.End() SetupLogger(os.ExpandEnv("$HOME/" + model.COMMAND_BASE_STORAGE_FOLDER)) @@ -127,7 +95,7 @@ func commandRg(c *cli.Context) error { // Get search text from args searchText := c.Args().First() if searchText == "" { - return fmt.Errorf("search text is required. Usage: shelltime rg ") + return fmt.Errorf("search text is required. Usage: shelltime grep ") } // Read config to get endpoint and token @@ -146,132 +114,127 @@ func commandRg(c *cli.Context) error { } // Build filter - filter := buildCommandFilter(c, searchText) - - // Build pagination - pagination := map[string]interface{}{ - "limit": c.Int("limit"), - "offset": c.Int("offset"), + filter, err := buildGrepFilter(c, searchText) + if err != nil { + return err } - // Build variables - variables := map[string]interface{}{ - "pagination": pagination, - "filter": filter, + // Build pagination + pagination := &model.SearchCommandsPagination{ + Limit: c.Int("limit"), + Offset: c.Int("offset"), } - // GraphQL query - query := `query fetchCommands($pagination: InputPagination!, $filter: CommandFilter!) { - fetchCommands(pagination: $pagination, filter: $filter) { - count - edges { - id - shell - sessionId - command - mainCommand - hostname - username - time - endTime - result - isEncrypted - originalCommand - } - } - }` - - var result FetchCommandsResponse - err = model.SendGraphQLRequest(model.GraphQLRequestOptions[FetchCommandsResponse]{ - Context: ctx, - Endpoint: endpoint, - Query: query, - Variables: variables, - Response: &result, - Timeout: time.Second * 30, - }) + // Fetch commands from server + result, err := model.FetchCommandsFromServer(ctx, endpoint, filter, pagination) if err != nil { return fmt.Errorf("failed to fetch commands: %w", err) } - commands := result.Data.FetchCommands.Edges - totalCount := result.Data.FetchCommands.Count - - if len(commands) == 0 { + if len(result.Edges) == 0 { color.Yellow.Println("No commands found matching your search") return nil } // Output based on format if format == "json" { - return outputRgJSON(commands, totalCount) + return outputGrepJSON(result.Edges, result.Count) } - return outputRgTable(commands, totalCount, c.Int("limit"), c.Int("offset")) + return outputGrepTable(result.Edges, result.Count, c.Int("limit"), c.Int("offset")) } -func buildCommandFilter(c *cli.Context, searchText string) map[string]interface{} { - filter := map[string]interface{}{ - "shell": []string{}, - "sessionId": []float64{}, - "mainCommand": []string{}, - "hostname": []string{}, - "username": []string{}, - "ip": []string{}, - "result": []int{}, - "time": []float64{}, - "command": searchText, +func buildGrepFilter(c *cli.Context, searchText string) (*model.SearchCommandsFilter, error) { + filter := &model.SearchCommandsFilter{ + Shell: []string{}, + MainCommand: []string{}, + Hostname: []string{}, + Username: []string{}, + IP: []string{}, + Result: []int{}, + Time: []float64{}, + SessionID: []float64{}, + Command: searchText, } // Add optional filters if provided if shell := c.String("shell"); shell != "" { - filter["shell"] = []string{shell} + filter.Shell = []string{shell} } if hostname := c.String("hostname"); hostname != "" { - filter["hostname"] = []string{hostname} + filter.Hostname = []string{hostname} } if username := c.String("username"); username != "" { - filter["username"] = []string{username} + filter.Username = []string{username} } if result := c.Int("result"); result >= 0 { - filter["result"] = []int{result} + filter.Result = []int{result} } if mainCmd := c.String("main-command"); mainCmd != "" { - filter["mainCommand"] = []string{mainCmd} + filter.MainCommand = []string{mainCmd} } - if session := c.Int64("session"); session >= 0 { - filter["sessionId"] = []float64{float64(session)} - } - - // Handle time filters + // Handle time filters with flexible date parsing var timeFilters []float64 if since := c.String("since"); since != "" { - t, err := time.Parse(time.RFC3339, since) - if err == nil { - timeFilters = append(timeFilters, float64(t.UnixMilli())) + t, err := parseFlexibleDate(since, false) + if err != nil { + return nil, fmt.Errorf("invalid --since date: %w", err) } + timeFilters = append(timeFilters, float64(t.UnixMilli())) } if until := c.String("until"); until != "" { - t, err := time.Parse(time.RFC3339, until) - if err == nil { - timeFilters = append(timeFilters, float64(t.UnixMilli())) + t, err := parseFlexibleDate(until, true) + if err != nil { + return nil, fmt.Errorf("invalid --until date: %w", err) } + timeFilters = append(timeFilters, float64(t.UnixMilli())) } if len(timeFilters) > 0 { - filter["time"] = timeFilters + filter.Time = timeFilters + } + + return filter, nil +} + +// parseFlexibleDate parses dates in formats: 2024, 2024-01, 2024-01-15 +// If isEndOfPeriod is true, returns end of the period (for --until) +func parseFlexibleDate(s string, isEndOfPeriod bool) (time.Time, error) { + // Try year only: 2024 + if t, err := time.Parse("2006", s); err == nil { + if isEndOfPeriod { + return time.Date(t.Year(), 12, 31, 23, 59, 59, 0, time.UTC), nil + } + return time.Date(t.Year(), 1, 1, 0, 0, 0, 0, time.UTC), nil + } + + // Try year-month: 2024-01 + if t, err := time.Parse("2006-01", s); err == nil { + if isEndOfPeriod { + // End of month: go to next month, then subtract 1 second + return t.AddDate(0, 1, 0).Add(-time.Second), nil + } + return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, time.UTC), nil + } + + // Try year-month-day: 2024-01-15 + if t, err := time.Parse("2006-01-02", s); err == nil { + if isEndOfPeriod { + return time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 0, time.UTC), nil + } + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC), nil } - return filter + return time.Time{}, fmt.Errorf("use format: 2024, 2024-01, or 2024-01-15") } -func outputRgJSON(commands []CommandEdge, totalCount int) error { +func outputGrepJSON(commands []model.SearchCommandEdge, totalCount int) error { output := struct { - TotalCount int `json:"totalCount"` - Commands []CommandEdge `json:"commands"` + TotalCount int `json:"totalCount"` + Commands []model.SearchCommandEdge `json:"commands"` }{ TotalCount: totalCount, Commands: commands, @@ -285,7 +248,7 @@ func outputRgJSON(commands []CommandEdge, totalCount int) error { return nil } -func outputRgTable(commands []CommandEdge, totalCount, limit, offset int) error { +func outputGrepTable(commands []model.SearchCommandEdge, totalCount, limit, offset int) error { w := tablewriter.NewWriter(os.Stdout) w.Header([]string{"COMMAND", "SHELL", "TIME", "END TIME", "DURATION(ms)", "STATUS", "USER", "HOST"}) diff --git a/model/command_search.go b/model/command_search.go new file mode 100644 index 0000000..40c6792 --- /dev/null +++ b/model/command_search.go @@ -0,0 +1,97 @@ +package model + +import ( + "context" + "time" +) + +// SearchCommandEdge represents a command from server search +type SearchCommandEdge struct { + ID int `json:"id"` + Shell string `json:"shell"` + Command string `json:"command"` + MainCommand string `json:"mainCommand"` + Hostname string `json:"hostname"` + Username string `json:"username"` + Time float64 `json:"time"` + EndTime float64 `json:"endTime"` + Result int `json:"result"` + IsEncrypted bool `json:"isEncrypted"` + OriginalCommand string `json:"originalCommand"` +} + +// SearchCommandsFilter for filtering search results +type SearchCommandsFilter struct { + Shell []string `json:"shell"` + MainCommand []string `json:"mainCommand"` + Hostname []string `json:"hostname"` + Username []string `json:"username"` + IP []string `json:"ip"` + Result []int `json:"result"` + Time []float64 `json:"time"` + SessionID []float64 `json:"sessionId"` + Command string `json:"command,omitempty"` +} + +// SearchCommandsPagination for pagination +type SearchCommandsPagination struct { + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +// SearchCommandsResult wraps the response +type SearchCommandsResult struct { + Count int `json:"count"` + Edges []SearchCommandEdge `json:"edges"` +} + +// fetchCommandsData wraps the GraphQL data response +type fetchCommandsData struct { + FetchCommands SearchCommandsResult `json:"fetchCommands"` +} + +// fetchCommandsResponse is the complete GraphQL response +type fetchCommandsResponse = GraphQLResponse[fetchCommandsData] + +// FetchCommandsFromServer searches commands via GraphQL +func FetchCommandsFromServer(ctx context.Context, endpoint Endpoint, filter *SearchCommandsFilter, pagination *SearchCommandsPagination) (*SearchCommandsResult, error) { + query := `query fetchCommands($pagination: InputPagination!, $filter: CommandFilter!) { + fetchCommands(pagination: $pagination, filter: $filter) { + count + edges { + id + shell + command + mainCommand + hostname + username + time + endTime + result + isEncrypted + originalCommand + } + } + }` + + variables := map[string]interface{}{ + "pagination": pagination, + "filter": filter, + } + + var result fetchCommandsResponse + err := SendGraphQLRequest(GraphQLRequestOptions[fetchCommandsResponse]{ + Context: ctx, + Endpoint: endpoint, + Query: query, + Variables: variables, + Response: &result, + Timeout: time.Second * 30, + }) + + if err != nil { + return nil, err + } + + return &result.Data.FetchCommands, nil +} From e6462eb015e2b5bea04957eb5efa5e45f5c766b3 Mon Sep 17 00:00:00 2001 From: AnnatarHe Date: Sat, 3 Jan 2026 23:40:00 +0800 Subject: [PATCH 3/5] feat(cli): add loading spinner to grep command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show a spinner with "Searching commands..." while fetching data from the server, then hide it when results are received. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- commands/grep.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/commands/grep.go b/commands/grep.go index 1dc25a1..47951e9 100644 --- a/commands/grep.go +++ b/commands/grep.go @@ -7,6 +7,7 @@ import ( "strconv" "time" + "github.com/briandowns/spinner" "github.com/gookit/color" "github.com/malamtime/cli/model" "github.com/olekukonko/tablewriter" @@ -125,8 +126,14 @@ func commandGrep(c *cli.Context) error { Offset: c.Int("offset"), } + // Show loading spinner + s := spinner.New(spinner.CharSets[35], 200*time.Millisecond) + s.Suffix = " Searching commands..." + s.Start() + // Fetch commands from server result, err := model.FetchCommandsFromServer(ctx, endpoint, filter, pagination) + s.Stop() if err != nil { return fmt.Errorf("failed to fetch commands: %w", err) } From 96816c31fa6de75f72f07bf4f2e9793a181d243d Mon Sep 17 00:00:00 2001 From: AnnatarHe Date: Sun, 4 Jan 2026 00:02:41 +0800 Subject: [PATCH 4/5] fix(cli): use cursor-based pagination for grep command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change from offset-based pagination to cursor-based pagination using lastId as required by the server's InputPagination type. Also add ID column to table output for pagination reference. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- commands/grep.go | 40 ++++++++++++++++++++++++++-------------- model/command_search.go | 4 ++-- 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/commands/grep.go b/commands/grep.go index 47951e9..dc39f96 100644 --- a/commands/grep.go +++ b/commands/grep.go @@ -3,6 +3,7 @@ package commands import ( "encoding/json" "fmt" + "log/slog" "os" "strconv" "time" @@ -34,10 +35,9 @@ var GrepCommand *cli.Command = &cli.Command{ Usage: "maximum number of results", }, &cli.IntFlag{ - Name: "offset", - Aliases: []string{"o"}, - Value: 0, - Usage: "skip this many results (pagination)", + Name: "last-id", + Value: 0, + Usage: "start after this command ID (for pagination)", }, &cli.StringFlag{ Name: "shell", @@ -95,6 +95,10 @@ func commandGrep(c *cli.Context) error { // Get search text from args searchText := c.Args().First() + slog.Debug("grep command args", + slog.String("first", searchText), + slog.Int("nArgs", c.NArg()), + slog.Any("allArgs", c.Args().Slice())) if searchText == "" { return fmt.Errorf("search text is required. Usage: shelltime grep ") } @@ -122,10 +126,15 @@ func commandGrep(c *cli.Context) error { // Build pagination pagination := &model.SearchCommandsPagination{ + LastID: c.Int("last-id"), Limit: c.Int("limit"), - Offset: c.Int("offset"), } + slog.Debug("grep filter", + slog.String("command", filter.Command), + slog.Int("limit", pagination.Limit), + slog.Int("lastId", pagination.LastID)) + // Show loading spinner s := spinner.New(spinner.CharSets[35], 200*time.Millisecond) s.Suffix = " Searching commands..." @@ -138,6 +147,10 @@ func commandGrep(c *cli.Context) error { return fmt.Errorf("failed to fetch commands: %w", err) } + slog.Debug("grep result", + slog.Int("count", result.Count), + slog.Int("edges", len(result.Edges))) + if len(result.Edges) == 0 { color.Yellow.Println("No commands found matching your search") return nil @@ -147,7 +160,7 @@ func commandGrep(c *cli.Context) error { if format == "json" { return outputGrepJSON(result.Edges, result.Count) } - return outputGrepTable(result.Edges, result.Count, c.Int("limit"), c.Int("offset")) + return outputGrepTable(result.Edges, result.Count, c.Int("limit")) } func buildGrepFilter(c *cli.Context, searchText string) (*model.SearchCommandsFilter, error) { @@ -255,10 +268,11 @@ func outputGrepJSON(commands []model.SearchCommandEdge, totalCount int) error { return nil } -func outputGrepTable(commands []model.SearchCommandEdge, totalCount, limit, offset int) error { +func outputGrepTable(commands []model.SearchCommandEdge, totalCount, limit int) error { w := tablewriter.NewWriter(os.Stdout) - w.Header([]string{"COMMAND", "SHELL", "TIME", "END TIME", "DURATION(ms)", "STATUS", "USER", "HOST"}) + w.Header([]string{"ID", "COMMAND", "SHELL", "TIME", "DURATION(ms)", "STATUS", "USER", "HOST"}) + var lastID int for _, cmd := range commands { // Use originalCommand if encrypted and available displayCommand := cmd.Command @@ -268,14 +282,14 @@ func outputGrepTable(commands []model.SearchCommandEdge, totalCount, limit, offs // Convert milliseconds to time startTime := time.UnixMilli(int64(cmd.Time)) - endTime := time.UnixMilli(int64(cmd.EndTime)) duration := int64(cmd.EndTime - cmd.Time) + lastID = cmd.ID w.Append([]string{ + strconv.Itoa(cmd.ID), displayCommand, cmd.Shell, startTime.Format(time.RFC3339), - endTime.Format(time.RFC3339), strconv.FormatInt(duration, 10), strconv.Itoa(cmd.Result), cmd.Username, @@ -288,10 +302,8 @@ func outputGrepTable(commands []model.SearchCommandEdge, totalCount, limit, offs // Show result count summary showing := len(commands) if totalCount > showing { - color.Gray.Printf("\nShowing %d of %d total results (offset: %d)\n", showing, totalCount, offset) - if offset+limit < totalCount { - color.Gray.Printf("Use --offset %d to see more results\n", offset+limit) - } + color.Gray.Printf("\nShowing %d of %d total results\n", showing, totalCount) + color.Gray.Printf("Use --last-id %d to see more results\n", lastID) } return nil diff --git a/model/command_search.go b/model/command_search.go index 40c6792..25bfe3b 100644 --- a/model/command_search.go +++ b/model/command_search.go @@ -33,10 +33,10 @@ type SearchCommandsFilter struct { Command string `json:"command,omitempty"` } -// SearchCommandsPagination for pagination +// SearchCommandsPagination for pagination (cursor-based) type SearchCommandsPagination struct { + LastID int `json:"lastId"` Limit int `json:"limit"` - Offset int `json:"offset"` } // SearchCommandsResult wraps the response From 69b7ec84652afe8407e8e6fc16bee71d8a22c9bd Mon Sep 17 00:00:00 2001 From: AnnatarHe Date: Sun, 4 Jan 2026 00:05:53 +0800 Subject: [PATCH 5/5] feat(cli): display API errors in grep output format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show errors as formatted output instead of returning them: - JSON format: output {"error": "message"} - Table format: print error in red 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- commands/grep.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/commands/grep.go b/commands/grep.go index dc39f96..50eb008 100644 --- a/commands/grep.go +++ b/commands/grep.go @@ -144,7 +144,16 @@ func commandGrep(c *cli.Context) error { result, err := model.FetchCommandsFromServer(ctx, endpoint, filter, pagination) s.Stop() if err != nil { - return fmt.Errorf("failed to fetch commands: %w", err) + if format == "json" { + errOutput := struct { + Error string `json:"error"` + }{Error: err.Error()} + jsonData, _ := json.MarshalIndent(errOutput, "", " ") + fmt.Println(string(jsonData)) + } else { + color.Red.Printf("Error: %s\n", err.Error()) + } + return nil } slog.Debug("grep result",