@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.
@@ -2,7 +2,9 @@ package checker
2
2
 
3
3
  import (
4
4
  "context"
5
+ "encoding/json"
5
6
  "fmt"
7
+ "os"
6
8
  "sort"
7
9
  "strings"
8
10
 
@@ -138,3 +140,65 @@ func FormatClaudeInstructions(result *CheckResult) string {
138
140
 
139
141
  return output.String()
140
142
  }
143
+
144
+ // FormatCursorInstructions formats violations as a JSON response for Cursor
145
+ // structured as {"followup_message": "<text>"}
146
+ func FormatCursorInstructions(result *CheckResult) string {
147
+ if len(result.Violations) == 0 {
148
+ return ""
149
+ }
150
+
151
+ // Group violations by file
152
+ violationsByFile := make(map[string][]Violation)
153
+ for _, v := range result.Violations {
154
+ violationsByFile[v.File] = append(violationsByFile[v.File], v)
155
+ }
156
+
157
+ // Sort files for consistent output
158
+ files := make([]string, 0, len(violationsByFile))
159
+ for file := range violationsByFile {
160
+ files = append(files, file)
161
+ }
162
+ sort.Strings(files)
163
+
164
+ var message strings.Builder
165
+ message.WriteString("POLICY VIOLATIONS DETECTED - PLEASE FIX\n\n")
166
+ message.WriteString(fmt.Sprintf("Found %d policy violation(s) that need to be fixed:\n\n", len(result.Violations)))
167
+
168
+ for _, file := range files {
169
+ violations := violationsByFile[file]
170
+
171
+ // Sort violations by line number
172
+ sort.Slice(violations, func(i, j int) bool {
173
+ return violations[i].LineNumber < violations[j].LineNumber
174
+ })
175
+
176
+ message.WriteString(fmt.Sprintf("File: %s\n", file))
177
+
178
+ for _, v := range violations {
179
+ message.WriteString(fmt.Sprintf("\n Line %d:\n", v.LineNumber))
180
+ message.WriteString(fmt.Sprintf(" Code: %s\n", v.Code))
181
+ message.WriteString(fmt.Sprintf(" Policy: %s\n", v.PolicyName))
182
+ message.WriteString(fmt.Sprintf(" Issue: %s\n", v.Message))
183
+ message.WriteString(fmt.Sprintf(" Action: Review and fix this code to comply with the policy.\n"))
184
+ }
185
+ message.WriteString("\n")
186
+ }
187
+
188
+ message.WriteString("Please fix all violations listed above and ensure the code complies with all policies.\n")
189
+
190
+ // Wrap in JSON structure
191
+ response := struct {
192
+ FollowupMessage string `json:"followup_message"`
193
+ }{
194
+ FollowupMessage: message.String(),
195
+ }
196
+
197
+ bytes, err := json.MarshalIndent(response, "", " ")
198
+ if err != nil {
199
+ fmt.Fprintf(os.Stderr, "Error formatting violations: %v\n", err)
200
+ return "{}"
201
+ }
202
+
203
+ return string(bytes)
204
+ }
@@ -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
+ }