@tanagram/cli 0.4.2 → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/checker/matcher.go +64 -0
- package/commands/config.go +343 -70
- package/commands/config_test.go +389 -0
- package/commands/run.go +23 -19
- package/commands/snapshot.go +15 -3
- package/commands/snapshot_test.go +88 -0
- package/main.go +58 -8
- package/package.json +1 -1
- package/utils/process.go +15 -4
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
package commands
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"os"
|
|
6
|
+
"path/filepath"
|
|
7
|
+
"testing"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
func TestConfigClaude(t *testing.T) {
|
|
11
|
+
// Create a temporary directory
|
|
12
|
+
tempDir, err := os.MkdirTemp("", "tanagram-config-test-*")
|
|
13
|
+
if err != nil {
|
|
14
|
+
t.Fatalf("Failed to create temp dir: %v", err)
|
|
15
|
+
}
|
|
16
|
+
defer os.RemoveAll(tempDir)
|
|
17
|
+
|
|
18
|
+
settingsPath := filepath.Join(tempDir, ".claude", "settings.json")
|
|
19
|
+
|
|
20
|
+
// Redirect stdout to suppress logs
|
|
21
|
+
oldStdout := os.Stdout
|
|
22
|
+
_, w, _ := os.Pipe()
|
|
23
|
+
os.Stdout = w
|
|
24
|
+
defer func() {
|
|
25
|
+
os.Stdout = oldStdout
|
|
26
|
+
}()
|
|
27
|
+
|
|
28
|
+
t.Run("Create new config", func(t *testing.T) {
|
|
29
|
+
if err := ConfigClaude(settingsPath); err != nil {
|
|
30
|
+
t.Fatalf("ConfigClaude() failed: %v", err)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
settings := readSettings(t, settingsPath)
|
|
34
|
+
pre, post := checkHooks(settings)
|
|
35
|
+
if !pre {
|
|
36
|
+
t.Error("PreToolUse hook not created")
|
|
37
|
+
}
|
|
38
|
+
if !post {
|
|
39
|
+
t.Error("PostToolUse hook not created")
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
t.Run("Idempotency", func(t *testing.T) {
|
|
44
|
+
// Run it again (first time was in "Create new config")
|
|
45
|
+
if err := ConfigClaude(settingsPath); err != nil {
|
|
46
|
+
t.Fatalf("ConfigClaude() failed on second run: %v", err)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
settings := readSettings(t, settingsPath)
|
|
50
|
+
pre, post := checkHooks(settings)
|
|
51
|
+
if !pre || !post {
|
|
52
|
+
t.Error("Hooks missing after second run")
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Check that we didn't duplicate them (rudimentary check: count of items)
|
|
56
|
+
hooks := settings["hooks"].(map[string]interface{})
|
|
57
|
+
preToolUse := hooks["PreToolUse"].([]interface{})
|
|
58
|
+
postToolUse := hooks["PostToolUse"].([]interface{})
|
|
59
|
+
|
|
60
|
+
// We expect exactly 1 hook in each since we started fresh
|
|
61
|
+
if len(preToolUse) != 1 {
|
|
62
|
+
t.Errorf("Expected 1 PreToolUse hook, got %d", len(preToolUse))
|
|
63
|
+
}
|
|
64
|
+
if len(postToolUse) != 1 {
|
|
65
|
+
t.Errorf("Expected 1 PostToolUse hook, got %d", len(postToolUse))
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
t.Run("Preserve existing hooks", func(t *testing.T) {
|
|
70
|
+
// Reset with a file that has other hooks
|
|
71
|
+
otherHook := map[string]interface{}{
|
|
72
|
+
"hooks": map[string]interface{}{
|
|
73
|
+
"PreToolUse": []interface{}{
|
|
74
|
+
map[string]interface{}{
|
|
75
|
+
"matcher": "Read",
|
|
76
|
+
"hooks": []interface{}{
|
|
77
|
+
map[string]interface{}{"command": "echo existing"},
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Clean up previous run
|
|
85
|
+
os.RemoveAll(filepath.Dir(settingsPath))
|
|
86
|
+
|
|
87
|
+
dir := filepath.Dir(settingsPath)
|
|
88
|
+
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
89
|
+
t.Fatalf("Failed to create dir: %v", err)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Write otherHook to settingsPath
|
|
93
|
+
data, _ := json.Marshal(otherHook)
|
|
94
|
+
if err := os.WriteFile(settingsPath, data, 0644); err != nil {
|
|
95
|
+
t.Fatalf("Failed to write settings: %v", err)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if err := ConfigClaude(settingsPath); err != nil {
|
|
99
|
+
t.Fatalf("ConfigClaude() failed: %v", err)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
settings := readSettings(t, settingsPath)
|
|
103
|
+
pre, post := checkHooks(settings)
|
|
104
|
+
if !pre || !post {
|
|
105
|
+
t.Error("Tanagram hooks missing when merging with existing config")
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Check if existing hook is still there
|
|
109
|
+
hooks := settings["hooks"].(map[string]interface{})
|
|
110
|
+
preToolUse := hooks["PreToolUse"].([]interface{})
|
|
111
|
+
|
|
112
|
+
foundExisting := false
|
|
113
|
+
for _, h := range preToolUse {
|
|
114
|
+
hMap := h.(map[string]interface{})
|
|
115
|
+
if hMap["matcher"] == "Read" {
|
|
116
|
+
// Check for the specific command to be sure it's the right one
|
|
117
|
+
if commands, ok := hMap["hooks"].([]interface{}); ok {
|
|
118
|
+
for _, cmd := range commands {
|
|
119
|
+
if cmdMap, ok := cmd.(map[string]interface{}); ok {
|
|
120
|
+
if cmdMap["command"] == "echo existing" {
|
|
121
|
+
foundExisting = true
|
|
122
|
+
break
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if foundExisting {
|
|
129
|
+
break
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if !foundExisting {
|
|
133
|
+
t.Error("Existing hook was lost")
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
func TestConfigCursor(t *testing.T) {
|
|
139
|
+
// Create a temporary directory
|
|
140
|
+
tempDir, err := os.MkdirTemp("", "tanagram-cursor-test-*")
|
|
141
|
+
if err != nil {
|
|
142
|
+
t.Fatalf("Failed to create temp dir: %v", err)
|
|
143
|
+
}
|
|
144
|
+
defer os.RemoveAll(tempDir)
|
|
145
|
+
|
|
146
|
+
hooksPath := filepath.Join(tempDir, ".cursor", "hooks.json")
|
|
147
|
+
|
|
148
|
+
// Redirect stdout to suppress logs
|
|
149
|
+
oldStdout := os.Stdout
|
|
150
|
+
_, w, _ := os.Pipe()
|
|
151
|
+
os.Stdout = w
|
|
152
|
+
defer func() {
|
|
153
|
+
os.Stdout = oldStdout
|
|
154
|
+
}()
|
|
155
|
+
|
|
156
|
+
t.Run("Create new config", func(t *testing.T) {
|
|
157
|
+
if err := ConfigCursor(hooksPath); err != nil {
|
|
158
|
+
t.Fatalf("ConfigCursor() failed: %v", err)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
settings := readSettings(t, hooksPath)
|
|
162
|
+
beforeSubmit, stop := checkCursorHooks(settings)
|
|
163
|
+
if !beforeSubmit {
|
|
164
|
+
t.Error("beforeSubmitPrompt hook not created")
|
|
165
|
+
}
|
|
166
|
+
if !stop {
|
|
167
|
+
t.Error("stop hook not created")
|
|
168
|
+
}
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
t.Run("Idempotency", func(t *testing.T) {
|
|
172
|
+
// Run it again
|
|
173
|
+
if err := ConfigCursor(hooksPath); err != nil {
|
|
174
|
+
t.Fatalf("ConfigCursor() failed on second run: %v", err)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
settings := readSettings(t, hooksPath)
|
|
178
|
+
beforeSubmit, stop := checkCursorHooks(settings)
|
|
179
|
+
if !beforeSubmit || !stop {
|
|
180
|
+
t.Error("Hooks missing after second run")
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Check duplicates
|
|
184
|
+
hooks := settings["hooks"].(map[string]interface{})
|
|
185
|
+
beforeSubmitHooks := hooks["beforeSubmitPrompt"].([]interface{})
|
|
186
|
+
stopHooks := hooks["stop"].([]interface{})
|
|
187
|
+
|
|
188
|
+
if len(beforeSubmitHooks) != 1 {
|
|
189
|
+
t.Errorf("Expected 1 beforeSubmitPrompt hook, got %d", len(beforeSubmitHooks))
|
|
190
|
+
}
|
|
191
|
+
if len(stopHooks) != 1 {
|
|
192
|
+
t.Errorf("Expected 1 stop hook, got %d", len(stopHooks))
|
|
193
|
+
}
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
t.Run("Preserve existing hooks", func(t *testing.T) {
|
|
197
|
+
// Reset with a file that has other hooks
|
|
198
|
+
otherHook := map[string]interface{}{
|
|
199
|
+
"hooks": map[string]interface{}{
|
|
200
|
+
"beforeSubmitPrompt": []interface{}{
|
|
201
|
+
map[string]interface{}{"command": "echo existing"},
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Clean up previous run
|
|
207
|
+
os.RemoveAll(filepath.Dir(hooksPath))
|
|
208
|
+
|
|
209
|
+
dir := filepath.Dir(hooksPath)
|
|
210
|
+
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
211
|
+
t.Fatalf("Failed to create dir: %v", err)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Write otherHook to hooksPath
|
|
215
|
+
data, _ := json.Marshal(otherHook)
|
|
216
|
+
if err := os.WriteFile(hooksPath, data, 0644); err != nil {
|
|
217
|
+
t.Fatalf("Failed to write settings: %v", err)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if err := ConfigCursor(hooksPath); err != nil {
|
|
221
|
+
t.Fatalf("ConfigCursor() failed: %v", err)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
settings := readSettings(t, hooksPath)
|
|
225
|
+
beforeSubmit, stop := checkCursorHooks(settings)
|
|
226
|
+
if !beforeSubmit || !stop {
|
|
227
|
+
t.Error("Tanagram hooks missing when merging with existing config")
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Check if existing hook is still there
|
|
231
|
+
hooks := settings["hooks"].(map[string]interface{})
|
|
232
|
+
beforeSubmitHooks := hooks["beforeSubmitPrompt"].([]interface{})
|
|
233
|
+
|
|
234
|
+
foundExisting := false
|
|
235
|
+
for _, h := range beforeSubmitHooks {
|
|
236
|
+
hMap := h.(map[string]interface{})
|
|
237
|
+
if cmd, ok := hMap["command"].(string); ok && cmd == "echo existing" {
|
|
238
|
+
foundExisting = true
|
|
239
|
+
break
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if !foundExisting {
|
|
243
|
+
t.Error("Existing hook was lost")
|
|
244
|
+
}
|
|
245
|
+
})
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
func TestEnsureHooksConfigured(t *testing.T) {
|
|
249
|
+
// Create a temporary home directory
|
|
250
|
+
tempHome, err := os.MkdirTemp("", "tanagram-home-test-*")
|
|
251
|
+
if err != nil {
|
|
252
|
+
t.Fatalf("Failed to create temp home: %v", err)
|
|
253
|
+
}
|
|
254
|
+
defer os.RemoveAll(tempHome)
|
|
255
|
+
|
|
256
|
+
// Mock HOME environment variable
|
|
257
|
+
originalHome := os.Getenv("HOME")
|
|
258
|
+
defer os.Setenv("HOME", originalHome)
|
|
259
|
+
os.Setenv("HOME", tempHome)
|
|
260
|
+
|
|
261
|
+
// Redirect stdout/stderr to suppress logs
|
|
262
|
+
oldStdout := os.Stdout
|
|
263
|
+
oldStderr := os.Stderr
|
|
264
|
+
_, w, _ := os.Pipe()
|
|
265
|
+
os.Stdout = w
|
|
266
|
+
os.Stderr = w
|
|
267
|
+
defer func() {
|
|
268
|
+
os.Stdout = oldStdout
|
|
269
|
+
os.Stderr = oldStderr
|
|
270
|
+
}()
|
|
271
|
+
|
|
272
|
+
t.Run("Fresh configuration", func(t *testing.T) {
|
|
273
|
+
if err := EnsureHooksConfigured(); err != nil {
|
|
274
|
+
t.Fatalf("EnsureHooksConfigured() failed: %v", err)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Check Claude settings
|
|
278
|
+
claudeSettingsPath := filepath.Join(tempHome, ".claude", "settings.json")
|
|
279
|
+
claudeSettings := readSettings(t, claudeSettingsPath)
|
|
280
|
+
pre, post := checkHooks(claudeSettings)
|
|
281
|
+
if !pre {
|
|
282
|
+
t.Error("PreToolUse hook not created")
|
|
283
|
+
}
|
|
284
|
+
if !post {
|
|
285
|
+
t.Error("PostToolUse hook not created")
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Check Cursor hooks
|
|
289
|
+
cursorHooksPath := filepath.Join(tempHome, ".cursor", "hooks.json")
|
|
290
|
+
cursorSettings := readSettings(t, cursorHooksPath)
|
|
291
|
+
beforeSubmit, stop := checkCursorHooks(cursorSettings)
|
|
292
|
+
if !beforeSubmit {
|
|
293
|
+
t.Error("beforeSubmitPrompt hook not created")
|
|
294
|
+
}
|
|
295
|
+
if !stop {
|
|
296
|
+
t.Error("stop hook not created")
|
|
297
|
+
}
|
|
298
|
+
})
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
func readSettings(t *testing.T, settingsPath string) map[string]interface{} {
|
|
302
|
+
data, err := os.ReadFile(settingsPath)
|
|
303
|
+
if err != nil {
|
|
304
|
+
t.Fatalf("Failed to read settings file: %v", err)
|
|
305
|
+
}
|
|
306
|
+
var settings map[string]interface{}
|
|
307
|
+
if err := json.Unmarshal(data, &settings); err != nil {
|
|
308
|
+
t.Fatalf("Failed to parse settings JSON: %v", err)
|
|
309
|
+
}
|
|
310
|
+
return settings
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
func checkHooks(settings map[string]interface{}) (bool, bool) {
|
|
314
|
+
hooks, ok := settings["hooks"].(map[string]interface{})
|
|
315
|
+
if !ok {
|
|
316
|
+
return false, false
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
preExists := false
|
|
320
|
+
if preToolUse, ok := hooks["PreToolUse"].([]interface{}); ok {
|
|
321
|
+
for _, h := range preToolUse {
|
|
322
|
+
if hMap, ok := h.(map[string]interface{}); ok {
|
|
323
|
+
if hMap["matcher"] == "Edit|Write" {
|
|
324
|
+
if innerHooks, ok := hMap["hooks"].([]interface{}); ok {
|
|
325
|
+
for _, ih := range innerHooks {
|
|
326
|
+
if ihMap, ok := ih.(map[string]interface{}); ok {
|
|
327
|
+
if ihMap["command"] == "tanagram snapshot" {
|
|
328
|
+
preExists = true
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
postExists := false
|
|
339
|
+
if postToolUse, ok := hooks["PostToolUse"].([]interface{}); ok {
|
|
340
|
+
for _, h := range postToolUse {
|
|
341
|
+
if hMap, ok := h.(map[string]interface{}); ok {
|
|
342
|
+
if hMap["matcher"] == "Edit|Write" {
|
|
343
|
+
if innerHooks, ok := hMap["hooks"].([]interface{}); ok {
|
|
344
|
+
for _, ih := range innerHooks {
|
|
345
|
+
if ihMap, ok := ih.(map[string]interface{}); ok {
|
|
346
|
+
if ihMap["command"] == "tanagram" {
|
|
347
|
+
postExists = true
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return preExists, postExists
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
func checkCursorHooks(settings map[string]interface{}) (bool, bool) {
|
|
361
|
+
hooks, ok := settings["hooks"].(map[string]interface{})
|
|
362
|
+
if !ok {
|
|
363
|
+
return false, false
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
beforeSubmitExists := false
|
|
367
|
+
if beforeSubmit, ok := hooks["beforeSubmitPrompt"].([]interface{}); ok {
|
|
368
|
+
for _, h := range beforeSubmit {
|
|
369
|
+
if hMap, ok := h.(map[string]interface{}); ok {
|
|
370
|
+
if cmd, ok := hMap["command"].(string); ok && cmd == "tanagram snapshot" {
|
|
371
|
+
beforeSubmitExists = true
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
stopExists := false
|
|
378
|
+
if stop, ok := hooks["stop"].([]interface{}); ok {
|
|
379
|
+
for _, h := range stop {
|
|
380
|
+
if hMap, ok := h.(map[string]interface{}); ok {
|
|
381
|
+
if cmd, ok := hMap["command"].(string); ok && cmd == "tanagram" {
|
|
382
|
+
stopExists = true
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return beforeSubmitExists, stopExists
|
|
389
|
+
}
|
package/commands/run.go
CHANGED
|
@@ -26,10 +26,10 @@ func spinner(stop chan bool, message string) {
|
|
|
26
26
|
for {
|
|
27
27
|
select {
|
|
28
28
|
case <-stop:
|
|
29
|
-
fmt.
|
|
29
|
+
fmt.Fprint(os.Stderr, "\r")
|
|
30
30
|
return
|
|
31
31
|
default:
|
|
32
|
-
fmt.
|
|
32
|
+
fmt.Fprintf(os.Stderr, "\r%s %s", chars[i%len(chars)], message)
|
|
33
33
|
i++
|
|
34
34
|
time.Sleep(100 * time.Millisecond)
|
|
35
35
|
}
|
|
@@ -80,7 +80,7 @@ func Run() error {
|
|
|
80
80
|
return err
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
fmt.
|
|
83
|
+
fmt.Fprintf(os.Stderr, "\nSyncing policies with LLM (processing %d changed file(s) in parallel)...\n", len(filesToSync))
|
|
84
84
|
|
|
85
85
|
syncStart := time.Now()
|
|
86
86
|
ctx := context.Background()
|
|
@@ -107,13 +107,13 @@ func Run() error {
|
|
|
107
107
|
for {
|
|
108
108
|
select {
|
|
109
109
|
case <-stop:
|
|
110
|
-
fmt.
|
|
110
|
+
fmt.Fprint(os.Stderr, "\r")
|
|
111
111
|
return
|
|
112
112
|
default:
|
|
113
113
|
mu.Lock()
|
|
114
114
|
c := completed
|
|
115
115
|
mu.Unlock()
|
|
116
|
-
fmt.
|
|
116
|
+
fmt.Fprintf(os.Stderr, "\r%s Processing files... (%d/%d completed)", chars[i%len(chars)], c, len(filesToSync))
|
|
117
117
|
i++
|
|
118
118
|
time.Sleep(100 * time.Millisecond)
|
|
119
119
|
}
|
|
@@ -145,7 +145,7 @@ func Run() error {
|
|
|
145
145
|
close(stop)
|
|
146
146
|
time.Sleep(50 * time.Millisecond)
|
|
147
147
|
mu.Lock()
|
|
148
|
-
fmt.
|
|
148
|
+
fmt.Fprintf(os.Stderr, "\r\033[K✗ Failed to process %s\n", result.relPath)
|
|
149
149
|
mu.Unlock()
|
|
150
150
|
return fmt.Errorf("failed to extract policies from %s: %w", result.file, result.err)
|
|
151
151
|
}
|
|
@@ -162,7 +162,7 @@ func Run() error {
|
|
|
162
162
|
// Atomic update of counter and output (prevents race with spinner)
|
|
163
163
|
mu.Lock()
|
|
164
164
|
completed++
|
|
165
|
-
fmt.
|
|
165
|
+
fmt.Fprintf(os.Stderr, "\r\033[K✓ %s - %d policies\n", result.relPath, len(result.policies))
|
|
166
166
|
mu.Unlock()
|
|
167
167
|
}
|
|
168
168
|
|
|
@@ -176,7 +176,7 @@ func Run() error {
|
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
syncDuration := time.Since(syncStart)
|
|
179
|
-
fmt.
|
|
179
|
+
fmt.Fprintf(os.Stderr, "\n✓ Synced %d policies from %d changed file(s)\n", totalPolicies, len(filesToSync))
|
|
180
180
|
|
|
181
181
|
// Track sync metrics
|
|
182
182
|
metrics.Track("cli.sync.complete", map[string]interface{}{
|
|
@@ -202,10 +202,10 @@ func Run() error {
|
|
|
202
202
|
cloudPolicies, err = cloudStorage.LoadCloudPoliciesAsParserFormat(repoInfo.Owner, repoInfo.Name)
|
|
203
203
|
if err != nil {
|
|
204
204
|
// Cloud policies exist but failed to load - warn but continue
|
|
205
|
-
fmt.
|
|
205
|
+
fmt.Fprintf(os.Stderr, "Warning: Failed to load cloud policies: %v\n", err)
|
|
206
206
|
cloudPolicies = []parser.Policy{}
|
|
207
207
|
} else if len(cloudPolicies) > 0 {
|
|
208
|
-
fmt.
|
|
208
|
+
fmt.Fprintf(os.Stderr, "Loaded %d cloud policies for %s/%s\n", len(cloudPolicies), repoInfo.Owner, repoInfo.Name)
|
|
209
209
|
}
|
|
210
210
|
}
|
|
211
211
|
// If repo detection failed, silently continue with local-only policies
|
|
@@ -214,7 +214,7 @@ func Run() error {
|
|
|
214
214
|
policies := storage.MergePolicies(localPolicies, cloudPolicies)
|
|
215
215
|
|
|
216
216
|
if len(policies) == 0 {
|
|
217
|
-
fmt.
|
|
217
|
+
fmt.Fprintln(os.Stderr, "No enforceable policies found")
|
|
218
218
|
return nil
|
|
219
219
|
}
|
|
220
220
|
|
|
@@ -223,9 +223,9 @@ func Run() error {
|
|
|
223
223
|
totalMerged := len(policies)
|
|
224
224
|
|
|
225
225
|
if totalCloud > 0 {
|
|
226
|
-
fmt.
|
|
226
|
+
fmt.Fprintf(os.Stderr, "Total policies: %d (%d local, %d cloud, %d after merge)\n", totalMerged, totalLocal, totalCloud, totalMerged)
|
|
227
227
|
} else {
|
|
228
|
-
fmt.
|
|
228
|
+
fmt.Fprintf(os.Stderr, "Loaded %d local policies\n", totalLocal)
|
|
229
229
|
}
|
|
230
230
|
|
|
231
231
|
// Check if a snapshot exists (from PreToolUse hook)
|
|
@@ -233,7 +233,7 @@ func Run() error {
|
|
|
233
233
|
useSnapshot := false
|
|
234
234
|
|
|
235
235
|
if snapshot.Exists(gitRoot) {
|
|
236
|
-
fmt.
|
|
236
|
+
fmt.Fprintln(os.Stderr, "Snapshot detected - checking only Claude's changes...")
|
|
237
237
|
|
|
238
238
|
// Load snapshot
|
|
239
239
|
snap, err := snapshot.Load(gitRoot)
|
|
@@ -271,7 +271,7 @@ func Run() error {
|
|
|
271
271
|
useSnapshot = true
|
|
272
272
|
} else {
|
|
273
273
|
// No snapshot - fall back to checking all git changes
|
|
274
|
-
fmt.
|
|
274
|
+
fmt.Fprintln(os.Stderr, "Checking all changes (unstaged + staged)...")
|
|
275
275
|
diffResult, err := gitpkg.GetAllChanges()
|
|
276
276
|
if err != nil {
|
|
277
277
|
return fmt.Errorf("error getting git diff: %w", err)
|
|
@@ -282,14 +282,14 @@ func Run() error {
|
|
|
282
282
|
|
|
283
283
|
if len(changesToCheck) == 0 {
|
|
284
284
|
if useSnapshot {
|
|
285
|
-
fmt.
|
|
285
|
+
fmt.Fprintln(os.Stderr, "No changes detected since snapshot")
|
|
286
286
|
} else {
|
|
287
|
-
fmt.
|
|
287
|
+
fmt.Fprintln(os.Stderr, "No changes to check")
|
|
288
288
|
}
|
|
289
289
|
return nil
|
|
290
290
|
}
|
|
291
291
|
|
|
292
|
-
fmt.
|
|
292
|
+
fmt.Fprintf(os.Stderr, "Scanning %d changed lines...\n\n", len(changesToCheck))
|
|
293
293
|
|
|
294
294
|
// Get API key once upfront before checking
|
|
295
295
|
apiKey, err := config.GetAPIKey()
|
|
@@ -325,6 +325,10 @@ func Run() error {
|
|
|
325
325
|
|
|
326
326
|
// Exit with code 2 to trigger Claude Code hook behavior
|
|
327
327
|
os.Exit(2)
|
|
328
|
+
} else if parentProcess == "cursor" {
|
|
329
|
+
// Format and output Cursor-friendly instructions to stdout
|
|
330
|
+
cursorInstructions := checker.FormatCursorInstructions(result)
|
|
331
|
+
fmt.Fprint(os.Stdout, cursorInstructions)
|
|
328
332
|
} else {
|
|
329
333
|
// Format and output violations to stderr
|
|
330
334
|
violationOutput := checker.FormatViolations(result)
|
|
@@ -333,6 +337,6 @@ func Run() error {
|
|
|
333
337
|
}
|
|
334
338
|
|
|
335
339
|
// No violations found - output success message
|
|
336
|
-
fmt.
|
|
340
|
+
fmt.Fprintln(os.Stderr, "✓ No policy violations found")
|
|
337
341
|
return nil
|
|
338
342
|
}
|
package/commands/snapshot.go
CHANGED
|
@@ -5,21 +5,33 @@ import (
|
|
|
5
5
|
|
|
6
6
|
"github.com/tanagram/cli/snapshot"
|
|
7
7
|
"github.com/tanagram/cli/storage"
|
|
8
|
+
"github.com/tanagram/cli/utils"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
// Extracted variables so that tests can override these
|
|
12
|
+
var (
|
|
13
|
+
findGitRoot = storage.FindGitRoot
|
|
14
|
+
createSnapshot = snapshot.Create
|
|
15
|
+
getParentProcess = utils.GetParentProcess
|
|
8
16
|
)
|
|
9
17
|
|
|
10
18
|
// Snapshot creates a snapshot of the current working directory state
|
|
11
19
|
func Snapshot() error {
|
|
12
20
|
// Find git root
|
|
13
|
-
gitRoot, err :=
|
|
21
|
+
gitRoot, err := findGitRoot()
|
|
14
22
|
if err != nil {
|
|
15
23
|
return err
|
|
16
24
|
}
|
|
17
25
|
|
|
18
26
|
// Create snapshot
|
|
19
|
-
if err :=
|
|
27
|
+
if err := createSnapshot(gitRoot); err != nil {
|
|
20
28
|
return fmt.Errorf("failed to create snapshot: %w", err)
|
|
21
29
|
}
|
|
22
30
|
|
|
23
|
-
|
|
31
|
+
parentProcess := getParentProcess()
|
|
32
|
+
if parentProcess == "Cursor" || parentProcess == "cursor" {
|
|
33
|
+
// https://cursor.com/docs/agent/hooks#beforesubmitprompt
|
|
34
|
+
fmt.Println(`{ "continue": true }`)
|
|
35
|
+
}
|
|
24
36
|
return nil
|
|
25
37
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
package commands
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"io"
|
|
6
|
+
"os"
|
|
7
|
+
"testing"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
func TestSnapshot_Cursor(t *testing.T) {
|
|
11
|
+
// Save original functions
|
|
12
|
+
origFindGitRoot := findGitRoot
|
|
13
|
+
origCreateSnapshot := createSnapshot
|
|
14
|
+
origGetParentProcess := getParentProcess
|
|
15
|
+
defer func() {
|
|
16
|
+
findGitRoot = origFindGitRoot
|
|
17
|
+
createSnapshot = origCreateSnapshot
|
|
18
|
+
getParentProcess = origGetParentProcess
|
|
19
|
+
}()
|
|
20
|
+
|
|
21
|
+
// Mock functions
|
|
22
|
+
findGitRoot = func() (string, error) {
|
|
23
|
+
return "/tmp/mock-git-root", nil
|
|
24
|
+
}
|
|
25
|
+
createSnapshot = func(root string) error {
|
|
26
|
+
return nil
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
tests := []struct {
|
|
30
|
+
name string
|
|
31
|
+
parentProcess string
|
|
32
|
+
expectedOutput string
|
|
33
|
+
}{
|
|
34
|
+
{
|
|
35
|
+
name: "Cursor process",
|
|
36
|
+
parentProcess: "Cursor",
|
|
37
|
+
expectedOutput: "{ \"continue\": true }\n",
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: "cursor process lowercase",
|
|
41
|
+
parentProcess: "cursor",
|
|
42
|
+
expectedOutput: "{ \"continue\": true }\n",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: "Claude",
|
|
46
|
+
parentProcess: "claude",
|
|
47
|
+
expectedOutput: "",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: "Terminal process",
|
|
51
|
+
parentProcess: "zsh",
|
|
52
|
+
expectedOutput: "",
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
for _, tt := range tests {
|
|
57
|
+
t.Run(tt.name, func(t *testing.T) {
|
|
58
|
+
// Set mock
|
|
59
|
+
getParentProcess = func() string {
|
|
60
|
+
return tt.parentProcess
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Capture stdout
|
|
64
|
+
oldStdout := os.Stdout
|
|
65
|
+
r, w, _ := os.Pipe()
|
|
66
|
+
os.Stdout = w
|
|
67
|
+
|
|
68
|
+
err := Snapshot()
|
|
69
|
+
|
|
70
|
+
// Restore stdout
|
|
71
|
+
w.Close()
|
|
72
|
+
os.Stdout = oldStdout
|
|
73
|
+
|
|
74
|
+
// Read captured output
|
|
75
|
+
var buf bytes.Buffer
|
|
76
|
+
io.Copy(&buf, r)
|
|
77
|
+
output := buf.String()
|
|
78
|
+
|
|
79
|
+
if err != nil {
|
|
80
|
+
t.Errorf("Snapshot() returned unexpected error: %v", err)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if output != tt.expectedOutput {
|
|
84
|
+
t.Errorf("Expected output %q, got %q", tt.expectedOutput, output)
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
}
|