Skip to content
This repository was archived by the owner on Dec 17, 2025. It is now read-only.

Commit a11623b

Browse files
committed
Auto-install support for cursor
* Uses a hook that runs on every prompt (run install once per convo) * Added better logging, inc context like which client and path
1 parent 51b97f9 commit a11623b

File tree

13 files changed

+908
-37
lines changed

13 files changed

+908
-37
lines changed

cmd/skills/main.go

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,28 @@ func init() {
2323
}
2424

2525
func main() {
26-
// Log command invocation
26+
// Log command invocation with context
2727
log := logger.Get()
28-
log.Info("command invoked", "version", buildinfo.Version, "command", strings.Join(os.Args[1:], " "))
28+
cwd, _ := os.Getwd()
29+
30+
// Extract --client flag if present (for hook mode context)
31+
client := ""
32+
for i, arg := range os.Args {
33+
if strings.HasPrefix(arg, "--client=") {
34+
client = strings.TrimPrefix(arg, "--client=")
35+
break
36+
}
37+
if arg == "--client" && i+1 < len(os.Args) {
38+
client = os.Args[i+1]
39+
break
40+
}
41+
}
42+
43+
logArgs := []any{"version", buildinfo.Version, "command", strings.Join(os.Args[1:], " "), "cwd", cwd}
44+
if client != "" {
45+
logArgs = append(logArgs, "client", client)
46+
}
47+
log.Info("command invoked", logArgs...)
2948

3049
// Check for updates in the background (non-blocking, once per day)
3150
// Skip if user is explicitly running the update command

internal/cache/sessions.go

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
package cache
2+
3+
import (
4+
"bufio"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
"time"
10+
11+
"github.com/sleuth-io/skills/internal/utils"
12+
)
13+
14+
// SessionCache provides fast conversation/session ID tracking for clients
15+
// that fire hooks on every prompt rather than once per session.
16+
//
17+
// File format: Line-based, space-separated `session_id timestamp`
18+
// Example:
19+
//
20+
// 668320d2-2fd8-4888-b33c-2a466fec86e7 2025-12-12T10:30:00Z
21+
// 490b90b7-a2ce-4c2c-bb76-cb77b125df2f 2025-12-11T15:45:00Z
22+
type SessionCache struct {
23+
filePath string
24+
}
25+
26+
// NewSessionCache creates a session cache for the given client ID
27+
func NewSessionCache(clientID string) (*SessionCache, error) {
28+
cacheDir, err := GetCacheDir()
29+
if err != nil {
30+
return nil, fmt.Errorf("failed to get cache dir: %w", err)
31+
}
32+
33+
filePath := filepath.Join(cacheDir, clientID+"-sessions")
34+
return &SessionCache{filePath: filePath}, nil
35+
}
36+
37+
// HasSession checks if a session ID has been seen before.
38+
// This is optimized for fast checks (~1ms) by scanning the file line by line.
39+
func (s *SessionCache) HasSession(sessionID string) bool {
40+
if sessionID == "" {
41+
return false
42+
}
43+
44+
file, err := os.Open(s.filePath)
45+
if err != nil {
46+
// File doesn't exist = session not seen
47+
return false
48+
}
49+
defer file.Close()
50+
51+
scanner := bufio.NewScanner(file)
52+
for scanner.Scan() {
53+
line := scanner.Text()
54+
parts := strings.SplitN(line, " ", 2)
55+
if len(parts) >= 1 && parts[0] == sessionID {
56+
return true
57+
}
58+
}
59+
60+
return false
61+
}
62+
63+
// RecordSession records a session ID with the current timestamp.
64+
// Should be called optimistically before installation starts.
65+
func (s *SessionCache) RecordSession(sessionID string) error {
66+
if sessionID == "" {
67+
return nil
68+
}
69+
70+
// Ensure directory exists
71+
if err := utils.EnsureDir(filepath.Dir(s.filePath)); err != nil {
72+
return fmt.Errorf("failed to create cache directory: %w", err)
73+
}
74+
75+
// Open file for appending (create if not exists)
76+
file, err := os.OpenFile(s.filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
77+
if err != nil {
78+
return fmt.Errorf("failed to open session file: %w", err)
79+
}
80+
defer file.Close()
81+
82+
// Write new entry
83+
entry := fmt.Sprintf("%s %s\n", sessionID, time.Now().UTC().Format(time.RFC3339))
84+
if _, err := file.WriteString(entry); err != nil {
85+
return fmt.Errorf("failed to write session entry: %w", err)
86+
}
87+
88+
return nil
89+
}
90+
91+
// CullOldEntries removes entries older than the specified max age.
92+
// This keeps the session file from growing indefinitely.
93+
func (s *SessionCache) CullOldEntries(maxAge time.Duration) error {
94+
file, err := os.Open(s.filePath)
95+
if err != nil {
96+
if os.IsNotExist(err) {
97+
return nil // Nothing to cull
98+
}
99+
return fmt.Errorf("failed to open session file: %w", err)
100+
}
101+
defer file.Close()
102+
103+
cutoff := time.Now().Add(-maxAge)
104+
var keepLines []string
105+
106+
scanner := bufio.NewScanner(file)
107+
for scanner.Scan() {
108+
line := scanner.Text()
109+
parts := strings.SplitN(line, " ", 2)
110+
if len(parts) < 2 {
111+
continue // Malformed line, skip
112+
}
113+
114+
timestamp, err := time.Parse(time.RFC3339, parts[1])
115+
if err != nil {
116+
continue // Can't parse timestamp, skip
117+
}
118+
119+
if timestamp.After(cutoff) {
120+
keepLines = append(keepLines, line)
121+
}
122+
}
123+
124+
if err := scanner.Err(); err != nil {
125+
return fmt.Errorf("failed to scan session file: %w", err)
126+
}
127+
128+
// Write filtered content back
129+
content := strings.Join(keepLines, "\n")
130+
if len(keepLines) > 0 {
131+
content += "\n"
132+
}
133+
134+
if err := os.WriteFile(s.filePath, []byte(content), 0644); err != nil {
135+
return fmt.Errorf("failed to write filtered sessions: %w", err)
136+
}
137+
138+
return nil
139+
}
140+
141+
// Clear removes all session entries.
142+
func (s *SessionCache) Clear() error {
143+
if err := os.Remove(s.filePath); err != nil && !os.IsNotExist(err) {
144+
return fmt.Errorf("failed to remove session file: %w", err)
145+
}
146+
return nil
147+
}
148+
149+
// FilePath returns the path to the session cache file (for testing/debugging).
150+
func (s *SessionCache) FilePath() string {
151+
return s.filePath
152+
}

internal/cache/sessions_test.go

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
package cache
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
"time"
8+
)
9+
10+
func TestSessionCache_HasSession(t *testing.T) {
11+
// Create temp cache dir
12+
tmpDir := t.TempDir()
13+
t.Setenv("SKILLS_CACHE_DIR", tmpDir)
14+
15+
cache, err := NewSessionCache("test-client")
16+
if err != nil {
17+
t.Fatalf("Failed to create session cache: %v", err)
18+
}
19+
20+
// Test empty cache
21+
if cache.HasSession("session-1") {
22+
t.Error("Expected HasSession to return false for empty cache")
23+
}
24+
25+
// Record a session
26+
if err := cache.RecordSession("session-1"); err != nil {
27+
t.Fatalf("Failed to record session: %v", err)
28+
}
29+
30+
// Test that session is now found
31+
if !cache.HasSession("session-1") {
32+
t.Error("Expected HasSession to return true after recording")
33+
}
34+
35+
// Test that other sessions are not found
36+
if cache.HasSession("session-2") {
37+
t.Error("Expected HasSession to return false for unrecorded session")
38+
}
39+
}
40+
41+
func TestSessionCache_RecordSession(t *testing.T) {
42+
tmpDir := t.TempDir()
43+
t.Setenv("SKILLS_CACHE_DIR", tmpDir)
44+
45+
cache, err := NewSessionCache("test-client")
46+
if err != nil {
47+
t.Fatalf("Failed to create session cache: %v", err)
48+
}
49+
50+
// Record multiple sessions
51+
sessions := []string{"session-1", "session-2", "session-3"}
52+
for _, s := range sessions {
53+
if err := cache.RecordSession(s); err != nil {
54+
t.Fatalf("Failed to record session %s: %v", s, err)
55+
}
56+
}
57+
58+
// Verify all are recorded
59+
for _, s := range sessions {
60+
if !cache.HasSession(s) {
61+
t.Errorf("Expected session %s to be recorded", s)
62+
}
63+
}
64+
65+
// Verify file format
66+
data, err := os.ReadFile(cache.FilePath())
67+
if err != nil {
68+
t.Fatalf("Failed to read session file: %v", err)
69+
}
70+
71+
// File should have 3 lines
72+
lines := 0
73+
for _, b := range data {
74+
if b == '\n' {
75+
lines++
76+
}
77+
}
78+
if lines != 3 {
79+
t.Errorf("Expected 3 lines in session file, got %d", lines)
80+
}
81+
}
82+
83+
func TestSessionCache_EmptySessionID(t *testing.T) {
84+
tmpDir := t.TempDir()
85+
t.Setenv("SKILLS_CACHE_DIR", tmpDir)
86+
87+
cache, err := NewSessionCache("test-client")
88+
if err != nil {
89+
t.Fatalf("Failed to create session cache: %v", err)
90+
}
91+
92+
// Empty session ID should not be recorded
93+
if err := cache.RecordSession(""); err != nil {
94+
t.Fatalf("RecordSession with empty ID should not error: %v", err)
95+
}
96+
97+
// File should not exist
98+
if _, err := os.Stat(cache.FilePath()); !os.IsNotExist(err) {
99+
t.Error("Expected no file to be created for empty session ID")
100+
}
101+
102+
// HasSession should return false for empty
103+
if cache.HasSession("") {
104+
t.Error("Expected HasSession to return false for empty session ID")
105+
}
106+
}
107+
108+
func TestSessionCache_CullOldEntries(t *testing.T) {
109+
tmpDir := t.TempDir()
110+
t.Setenv("SKILLS_CACHE_DIR", tmpDir)
111+
112+
cache, err := NewSessionCache("test-client")
113+
if err != nil {
114+
t.Fatalf("Failed to create session cache: %v", err)
115+
}
116+
117+
// Write some entries with old timestamps manually
118+
oldTime := time.Now().Add(-10 * 24 * time.Hour).UTC().Format(time.RFC3339)
119+
newTime := time.Now().UTC().Format(time.RFC3339)
120+
121+
// Ensure directory exists
122+
if err := os.MkdirAll(filepath.Dir(cache.FilePath()), 0755); err != nil {
123+
t.Fatalf("Failed to create directory: %v", err)
124+
}
125+
126+
content := "old-session " + oldTime + "\n" +
127+
"new-session " + newTime + "\n"
128+
129+
if err := os.WriteFile(cache.FilePath(), []byte(content), 0644); err != nil {
130+
t.Fatalf("Failed to write test data: %v", err)
131+
}
132+
133+
// Cull entries older than 5 days
134+
if err := cache.CullOldEntries(5 * 24 * time.Hour); err != nil {
135+
t.Fatalf("Failed to cull old entries: %v", err)
136+
}
137+
138+
// Old session should be gone
139+
if cache.HasSession("old-session") {
140+
t.Error("Expected old session to be culled")
141+
}
142+
143+
// New session should still exist
144+
if !cache.HasSession("new-session") {
145+
t.Error("Expected new session to still exist after culling")
146+
}
147+
}
148+
149+
func TestSessionCache_Clear(t *testing.T) {
150+
tmpDir := t.TempDir()
151+
t.Setenv("SKILLS_CACHE_DIR", tmpDir)
152+
153+
cache, err := NewSessionCache("test-client")
154+
if err != nil {
155+
t.Fatalf("Failed to create session cache: %v", err)
156+
}
157+
158+
// Record a session
159+
if err := cache.RecordSession("session-1"); err != nil {
160+
t.Fatalf("Failed to record session: %v", err)
161+
}
162+
163+
// Clear cache
164+
if err := cache.Clear(); err != nil {
165+
t.Fatalf("Failed to clear cache: %v", err)
166+
}
167+
168+
// Session should no longer exist
169+
if cache.HasSession("session-1") {
170+
t.Error("Expected session to be cleared")
171+
}
172+
}
173+
174+
func TestSessionCache_MultipleClients(t *testing.T) {
175+
tmpDir := t.TempDir()
176+
t.Setenv("SKILLS_CACHE_DIR", tmpDir)
177+
178+
cache1, err := NewSessionCache("client-1")
179+
if err != nil {
180+
t.Fatalf("Failed to create cache 1: %v", err)
181+
}
182+
183+
cache2, err := NewSessionCache("client-2")
184+
if err != nil {
185+
t.Fatalf("Failed to create cache 2: %v", err)
186+
}
187+
188+
// Record to client 1
189+
if err := cache1.RecordSession("session-a"); err != nil {
190+
t.Fatalf("Failed to record to cache 1: %v", err)
191+
}
192+
193+
// Client 1 should have it
194+
if !cache1.HasSession("session-a") {
195+
t.Error("Client 1 should have session-a")
196+
}
197+
198+
// Client 2 should NOT have it
199+
if cache2.HasSession("session-a") {
200+
t.Error("Client 2 should not have session-a")
201+
}
202+
203+
// Verify different files
204+
if cache1.FilePath() == cache2.FilePath() {
205+
t.Error("Different clients should have different cache files")
206+
}
207+
}

0 commit comments

Comments
 (0)