@tanagram/cli 0.4.2 → 0.4.3

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.
@@ -8,34 +8,11 @@ import (
8
8
  "strings"
9
9
  )
10
10
 
11
- // ConfigClaude sets up the Claude Code hook in ~/.claude/settings.json
12
- func ConfigClaude() error {
13
- // Get the Claude settings file path
14
- home, err := os.UserHomeDir()
11
+ // ConfigClaude sets up the Claude Code hook in the specified settings file
12
+ func ConfigClaude(settingsPath string) error {
13
+ settings, err := loadConfig(settingsPath)
15
14
  if err != nil {
16
- return fmt.Errorf("failed to get home directory: %w", err)
17
- }
18
-
19
- settingsPath := filepath.Join(home, ".claude", "settings.json")
20
-
21
- // Read existing settings or create new structure
22
- var settings map[string]interface{}
23
-
24
- // Check if file exists
25
- if _, err := os.Stat(settingsPath); os.IsNotExist(err) {
26
- // File doesn't exist, create new settings
27
- settings = make(map[string]interface{})
28
- } else {
29
- // File exists, read it
30
- data, err := os.ReadFile(settingsPath)
31
- if err != nil {
32
- return fmt.Errorf("failed to read Claude settings: %w", err)
33
- }
34
-
35
- // Parse JSON
36
- if err := json.Unmarshal(data, &settings); err != nil {
37
- return fmt.Errorf("failed to parse Claude settings (invalid JSON): %w", err)
38
- }
15
+ return fmt.Errorf("failed to load Claude settings: %w", err)
39
16
  }
40
17
 
41
18
  // Check if hooks already exist
@@ -102,6 +79,7 @@ func ConfigClaude() error {
102
79
  }
103
80
  }
104
81
  }
82
+
105
83
  }
106
84
  }
107
85
 
@@ -138,20 +116,8 @@ func ConfigClaude() error {
138
116
  hooks["PostToolUse"] = postToolUse
139
117
  }
140
118
 
141
- // Ensure .claude directory exists
142
- claudeDir := filepath.Join(home, ".claude")
143
- if err := os.MkdirAll(claudeDir, 0755); err != nil {
144
- return fmt.Errorf("failed to create .claude directory: %w", err)
145
- }
146
-
147
- // Write updated settings back to file
148
- data, err := json.MarshalIndent(settings, "", " ")
149
- if err != nil {
150
- return fmt.Errorf("failed to marshal settings: %w", err)
151
- }
152
-
153
- if err := os.WriteFile(settingsPath, data, 0644); err != nil {
154
- return fmt.Errorf("failed to write settings: %w", err)
119
+ if err := saveConfig(settingsPath, settings); err != nil {
120
+ return fmt.Errorf("failed to save settings: %w", err)
155
121
  }
156
122
 
157
123
  fmt.Println("✓ Tanagram hooks added to ~/.claude/settings.json")
@@ -164,35 +130,140 @@ func ConfigClaude() error {
164
130
  return nil
165
131
  }
166
132
 
167
- // ConfigList shows where Tanagram hooks are installed
168
- func ConfigList() error {
169
- home, err := os.UserHomeDir()
133
+ // ConfigCursor sets up the Cursor hook in the specified settings file
134
+ func ConfigCursor(hooksPath string) error {
135
+
136
+ hooksConfig, err := loadConfig(hooksPath)
170
137
  if err != nil {
171
- return fmt.Errorf("failed to get home directory: %w", err)
138
+ return fmt.Errorf("failed to load Cursor hooks: %w", err)
139
+ }
140
+
141
+ // Ensure version field exists
142
+ if _, ok := hooksConfig["version"]; !ok {
143
+ hooksConfig["version"] = 1
144
+ }
145
+
146
+ // Check if hooks object exists
147
+ hooks, hooksExist := hooksConfig["hooks"].(map[string]interface{})
148
+ if !hooksExist {
149
+ hooks = make(map[string]interface{})
150
+ hooksConfig["hooks"] = hooks
151
+ }
152
+
153
+ // Check if beforeSubmitPrompt exists
154
+ beforeSubmitPrompt, beforeSubmitExists := hooks["beforeSubmitPrompt"].([]interface{})
155
+ if !beforeSubmitExists {
156
+ beforeSubmitPrompt = []interface{}{}
172
157
  }
173
158
 
159
+ // Check if stop exists
160
+ stopHooks, stopExists := hooks["stop"].([]interface{})
161
+ if !stopExists {
162
+ stopHooks = []interface{}{}
163
+ }
164
+
165
+ // Check if tanagram hooks already exist
166
+ beforeSubmitHookExists := false
167
+ stopHookExists := false
168
+
169
+ for _, hook := range beforeSubmitPrompt {
170
+ hookMap, ok := hook.(map[string]interface{})
171
+ if !ok {
172
+ continue
173
+ }
174
+
175
+ if cmd, ok := hookMap["command"].(string); ok && cmd == "tanagram snapshot" {
176
+ beforeSubmitHookExists = true
177
+ break
178
+ }
179
+ }
180
+
181
+ for _, hook := range stopHooks {
182
+ hookMap, ok := hook.(map[string]interface{})
183
+ if !ok {
184
+ continue
185
+ }
186
+
187
+ if cmd, ok := hookMap["command"].(string); ok && cmd == "tanagram" {
188
+ stopHookExists = true
189
+ break
190
+ }
191
+ }
192
+
193
+ if beforeSubmitHookExists && stopHookExists {
194
+ fmt.Println("✓ Tanagram hooks are already configured in ~/.cursor/hooks.json")
195
+ return nil
196
+ }
197
+
198
+ if !beforeSubmitHookExists {
199
+ beforeSubmitHook := map[string]interface{}{
200
+ "command": "tanagram snapshot",
201
+ }
202
+ beforeSubmitPrompt = append(beforeSubmitPrompt, beforeSubmitHook)
203
+ hooks["beforeSubmitPrompt"] = beforeSubmitPrompt
204
+ }
205
+
206
+ if !stopHookExists {
207
+ stopHook := map[string]interface{}{
208
+ "command": "tanagram",
209
+ }
210
+ stopHooks = append(stopHooks, stopHook)
211
+ hooks["stop"] = stopHooks
212
+ }
213
+
214
+ if err := saveConfig(hooksPath, hooksConfig); err != nil {
215
+ return fmt.Errorf("failed to save hooks: %w", err)
216
+ }
217
+
218
+ fmt.Println("✓ Tanagram hooks added to ~/.cursor/hooks.json")
219
+ fmt.Println("\nCursor will now:")
220
+ fmt.Println(" - Snapshot file state before each prompt (beforeSubmitPrompt)")
221
+ fmt.Println(" - Check only Cursor's changes after agent completes (stop)")
222
+ fmt.Println(" - Send policy violations to Cursor for automatic fixing")
223
+ fmt.Println("\nThis prevents false positives from user-written code!")
224
+
225
+ return nil
226
+ }
227
+
228
+ // ConfigList shows where Tanagram hooks are installed
229
+ func ConfigList() error {
174
230
  // Check user settings (~/.claude/settings.json)
175
- userSettingsPath := filepath.Join(home, ".claude", "settings.json")
176
- userStatus := checkHookStatus(userSettingsPath)
231
+ userSettingsPath, err := GetHomeConfigPath(".claude", "settings.json")
232
+ if err != nil {
233
+ return err
234
+ }
235
+ userStatus := checkClaudeHookStatus(userSettingsPath)
177
236
 
178
237
  // Check project settings (./.claude/settings.json)
179
238
  projectSettingsPath := ".claude/settings.json"
180
- projectStatus := checkHookStatus(projectSettingsPath)
239
+ projectStatus := checkClaudeHookStatus(projectSettingsPath)
240
+
241
+ // Check Cursor hooks (~/.cursor/hooks.json)
242
+ cursorHooksPath, err := GetHomeConfigPath(".cursor", "hooks.json")
243
+ if err != nil {
244
+ return err
245
+ }
246
+ cursorStatus := checkCursorHookStatus(cursorHooksPath)
181
247
 
182
248
  // Print results
183
249
  fmt.Println("Claude Code Hook Status:")
184
250
 
185
251
  fmt.Printf("User Settings (~/.claude/settings.json):\n")
186
- printHookStatus(userStatus, userSettingsPath)
252
+ printClaudeHookStatus(userStatus, userSettingsPath)
187
253
 
188
254
  fmt.Printf("\nProject Settings (./.claude/settings.json):\n")
189
- printHookStatus(projectStatus, projectSettingsPath)
255
+ printClaudeHookStatus(projectStatus, projectSettingsPath)
256
+
257
+ fmt.Println("\nCursor Hook Status:")
258
+
259
+ fmt.Printf("User Settings (~/.cursor/hooks.json):\n")
260
+ printCursorHookStatus(cursorStatus, cursorHooksPath)
190
261
 
191
262
  return nil
192
263
  }
193
264
 
194
- // HookStatus represents the status of a hook configuration
195
- type HookStatus struct {
265
+ // ClaudeHookStatus represents the status of a hook configuration
266
+ type ClaudeHookStatus struct {
196
267
  FileExists bool
197
268
  PreHookExists bool
198
269
  PostHookExists bool
@@ -202,9 +273,20 @@ type HookStatus struct {
202
273
  Error error
203
274
  }
204
275
 
205
- // checkHookStatus checks the status of Tanagram hook in a settings file
206
- func checkHookStatus(settingsPath string) HookStatus {
207
- status := HookStatus{
276
+ // CursorHookStatus represents the status of a Cursor hook configuration
277
+ type CursorHookStatus struct {
278
+ FileExists bool
279
+ BeforeSubmitExists bool
280
+ StopExists bool
281
+ IsUpToDate bool
282
+ BeforeSubmitCommand string
283
+ StopCommand string
284
+ Error error
285
+ }
286
+
287
+ // checkClaudeHookStatus checks the status of Tanagram hook in a settings file
288
+ func checkClaudeHookStatus(settingsPath string) ClaudeHookStatus {
289
+ status := ClaudeHookStatus{
208
290
  FileExists: false,
209
291
  PreHookExists: false,
210
292
  PostHookExists: false,
@@ -220,18 +302,12 @@ func checkHookStatus(settingsPath string) HookStatus {
220
302
 
221
303
  status.FileExists = true
222
304
 
223
- data, err := os.ReadFile(settingsPath)
305
+ settings, err := loadConfig(settingsPath)
224
306
  if err != nil {
225
307
  status.Error = err
226
308
  return status
227
309
  }
228
310
 
229
- var settings map[string]interface{}
230
- if err := json.Unmarshal(data, &settings); err != nil {
231
- status.Error = fmt.Errorf("invalid JSON: %w", err)
232
- return status
233
- }
234
-
235
311
  hooks, ok := settings["hooks"].(map[string]interface{})
236
312
  if !ok {
237
313
  return status
@@ -310,8 +386,74 @@ func checkHookStatus(settingsPath string) HookStatus {
310
386
  return status
311
387
  }
312
388
 
313
- // printHookStatus prints the status of a hook in a human-readable format
314
- func printHookStatus(status HookStatus, path string) {
389
+ // checkCursorHookStatus checks the status of Tanagram hooks in a Cursor hooks.json file
390
+ func checkCursorHookStatus(hooksPath string) CursorHookStatus {
391
+ status := CursorHookStatus{
392
+ FileExists: false,
393
+ BeforeSubmitExists: false,
394
+ StopExists: false,
395
+ IsUpToDate: false,
396
+ BeforeSubmitCommand: "",
397
+ StopCommand: "",
398
+ Error: nil,
399
+ }
400
+
401
+ if _, err := os.Stat(hooksPath); os.IsNotExist(err) {
402
+ return status
403
+ }
404
+
405
+ status.FileExists = true
406
+
407
+ hooksConfig, err := loadConfig(hooksPath)
408
+ if err != nil {
409
+ status.Error = err
410
+ return status
411
+ }
412
+
413
+ hooks, ok := hooksConfig["hooks"].(map[string]interface{})
414
+ if !ok {
415
+ return status
416
+ }
417
+
418
+ if beforeSubmitPrompt, ok := hooks["beforeSubmitPrompt"].([]interface{}); ok {
419
+ for _, hook := range beforeSubmitPrompt {
420
+ hookMap, ok := hook.(map[string]interface{})
421
+ if !ok {
422
+ continue
423
+ }
424
+
425
+ cmd, cmdOk := hookMap["command"].(string)
426
+ if cmdOk && cmd == "tanagram snapshot" {
427
+ status.BeforeSubmitExists = true
428
+ status.BeforeSubmitCommand = cmd
429
+ }
430
+ }
431
+ }
432
+
433
+ if stopHooks, ok := hooks["stop"].([]interface{}); ok {
434
+ for _, hook := range stopHooks {
435
+ hookMap, ok := hook.(map[string]interface{})
436
+ if !ok {
437
+ continue
438
+ }
439
+
440
+ cmd, cmdOk := hookMap["command"].(string)
441
+ if cmdOk && cmd == "tanagram" {
442
+ status.StopExists = true
443
+ status.StopCommand = cmd
444
+ }
445
+ }
446
+ }
447
+
448
+ if status.BeforeSubmitExists && status.StopExists {
449
+ status.IsUpToDate = true
450
+ }
451
+
452
+ return status
453
+ }
454
+
455
+ // printClaudeHookStatus prints the status of a hook in a human-readable format
456
+ func printClaudeHookStatus(status ClaudeHookStatus, path string) {
315
457
  if status.Error != nil {
316
458
  fmt.Printf(" ✗ Error: %v\n", status.Error)
317
459
  return
@@ -350,18 +492,57 @@ func printHookStatus(status HookStatus, path string) {
350
492
  }
351
493
  }
352
494
 
353
- // EnsureClaudeConfigured checks if Claude Code hooks are configured,
495
+ // printCursorHookStatus prints the status of a Cursor hook in a human-readable format
496
+ func printCursorHookStatus(status CursorHookStatus, path string) {
497
+ if status.Error != nil {
498
+ fmt.Printf(" ✗ Error: %v\n", status.Error)
499
+ return
500
+ }
501
+
502
+ if !status.FileExists {
503
+ fmt.Printf(" ○ Not configured (file does not exist)\n")
504
+ fmt.Printf(" → Run: tanagram config cursor\n")
505
+ return
506
+ }
507
+
508
+ if !status.BeforeSubmitExists && !status.StopExists {
509
+ fmt.Printf(" ○ Not configured\n")
510
+ fmt.Printf(" → Run: tanagram config cursor\n")
511
+ return
512
+ }
513
+
514
+ if status.IsUpToDate {
515
+ fmt.Printf(" ✓ Configured and up to date\n")
516
+ fmt.Printf(" beforeSubmitPrompt: %s\n", status.BeforeSubmitCommand)
517
+ fmt.Printf(" stop: %s\n", status.StopCommand)
518
+ fmt.Printf(" Location: %s\n", path)
519
+ } else {
520
+ fmt.Printf(" ⚠ Configured but incomplete\n")
521
+ if status.BeforeSubmitExists {
522
+ fmt.Printf(" ✓ beforeSubmitPrompt: %s\n", status.BeforeSubmitCommand)
523
+ } else {
524
+ fmt.Printf(" ✗ beforeSubmitPrompt: missing\n")
525
+ }
526
+ if status.StopExists {
527
+ fmt.Printf(" ✓ stop: %s\n", status.StopCommand)
528
+ } else {
529
+ fmt.Printf(" ✗ stop: missing\n")
530
+ }
531
+ fmt.Printf(" → Run: tanagram config cursor\n")
532
+ }
533
+ }
534
+
535
+ // ensureClaudeConfigured checks if Claude Code hooks are configured,
354
536
  // and automatically sets them up if not. This is called on first run
355
537
  // of commands that need Claude Code integration.
356
- func EnsureClaudeConfigured() error {
357
- home, err := os.UserHomeDir()
538
+ func ensureClaudeConfigured() error {
539
+ settingsPath, err := GetHomeConfigPath(".claude", "settings.json")
358
540
  if err != nil {
359
541
  // Silently skip if we can't get home dir - not critical
360
542
  return nil
361
543
  }
362
544
 
363
- settingsPath := filepath.Join(home, ".claude", "settings.json")
364
- status := checkHookStatus(settingsPath)
545
+ status := checkClaudeHookStatus(settingsPath)
365
546
 
366
547
  // If hooks are already configured, we're done
367
548
  if status.IsUpToDate {
@@ -371,7 +552,7 @@ func EnsureClaudeConfigured() error {
371
552
  // If file doesn't exist or hooks aren't configured, set them up
372
553
  if !status.FileExists || !status.PreHookExists || !status.PostHookExists {
373
554
  fmt.Println("Setting up Claude Code integration...")
374
- if err := ConfigClaude(); err != nil {
555
+ if err := ConfigClaude(settingsPath); err != nil {
375
556
  // Don't fail the command if hook setup fails - just warn
376
557
  fmt.Fprintf(os.Stderr, "Warning: Failed to setup Claude Code hooks: %v\n", err)
377
558
  fmt.Fprintf(os.Stderr, "You can manually setup hooks later with: tanagram config claude\n\n")
@@ -382,3 +563,95 @@ func EnsureClaudeConfigured() error {
382
563
 
383
564
  return nil
384
565
  }
566
+
567
+ // ensureCursorConfigured checks if Cursor hooks are configured,
568
+ // and automatically sets them up if not. This is called on first run
569
+ // of commands that need Cursor integration.
570
+ func ensureCursorConfigured() error {
571
+ hooksPath, err := GetHomeConfigPath(".cursor", "hooks.json")
572
+ if err != nil {
573
+ // Silently skip if we can't get home dir - not critical
574
+ return nil
575
+ }
576
+
577
+ status := checkCursorHookStatus(hooksPath)
578
+
579
+ // If hooks are already configured, we're done
580
+ if status.IsUpToDate {
581
+ return nil
582
+ }
583
+
584
+ // If file doesn't exist or hooks aren't configured, set them up
585
+ if !status.FileExists || !status.BeforeSubmitExists || !status.StopExists {
586
+ fmt.Println("Setting up Cursor integration...")
587
+ if err := ConfigCursor(hooksPath); err != nil {
588
+ // Don't fail the command if hook setup fails - just warn
589
+ fmt.Fprintf(os.Stderr, "Warning: Failed to setup Cursor hooks: %v\n", err)
590
+ fmt.Fprintf(os.Stderr, "You can manually setup hooks later with: tanagram config cursor\n\n")
591
+ return nil
592
+ }
593
+ fmt.Println() // Add blank line after setup message
594
+ }
595
+
596
+ return nil
597
+ }
598
+
599
+ // EnsureHooksConfigured ensures that both Claude Code and Cursor hooks are configured.
600
+ // This is a convenience function for commands that need both integrations.
601
+ func EnsureHooksConfigured() error {
602
+ if err := ensureClaudeConfigured(); err != nil {
603
+ return err
604
+ }
605
+ if err := ensureCursorConfigured(); err != nil {
606
+ return err
607
+ }
608
+ return nil
609
+ }
610
+
611
+ // Helpers
612
+
613
+ func GetHomeConfigPath(parts ...string) (string, error) {
614
+ home, err := os.UserHomeDir()
615
+ if err != nil {
616
+ return "", fmt.Errorf("failed to get home directory: %w", err)
617
+ }
618
+ // Prepend home to parts
619
+ allParts := append([]string{home}, parts...)
620
+ return filepath.Join(allParts...), nil
621
+ }
622
+
623
+ func loadConfig(path string) (map[string]interface{}, error) {
624
+ if _, err := os.Stat(path); os.IsNotExist(err) {
625
+ return make(map[string]interface{}), nil
626
+ }
627
+
628
+ data, err := os.ReadFile(path)
629
+ if err != nil {
630
+ return nil, fmt.Errorf("failed to read config: %w", err)
631
+ }
632
+
633
+ var config map[string]interface{}
634
+ if err := json.Unmarshal(data, &config); err != nil {
635
+ return nil, fmt.Errorf("failed to parse config (invalid JSON): %w", err)
636
+ }
637
+
638
+ return config, nil
639
+ }
640
+
641
+ func saveConfig(path string, config map[string]interface{}) error {
642
+ dir := filepath.Dir(path)
643
+ if err := os.MkdirAll(dir, 0755); err != nil {
644
+ return fmt.Errorf("failed to create directory: %w", err)
645
+ }
646
+
647
+ data, err := json.MarshalIndent(config, "", " ")
648
+ if err != nil {
649
+ return fmt.Errorf("failed to marshal config: %w", err)
650
+ }
651
+
652
+ if err := os.WriteFile(path, data, 0644); err != nil {
653
+ return fmt.Errorf("failed to write config: %w", err)
654
+ }
655
+
656
+ return nil
657
+ }
@@ -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/main.go CHANGED
@@ -43,8 +43,8 @@ func main() {
43
43
  metrics.Track("cli.command.execute", map[string]interface{}{
44
44
  "command": "run",
45
45
  })
46
- // Auto-setup Claude Code hooks on first run
47
- if err := commands.EnsureClaudeConfigured(); err != nil {
46
+ // Auto-setup hooks on first run
47
+ if err := commands.EnsureHooksConfigured(); err != nil {
48
48
  fmt.Fprintf(os.Stderr, "Error: %v\n", err)
49
49
  exitCode = 1
50
50
  return
@@ -59,8 +59,8 @@ func main() {
59
59
  metrics.Track("cli.command.execute", map[string]interface{}{
60
60
  "command": "sync",
61
61
  })
62
- // Auto-setup Claude Code hooks on first run
63
- if err := commands.EnsureClaudeConfigured(); err != nil {
62
+ // Auto-setup hooks on first run
63
+ if err := commands.EnsureHooksConfigured(); err != nil {
64
64
  fmt.Fprintf(os.Stderr, "Error: %v\n", err)
65
65
  exitCode = 1
66
66
  return
@@ -70,8 +70,8 @@ func main() {
70
70
  metrics.Track("cli.command.execute", map[string]interface{}{
71
71
  "command": "list",
72
72
  })
73
- // Auto-setup Claude Code hooks on first run
74
- if err := commands.EnsureClaudeConfigured(); err != nil {
73
+ // Auto-setup hooks on first run
74
+ if err := commands.EnsureHooksConfigured(); err != nil {
75
75
  fmt.Fprintf(os.Stderr, "Error: %v\n", err)
76
76
  exitCode = 1
77
77
  return
@@ -83,6 +83,7 @@ func main() {
83
83
  fmt.Fprintf(os.Stderr, "Usage: tanagram config <subcommand>\n")
84
84
  fmt.Fprintf(os.Stderr, "\nAvailable subcommands:\n")
85
85
  fmt.Fprintf(os.Stderr, " claude Setup Claude Code hook\n")
86
+ fmt.Fprintf(os.Stderr, " cursor Setup Cursor hook\n")
86
87
  fmt.Fprintf(os.Stderr, " list Show hook installation status\n")
87
88
  exitCode = 1
88
89
  return
@@ -93,7 +94,22 @@ func main() {
93
94
  metrics.Track("cli.command.execute", map[string]interface{}{
94
95
  "command": "config.claude",
95
96
  })
96
- err = commands.ConfigClaude()
97
+ settingsPath, pathErr := commands.GetHomeConfigPath(".claude", "settings.json")
98
+ if pathErr != nil {
99
+ err = pathErr
100
+ break
101
+ }
102
+ err = commands.ConfigClaude(settingsPath)
103
+ case "cursor":
104
+ metrics.Track("cli.command.execute", map[string]interface{}{
105
+ "command": "config.cursor",
106
+ })
107
+ hooksPath, pathErr := commands.GetHomeConfigPath(".cursor", "hooks.json")
108
+ if pathErr != nil {
109
+ err = pathErr
110
+ break
111
+ }
112
+ err = commands.ConfigCursor(hooksPath)
97
113
  case "list":
98
114
  metrics.Track("cli.command.execute", map[string]interface{}{
99
115
  "command": "config.list",
@@ -184,6 +200,7 @@ COMMANDS:
184
200
  sync Manually sync instruction files to cache
185
201
  list Show all cached policies
186
202
  config claude Setup Claude Code hook automatically
203
+ config cursor Setup Cursor hook automatically
187
204
  config list Show hook installation status
188
205
  version Show the CLI version
189
206
  help Show this help message
@@ -197,6 +214,7 @@ EXAMPLES:
197
214
  tanagram sync # Manually sync local instruction files
198
215
  tanagram list # View all cached policies
199
216
  tanagram config claude # Setup Claude Code hooks in ~/.claude/settings.json
217
+ tanagram config cursor # Setup Cursor hooks in ~/.cursor/hooks.json
200
218
  tanagram config list # Show where hooks are installed
201
219
  tanagram version # Show the version
202
220
 
@@ -208,9 +226,15 @@ INSTRUCTION FILES:
208
226
  Policies are cached and automatically resynced when files change.
209
227
 
210
228
  HOOK WORKFLOW:
211
- When configured with 'tanagram config claude', two hooks are installed:
229
+ When configured with 'tanagram config claude' or 'tanagram config cursor':
230
+
231
+ Claude Code:
212
232
  1. PreToolUse (Edit|Write): Creates snapshot before Claude makes changes
213
233
  2. PostToolUse (Edit|Write): Checks only Claude's changes against policies
234
+
235
+ Cursor:
236
+ 1. beforeSubmitPrompt: Creates snapshot before agent starts working
237
+ 2. stop: Checks only Cursor's changes after agent completes
214
238
 
215
239
  This prevents false positives from user-written code!
216
240
  `
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanagram/cli",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "Tanagram - Catch sloppy code before it ships",
5
5
  "main": "index.js",
6
6
  "bin": {