@tanagram/cli 0.4.3 → 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
+ }
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.Print("\r")
29
+ fmt.Fprint(os.Stderr, "\r")
30
30
  return
31
31
  default:
32
- fmt.Printf("\r%s %s", chars[i%len(chars)], message)
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.Printf("\nSyncing policies with LLM (processing %d changed file(s) in parallel)...\n", len(filesToSync))
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.Print("\r")
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.Printf("\r%s Processing files... (%d/%d completed)", chars[i%len(chars)], c, len(filesToSync))
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.Printf("\r\033[K✗ Failed to process %s\n", result.relPath)
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.Printf("\r\033[K✓ %s - %d policies\n", result.relPath, len(result.policies))
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.Printf("\n✓ Synced %d policies from %d changed file(s)\n", totalPolicies, len(filesToSync))
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.Printf("Warning: Failed to load cloud policies: %v\n", err)
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.Printf("Loaded %d cloud policies for %s/%s\n", len(cloudPolicies), repoInfo.Owner, repoInfo.Name)
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.Println("No enforceable policies found")
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.Printf("Total policies: %d (%d local, %d cloud, %d after merge)\n", totalMerged, totalLocal, totalCloud, totalMerged)
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.Printf("Loaded %d local policies\n", totalLocal)
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.Println("Snapshot detected - checking only Claude's changes...")
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.Println("Checking all changes (unstaged + staged)...")
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.Println("No changes detected since snapshot")
285
+ fmt.Fprintln(os.Stderr, "No changes detected since snapshot")
286
286
  } else {
287
- fmt.Println("No changes to check")
287
+ fmt.Fprintln(os.Stderr, "No changes to check")
288
288
  }
289
289
  return nil
290
290
  }
291
291
 
292
- fmt.Printf("Scanning %d changed lines...\n\n", len(changesToCheck))
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.Println("✓ No policy violations found")
340
+ fmt.Fprintln(os.Stderr, "✓ No policy violations found")
337
341
  return nil
338
342
  }
@@ -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 := storage.FindGitRoot()
21
+ gitRoot, err := findGitRoot()
14
22
  if err != nil {
15
23
  return err
16
24
  }
17
25
 
18
26
  // Create snapshot
19
- if err := snapshot.Create(gitRoot); err != nil {
27
+ if err := createSnapshot(gitRoot); err != nil {
20
28
  return fmt.Errorf("failed to create snapshot: %w", err)
21
29
  }
22
30
 
23
- // Silent success - no output for hooks
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
+ }
package/main.go CHANGED
@@ -1,12 +1,15 @@
1
1
  package main
2
2
 
3
3
  import (
4
+ "encoding/json"
4
5
  "fmt"
6
+ "io"
5
7
  "os"
6
8
 
7
9
  "github.com/tanagram/cli/commands"
8
10
  "github.com/tanagram/cli/metrics"
9
11
  "github.com/tanagram/cli/tui"
12
+ "github.com/tanagram/cli/utils"
10
13
  )
11
14
 
12
15
  // Version is set in install.js via `-X` ldflags
@@ -37,6 +40,29 @@ func main() {
37
40
  "subcommand": subcommand,
38
41
  })
39
42
 
43
+ // THIS IS A HUGE HACK
44
+ // Cursor runs its hooks in the ~/.cursor directory as cwd
45
+ // Claude runs its hooks directly as a subprocess, with your terminal directory as cwd
46
+ // We have existing code that depends on cwd being the workspace root
47
+ // So we detect if we're running through Cursor (`GetParentProcess` does some magic here)
48
+ // and set cwd based on the workspace_root that Cursor gives us.
49
+ // TODO: handle 0 or multiple workspace_roots
50
+ if utils.GetParentProcess() == "cursor" {
51
+ input, err := io.ReadAll(os.Stdin)
52
+ if err == nil {
53
+ var payload struct {
54
+ WorkspaceRoots []string `json:"workspace_roots"`
55
+ }
56
+ if err := json.Unmarshal(input, &payload); err != nil {
57
+ fmt.Fprintf(os.Stderr, "Error parsing input: %v\n", err)
58
+ } else if len(payload.WorkspaceRoots) > 0 {
59
+ os.Chdir(payload.WorkspaceRoots[0])
60
+ } else {
61
+ fmt.Fprintf(os.Stderr, "No workspace roots found\n")
62
+ }
63
+ }
64
+ }
65
+
40
66
  var err error
41
67
  switch subcommand {
42
68
  case "run":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanagram/cli",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "Tanagram - Catch sloppy code before it ships",
5
5
  "main": "index.js",
6
6
  "bin": {
package/utils/process.go CHANGED
@@ -2,6 +2,7 @@ package utils
2
2
 
3
3
  import (
4
4
  "os"
5
+ "strings"
5
6
  "sync"
6
7
 
7
8
  "github.com/shirou/gopsutil/v3/process"
@@ -17,20 +18,30 @@ var (
17
18
  func GetParentProcess() string {
18
19
  parentProcessOnce.Do(func() {
19
20
  parentProcessName = "unknown"
20
-
21
+
21
22
  parentPID := int32(os.Getppid())
22
23
  parent, err := process.NewProcess(parentPID)
23
24
  if err != nil {
24
25
  return
25
26
  }
26
-
27
+
27
28
  name, err := parent.Name()
28
29
  if err != nil {
29
30
  return
30
31
  }
31
-
32
+
33
+ // Cursor runs hooks via `node` or `zsh` (I've seen both) in ~/.cursor
34
+ // Handle this case and make life easier for users of this function.
35
+ if name == "node" || name == "zsh" {
36
+ cwd, err := parent.Cwd()
37
+ if err == nil && strings.Contains(cwd, ".cursor") {
38
+ parentProcessName = "cursor"
39
+ return
40
+ }
41
+ }
42
+
32
43
  parentProcessName = name
33
44
  })
34
-
45
+
35
46
  return parentProcessName
36
47
  }