Skip to content

Commit 9ed5605

Browse files
committed
Add cmdhistory: structured command history with FTS5 search
From atuinsh/atuin pattern: - SQLite-backed (modernc.org/sqlite, pure Go, WAL mode) - Records: command, exit_code, duration, CWD, git_branch, session_id - FTS5 full-text search on commands with prefix expansion - Filter by exit code, CWD, session, time range - Stats: total/unique commands, success rate, top commands/dirs - Triggers keep FTS index in sync on INSERT/UPDATE/DELETE - 8-char hex IDs from crypto/rand - 18 tests covering search, filtering, stats, ordering
1 parent 420dbd4 commit 9ed5605

3 files changed

Lines changed: 907 additions & 0 deletions

File tree

‎cmdhistory/history.go‎

Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
// Package cmdhistory implements a structured command history store using SQLite.
2+
// Inspired by atuin, it records every Bash tool call with rich context
3+
// (exit code, duration, working directory, git branch, session) and provides
4+
// powerful FTS5-backed search and recall.
5+
package cmdhistory
6+
7+
import (
8+
"crypto/rand"
9+
"database/sql"
10+
"fmt"
11+
"strings"
12+
"time"
13+
14+
_ "modernc.org/sqlite"
15+
)
16+
17+
// Entry represents a single command execution with context.
18+
type Entry struct {
19+
ID string
20+
Command string
21+
ExitCode int
22+
Duration time.Duration
23+
CWD string
24+
GitBranch string
25+
SessionID string
26+
CreatedAt time.Time
27+
}
28+
29+
// SearchOpts controls filtering for the Search method.
30+
type SearchOpts struct {
31+
Limit int
32+
ExitCode *int // filter by exit code (nil = any)
33+
CWD string // filter by working directory
34+
SessionID string // filter by session
35+
Since time.Time // only entries after this time
36+
}
37+
38+
// CommandCount pairs a command string with its execution count.
39+
type CommandCount struct {
40+
Command string
41+
Count int
42+
}
43+
44+
// DirCount pairs a directory path with its execution count.
45+
type DirCount struct {
46+
Dir string
47+
Count int
48+
}
49+
50+
// HistoryStats holds aggregate usage statistics.
51+
type HistoryStats struct {
52+
TotalCommands int
53+
UniqueCommands int
54+
SuccessRate float64
55+
TopCommands []CommandCount
56+
TopDirectories []DirCount
57+
}
58+
59+
// Store provides access to the command history database.
60+
type Store struct {
61+
db *sql.DB
62+
}
63+
64+
// New opens or creates the command history database at the given path.
65+
func New(dbPath string) (*Store, error) {
66+
db, err := sql.Open("sqlite", dbPath)
67+
if err != nil {
68+
return nil, fmt.Errorf("open database: %w", err)
69+
}
70+
71+
if err := createSchema(db); err != nil {
72+
db.Close()
73+
return nil, fmt.Errorf("create schema: %w", err)
74+
}
75+
76+
return &Store{db: db}, nil
77+
}
78+
79+
// Record saves a command execution with its context.
80+
func (s *Store) Record(entry Entry) error {
81+
if entry.ID == "" {
82+
entry.ID = generateID()
83+
}
84+
if entry.CreatedAt.IsZero() {
85+
entry.CreatedAt = time.Now().UTC()
86+
}
87+
88+
_, err := s.db.Exec(
89+
`INSERT INTO entries (id, command, exit_code, duration_ms, cwd, git_branch, session_id, created_at)
90+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
91+
entry.ID,
92+
entry.Command,
93+
entry.ExitCode,
94+
entry.Duration.Milliseconds(),
95+
entry.CWD,
96+
entry.GitBranch,
97+
entry.SessionID,
98+
entry.CreatedAt.Format(time.RFC3339Nano),
99+
)
100+
if err != nil {
101+
return fmt.Errorf("insert entry: %w", err)
102+
}
103+
return nil
104+
}
105+
106+
// Search finds commands matching the query using FTS5 full-text search.
107+
// The query is passed through to the FTS5 MATCH operator, so callers can use
108+
// FTS5 query syntax (e.g. prefix queries with *, boolean AND/OR/NOT).
109+
// For simple substring searches, each word is automatically turned into a prefix token.
110+
func (s *Store) Search(query string, opts SearchOpts) ([]Entry, error) {
111+
if opts.Limit <= 0 {
112+
opts.Limit = 50
113+
}
114+
115+
// Build FTS5 match expression: split on whitespace and add * for prefix matching.
116+
words := strings.Fields(query)
117+
if len(words) == 0 {
118+
return nil, nil
119+
}
120+
for i, w := range words {
121+
// Only add wildcard if the word doesn't already contain FTS5 operators.
122+
if !strings.ContainsAny(w, "*\"{}") {
123+
words[i] = w + "*"
124+
}
125+
}
126+
ftsQuery := strings.Join(words, " ")
127+
128+
var clauses []string
129+
var args []interface{}
130+
131+
clauses = append(clauses, "entries_fts MATCH ?")
132+
args = append(args, ftsQuery)
133+
134+
if opts.ExitCode != nil {
135+
clauses = append(clauses, "e.exit_code = ?")
136+
args = append(args, *opts.ExitCode)
137+
}
138+
if opts.CWD != "" {
139+
clauses = append(clauses, "e.cwd = ?")
140+
args = append(args, opts.CWD)
141+
}
142+
if opts.SessionID != "" {
143+
clauses = append(clauses, "e.session_id = ?")
144+
args = append(args, opts.SessionID)
145+
}
146+
if !opts.Since.IsZero() {
147+
clauses = append(clauses, "e.created_at >= ?")
148+
args = append(args, opts.Since.Format(time.RFC3339Nano))
149+
}
150+
151+
where := strings.Join(clauses, " AND ")
152+
args = append(args, opts.Limit)
153+
154+
q := fmt.Sprintf(
155+
`SELECT e.id, e.command, e.exit_code, e.duration_ms, e.cwd, e.git_branch, e.session_id, e.created_at
156+
FROM entries e
157+
JOIN entries_fts ON entries_fts.rowid = e.rowid
158+
WHERE %s
159+
ORDER BY e.created_at DESC
160+
LIMIT ?`, where,
161+
)
162+
163+
rows, err := s.db.Query(q, args...)
164+
if err != nil {
165+
return nil, fmt.Errorf("search query: %w", err)
166+
}
167+
defer rows.Close()
168+
169+
return scanEntries(rows)
170+
}
171+
172+
// Recent returns the N most recent commands, ordered newest-first.
173+
func (s *Store) Recent(n int) ([]Entry, error) {
174+
if n <= 0 {
175+
n = 20
176+
}
177+
178+
rows, err := s.db.Query(
179+
`SELECT id, command, exit_code, duration_ms, cwd, git_branch, session_id, created_at
180+
FROM entries
181+
ORDER BY created_at DESC
182+
LIMIT ?`, n,
183+
)
184+
if err != nil {
185+
return nil, fmt.Errorf("recent query: %w", err)
186+
}
187+
defer rows.Close()
188+
189+
return scanEntries(rows)
190+
}
191+
192+
// SearchByDir returns commands executed in a specific directory, newest-first.
193+
func (s *Store) SearchByDir(dir string, limit int) ([]Entry, error) {
194+
if limit <= 0 {
195+
limit = 50
196+
}
197+
198+
rows, err := s.db.Query(
199+
`SELECT id, command, exit_code, duration_ms, cwd, git_branch, session_id, created_at
200+
FROM entries
201+
WHERE cwd = ?
202+
ORDER BY created_at DESC
203+
LIMIT ?`, dir, limit,
204+
)
205+
if err != nil {
206+
return nil, fmt.Errorf("search by dir: %w", err)
207+
}
208+
defer rows.Close()
209+
210+
return scanEntries(rows)
211+
}
212+
213+
// Stats returns aggregate usage statistics computed in SQL for efficiency.
214+
func (s *Store) Stats() (*HistoryStats, error) {
215+
stats := &HistoryStats{}
216+
217+
// Total and unique command counts, plus success rate.
218+
err := s.db.QueryRow(`
219+
SELECT
220+
COUNT(*) AS total,
221+
COUNT(DISTINCT command) AS uniq,
222+
COALESCE(
223+
CAST(SUM(CASE WHEN exit_code = 0 THEN 1 ELSE 0 END) AS REAL) / NULLIF(COUNT(*), 0),
224+
0
225+
) AS success_rate
226+
FROM entries
227+
`).Scan(&stats.TotalCommands, &stats.UniqueCommands, &stats.SuccessRate)
228+
if err != nil {
229+
return nil, fmt.Errorf("stats totals: %w", err)
230+
}
231+
232+
// Top 10 commands by frequency.
233+
cmdRows, err := s.db.Query(`
234+
SELECT command, COUNT(*) AS cnt
235+
FROM entries
236+
GROUP BY command
237+
ORDER BY cnt DESC
238+
LIMIT 10
239+
`)
240+
if err != nil {
241+
return nil, fmt.Errorf("stats top commands: %w", err)
242+
}
243+
defer cmdRows.Close()
244+
245+
for cmdRows.Next() {
246+
var cc CommandCount
247+
if err := cmdRows.Scan(&cc.Command, &cc.Count); err != nil {
248+
return nil, fmt.Errorf("scan command count: %w", err)
249+
}
250+
stats.TopCommands = append(stats.TopCommands, cc)
251+
}
252+
if err := cmdRows.Err(); err != nil {
253+
return nil, err
254+
}
255+
256+
// Top 10 directories by frequency.
257+
dirRows, err := s.db.Query(`
258+
SELECT cwd, COUNT(*) AS cnt
259+
FROM entries
260+
WHERE cwd != ''
261+
GROUP BY cwd
262+
ORDER BY cnt DESC
263+
LIMIT 10
264+
`)
265+
if err != nil {
266+
return nil, fmt.Errorf("stats top dirs: %w", err)
267+
}
268+
defer dirRows.Close()
269+
270+
for dirRows.Next() {
271+
var dc DirCount
272+
if err := dirRows.Scan(&dc.Dir, &dc.Count); err != nil {
273+
return nil, fmt.Errorf("scan dir count: %w", err)
274+
}
275+
stats.TopDirectories = append(stats.TopDirectories, dc)
276+
}
277+
if err := dirRows.Err(); err != nil {
278+
return nil, err
279+
}
280+
281+
return stats, nil
282+
}
283+
284+
// Close closes the underlying database connection.
285+
func (s *Store) Close() error {
286+
return s.db.Close()
287+
}
288+
289+
// --- internal helpers ---
290+
291+
func scanEntries(rows *sql.Rows) ([]Entry, error) {
292+
var entries []Entry
293+
for rows.Next() {
294+
var (
295+
e Entry
296+
durMS int64
297+
createdStr string
298+
)
299+
err := rows.Scan(
300+
&e.ID, &e.Command, &e.ExitCode, &durMS,
301+
&e.CWD, &e.GitBranch, &e.SessionID, &createdStr,
302+
)
303+
if err != nil {
304+
return nil, fmt.Errorf("scan entry: %w", err)
305+
}
306+
e.Duration = time.Duration(durMS) * time.Millisecond
307+
e.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdStr)
308+
entries = append(entries, e)
309+
}
310+
return entries, rows.Err()
311+
}
312+
313+
// generateID produces a short 8-character hex ID from crypto/rand.
314+
func generateID() string {
315+
b := make([]byte, 4)
316+
rand.Read(b)
317+
return fmt.Sprintf("%x", b)
318+
}

0 commit comments

Comments
 (0)