Skip to content

Commit 8a55de2

Browse files
authored
Merge pull request #187 from shelltime/feat/add-rg-grep-command
feat(cli): add rg/grep command for searching synced commands
2 parents c76fce1 + 69b7ec8 commit 8a55de2

4 files changed

Lines changed: 418 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ shelltime daemon install # Optional: background sync for <8ms latency
2424
| Command | Description |
2525
|---------|-------------|
2626
| `shelltime sync` | Sync pending commands to server |
27+
| `shelltime rg "pattern"` | Search synced commands (alias: `grep`) |
2728
| `shelltime q "prompt"` | AI-powered command suggestions |
2829
| `shelltime doctor` | Diagnose installation issues |
2930
| `shelltime web` | Open dashboard in browser |

cmd/cli/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ func main() {
109109
commands.CCCommand,
110110
commands.CodexCommand,
111111
commands.SchemaCommand,
112+
commands.GrepCommand,
112113
}
113114
err = app.Run(os.Args)
114115
if err != nil {

commands/grep.go

Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
package commands
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"log/slog"
7+
"os"
8+
"strconv"
9+
"time"
10+
11+
"github.com/briandowns/spinner"
12+
"github.com/gookit/color"
13+
"github.com/malamtime/cli/model"
14+
"github.com/olekukonko/tablewriter"
15+
"github.com/urfave/cli/v2"
16+
"go.opentelemetry.io/otel/trace"
17+
)
18+
19+
var GrepCommand *cli.Command = &cli.Command{
20+
Name: "rg",
21+
Aliases: []string{"grep"},
22+
Usage: "Search server-synced commands",
23+
ArgsUsage: "<search-text>",
24+
Flags: []cli.Flag{
25+
&cli.StringFlag{
26+
Name: "format",
27+
Aliases: []string{"f"},
28+
Value: "table",
29+
Usage: "output format (table/json)",
30+
},
31+
&cli.IntFlag{
32+
Name: "limit",
33+
Aliases: []string{"l"},
34+
Value: 50,
35+
Usage: "maximum number of results",
36+
},
37+
&cli.IntFlag{
38+
Name: "last-id",
39+
Value: 0,
40+
Usage: "start after this command ID (for pagination)",
41+
},
42+
&cli.StringFlag{
43+
Name: "shell",
44+
Aliases: []string{"s"},
45+
Usage: "filter by shell (bash, zsh, fish)",
46+
},
47+
&cli.StringFlag{
48+
Name: "hostname",
49+
Aliases: []string{"H"},
50+
Usage: "filter by hostname",
51+
},
52+
&cli.StringFlag{
53+
Name: "username",
54+
Aliases: []string{"u"},
55+
Usage: "filter by username",
56+
},
57+
&cli.IntFlag{
58+
Name: "result",
59+
Aliases: []string{"r"},
60+
Value: -1,
61+
Usage: "filter by exit code (-1 means any)",
62+
},
63+
&cli.StringFlag{
64+
Name: "main-command",
65+
Aliases: []string{"m"},
66+
Usage: "filter by main command (e.g., git, npm)",
67+
},
68+
&cli.StringFlag{
69+
Name: "since",
70+
Usage: "filter commands since date (2024, 2024-01, or 2024-01-15)",
71+
},
72+
&cli.StringFlag{
73+
Name: "until",
74+
Usage: "filter commands until date (2024, 2024-01, or 2024-01-15)",
75+
},
76+
},
77+
Action: commandGrep,
78+
OnUsageError: func(cCtx *cli.Context, err error, isSubcommand bool) error {
79+
color.Red.Println(err.Error())
80+
return nil
81+
},
82+
}
83+
84+
func commandGrep(c *cli.Context) error {
85+
ctx, span := commandTracer.Start(c.Context, "grep", trace.WithSpanKind(trace.SpanKindClient))
86+
defer span.End()
87+
88+
SetupLogger(os.ExpandEnv("$HOME/" + model.COMMAND_BASE_STORAGE_FOLDER))
89+
90+
// Validate format
91+
format := c.String("format")
92+
if format != "table" && format != "json" {
93+
return fmt.Errorf("unsupported format: %s. Use 'table' or 'json'", format)
94+
}
95+
96+
// Get search text from args
97+
searchText := c.Args().First()
98+
slog.Debug("grep command args",
99+
slog.String("first", searchText),
100+
slog.Int("nArgs", c.NArg()),
101+
slog.Any("allArgs", c.Args().Slice()))
102+
if searchText == "" {
103+
return fmt.Errorf("search text is required. Usage: shelltime grep <search-text>")
104+
}
105+
106+
// Read config to get endpoint and token
107+
cfg, err := configService.ReadConfigFile(ctx)
108+
if err != nil {
109+
return fmt.Errorf("failed to read config: %w", err)
110+
}
111+
112+
if cfg.Token == "" {
113+
return fmt.Errorf("not authenticated. Please run 'shelltime auth' first")
114+
}
115+
116+
endpoint := model.Endpoint{
117+
APIEndpoint: cfg.APIEndpoint,
118+
Token: cfg.Token,
119+
}
120+
121+
// Build filter
122+
filter, err := buildGrepFilter(c, searchText)
123+
if err != nil {
124+
return err
125+
}
126+
127+
// Build pagination
128+
pagination := &model.SearchCommandsPagination{
129+
LastID: c.Int("last-id"),
130+
Limit: c.Int("limit"),
131+
}
132+
133+
slog.Debug("grep filter",
134+
slog.String("command", filter.Command),
135+
slog.Int("limit", pagination.Limit),
136+
slog.Int("lastId", pagination.LastID))
137+
138+
// Show loading spinner
139+
s := spinner.New(spinner.CharSets[35], 200*time.Millisecond)
140+
s.Suffix = " Searching commands..."
141+
s.Start()
142+
143+
// Fetch commands from server
144+
result, err := model.FetchCommandsFromServer(ctx, endpoint, filter, pagination)
145+
s.Stop()
146+
if err != nil {
147+
if format == "json" {
148+
errOutput := struct {
149+
Error string `json:"error"`
150+
}{Error: err.Error()}
151+
jsonData, _ := json.MarshalIndent(errOutput, "", " ")
152+
fmt.Println(string(jsonData))
153+
} else {
154+
color.Red.Printf("Error: %s\n", err.Error())
155+
}
156+
return nil
157+
}
158+
159+
slog.Debug("grep result",
160+
slog.Int("count", result.Count),
161+
slog.Int("edges", len(result.Edges)))
162+
163+
if len(result.Edges) == 0 {
164+
color.Yellow.Println("No commands found matching your search")
165+
return nil
166+
}
167+
168+
// Output based on format
169+
if format == "json" {
170+
return outputGrepJSON(result.Edges, result.Count)
171+
}
172+
return outputGrepTable(result.Edges, result.Count, c.Int("limit"))
173+
}
174+
175+
func buildGrepFilter(c *cli.Context, searchText string) (*model.SearchCommandsFilter, error) {
176+
filter := &model.SearchCommandsFilter{
177+
Shell: []string{},
178+
MainCommand: []string{},
179+
Hostname: []string{},
180+
Username: []string{},
181+
IP: []string{},
182+
Result: []int{},
183+
Time: []float64{},
184+
SessionID: []float64{},
185+
Command: searchText,
186+
}
187+
188+
// Add optional filters if provided
189+
if shell := c.String("shell"); shell != "" {
190+
filter.Shell = []string{shell}
191+
}
192+
193+
if hostname := c.String("hostname"); hostname != "" {
194+
filter.Hostname = []string{hostname}
195+
}
196+
197+
if username := c.String("username"); username != "" {
198+
filter.Username = []string{username}
199+
}
200+
201+
if result := c.Int("result"); result >= 0 {
202+
filter.Result = []int{result}
203+
}
204+
205+
if mainCmd := c.String("main-command"); mainCmd != "" {
206+
filter.MainCommand = []string{mainCmd}
207+
}
208+
209+
// Handle time filters with flexible date parsing
210+
var timeFilters []float64
211+
if since := c.String("since"); since != "" {
212+
t, err := parseFlexibleDate(since, false)
213+
if err != nil {
214+
return nil, fmt.Errorf("invalid --since date: %w", err)
215+
}
216+
timeFilters = append(timeFilters, float64(t.UnixMilli()))
217+
}
218+
if until := c.String("until"); until != "" {
219+
t, err := parseFlexibleDate(until, true)
220+
if err != nil {
221+
return nil, fmt.Errorf("invalid --until date: %w", err)
222+
}
223+
timeFilters = append(timeFilters, float64(t.UnixMilli()))
224+
}
225+
if len(timeFilters) > 0 {
226+
filter.Time = timeFilters
227+
}
228+
229+
return filter, nil
230+
}
231+
232+
// parseFlexibleDate parses dates in formats: 2024, 2024-01, 2024-01-15
233+
// If isEndOfPeriod is true, returns end of the period (for --until)
234+
func parseFlexibleDate(s string, isEndOfPeriod bool) (time.Time, error) {
235+
// Try year only: 2024
236+
if t, err := time.Parse("2006", s); err == nil {
237+
if isEndOfPeriod {
238+
return time.Date(t.Year(), 12, 31, 23, 59, 59, 0, time.UTC), nil
239+
}
240+
return time.Date(t.Year(), 1, 1, 0, 0, 0, 0, time.UTC), nil
241+
}
242+
243+
// Try year-month: 2024-01
244+
if t, err := time.Parse("2006-01", s); err == nil {
245+
if isEndOfPeriod {
246+
// End of month: go to next month, then subtract 1 second
247+
return t.AddDate(0, 1, 0).Add(-time.Second), nil
248+
}
249+
return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, time.UTC), nil
250+
}
251+
252+
// Try year-month-day: 2024-01-15
253+
if t, err := time.Parse("2006-01-02", s); err == nil {
254+
if isEndOfPeriod {
255+
return time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 0, time.UTC), nil
256+
}
257+
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC), nil
258+
}
259+
260+
return time.Time{}, fmt.Errorf("use format: 2024, 2024-01, or 2024-01-15")
261+
}
262+
263+
func outputGrepJSON(commands []model.SearchCommandEdge, totalCount int) error {
264+
output := struct {
265+
TotalCount int `json:"totalCount"`
266+
Commands []model.SearchCommandEdge `json:"commands"`
267+
}{
268+
TotalCount: totalCount,
269+
Commands: commands,
270+
}
271+
272+
jsonData, err := json.MarshalIndent(output, "", " ")
273+
if err != nil {
274+
return err
275+
}
276+
fmt.Println(string(jsonData))
277+
return nil
278+
}
279+
280+
func outputGrepTable(commands []model.SearchCommandEdge, totalCount, limit int) error {
281+
w := tablewriter.NewWriter(os.Stdout)
282+
w.Header([]string{"ID", "COMMAND", "SHELL", "TIME", "DURATION(ms)", "STATUS", "USER", "HOST"})
283+
284+
var lastID int
285+
for _, cmd := range commands {
286+
// Use originalCommand if encrypted and available
287+
displayCommand := cmd.Command
288+
if cmd.IsEncrypted && cmd.OriginalCommand != "" {
289+
displayCommand = cmd.OriginalCommand
290+
}
291+
292+
// Convert milliseconds to time
293+
startTime := time.UnixMilli(int64(cmd.Time))
294+
duration := int64(cmd.EndTime - cmd.Time)
295+
lastID = cmd.ID
296+
297+
w.Append([]string{
298+
strconv.Itoa(cmd.ID),
299+
displayCommand,
300+
cmd.Shell,
301+
startTime.Format(time.RFC3339),
302+
strconv.FormatInt(duration, 10),
303+
strconv.Itoa(cmd.Result),
304+
cmd.Username,
305+
cmd.Hostname,
306+
})
307+
}
308+
309+
w.Render()
310+
311+
// Show result count summary
312+
showing := len(commands)
313+
if totalCount > showing {
314+
color.Gray.Printf("\nShowing %d of %d total results\n", showing, totalCount)
315+
color.Gray.Printf("Use --last-id %d to see more results\n", lastID)
316+
}
317+
318+
return nil
319+
}

0 commit comments

Comments
 (0)