@tanagram/cli 0.4.13 → 0.4.14

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/commands/run.go CHANGED
@@ -3,6 +3,7 @@ package commands
3
3
  import (
4
4
  "context"
5
5
  "fmt"
6
+ "log/slog"
6
7
  "os"
7
8
  "path/filepath"
8
9
  "sync"
@@ -26,10 +27,10 @@ func spinner(stop chan bool, message string) {
26
27
  for {
27
28
  select {
28
29
  case <-stop:
29
- fmt.Fprint(os.Stderr, "\r")
30
+ slog.Info("\r")
30
31
  return
31
32
  default:
32
- fmt.Fprintf(os.Stderr, "\r%s %s", chars[i%len(chars)], message)
33
+ slog.Info("spinner", "char", chars[i%len(chars)], "message", message)
33
34
  i++
34
35
  time.Sleep(100 * time.Millisecond)
35
36
  }
@@ -80,7 +81,7 @@ func Run() error {
80
81
  return err
81
82
  }
82
83
 
83
- fmt.Fprintf(os.Stderr, "\nSyncing policies with LLM (processing %d changed file(s) in parallel)...\n", len(filesToSync))
84
+ slog.Info("Syncing policies with LLM", "files_to_process", len(filesToSync))
84
85
 
85
86
  syncStart := time.Now()
86
87
  ctx := context.Background()
@@ -102,19 +103,16 @@ func Run() error {
102
103
  var completed int
103
104
  var mu sync.Mutex
104
105
  go func() {
105
- chars := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
106
- i := 0
107
106
  for {
108
107
  select {
109
108
  case <-stop:
110
- fmt.Fprint(os.Stderr, "\r")
109
+ slog.Info("spinner stopped")
111
110
  return
112
111
  default:
113
112
  mu.Lock()
114
113
  c := completed
115
114
  mu.Unlock()
116
- fmt.Fprintf(os.Stderr, "\r%s Processing files... (%d/%d completed)", chars[i%len(chars)], c, len(filesToSync))
117
- i++
115
+ slog.Info("Processing files", "completed", c, "total", len(filesToSync))
118
116
  time.Sleep(100 * time.Millisecond)
119
117
  }
120
118
  }
@@ -145,7 +143,7 @@ func Run() error {
145
143
  close(stop)
146
144
  time.Sleep(50 * time.Millisecond)
147
145
  mu.Lock()
148
- fmt.Fprintf(os.Stderr, "\r\033[K✗ Failed to process %s\n", result.relPath)
146
+ slog.Error("Failed to process file", "file", result.relPath)
149
147
  mu.Unlock()
150
148
  return fmt.Errorf("failed to extract policies from %s: %w", result.file, result.err)
151
149
  }
@@ -162,7 +160,7 @@ func Run() error {
162
160
  // Atomic update of counter and output (prevents race with spinner)
163
161
  mu.Lock()
164
162
  completed++
165
- fmt.Fprintf(os.Stderr, "\r\033[K✓ %s - %d policies\n", result.relPath, len(result.policies))
163
+ slog.Info("Processed file", "file", result.relPath, "policies", len(result.policies))
166
164
  mu.Unlock()
167
165
  }
168
166
 
@@ -176,7 +174,7 @@ func Run() error {
176
174
  }
177
175
 
178
176
  syncDuration := time.Since(syncStart)
179
- fmt.Fprintf(os.Stderr, "\n✓ Synced %d policies from %d changed file(s)\n", totalPolicies, len(filesToSync))
177
+ slog.Info("Sync complete", "policies_synced", totalPolicies, "files_synced", len(filesToSync))
180
178
 
181
179
  // Track sync metrics
182
180
  metrics.Track("cli.sync.complete", map[string]interface{}{
@@ -202,10 +200,10 @@ func Run() error {
202
200
  cloudPolicies, err = cloudStorage.LoadCloudPoliciesAsParserFormat(repoInfo.Owner, repoInfo.Name)
203
201
  if err != nil {
204
202
  // Cloud policies exist but failed to load - warn but continue
205
- fmt.Fprintf(os.Stderr, "Warning: Failed to load cloud policies: %v\n", err)
203
+ slog.Warn("Failed to load cloud policies", "error", err)
206
204
  cloudPolicies = []parser.Policy{}
207
205
  } else if len(cloudPolicies) > 0 {
208
- fmt.Fprintf(os.Stderr, "Loaded %d cloud policies for %s/%s\n", len(cloudPolicies), repoInfo.Owner, repoInfo.Name)
206
+ slog.Info("Loaded cloud policies", "count", len(cloudPolicies), "owner", repoInfo.Owner, "repo", repoInfo.Name)
209
207
  }
210
208
  }
211
209
  // If repo detection failed, silently continue with local-only policies
@@ -214,7 +212,7 @@ func Run() error {
214
212
  policies := storage.MergePolicies(localPolicies, cloudPolicies)
215
213
 
216
214
  if len(policies) == 0 {
217
- fmt.Fprintln(os.Stderr, "No enforceable policies found")
215
+ slog.Info("No enforceable policies found")
218
216
  return nil
219
217
  }
220
218
 
@@ -223,9 +221,9 @@ func Run() error {
223
221
  totalMerged := len(policies)
224
222
 
225
223
  if totalCloud > 0 {
226
- fmt.Fprintf(os.Stderr, "Total policies: %d (%d local, %d cloud, %d after merge)\n", totalMerged, totalLocal, totalCloud, totalMerged)
224
+ slog.Info("Total policies", "total", totalMerged, "local", totalLocal, "cloud", totalCloud, "after_merge", totalMerged)
227
225
  } else {
228
- fmt.Fprintf(os.Stderr, "Loaded %d local policies\n", totalLocal)
226
+ slog.Info("Loaded local policies", "count", totalLocal)
229
227
  }
230
228
 
231
229
  // Check if a snapshot exists (from PreToolUse hook)
@@ -233,7 +231,7 @@ func Run() error {
233
231
  useSnapshot := false
234
232
 
235
233
  if snapshot.Exists(gitRoot) {
236
- fmt.Fprintln(os.Stderr, "Snapshot detected - checking only Claude's changes...")
234
+ slog.Info("Snapshot detected - checking only Claude's changes")
237
235
 
238
236
  // Load snapshot
239
237
  snap, err := snapshot.Load(gitRoot)
@@ -265,13 +263,13 @@ func Run() error {
265
263
 
266
264
  // Delete snapshot after using it
267
265
  if err := snapshot.Delete(gitRoot); err != nil {
268
- fmt.Fprintf(os.Stderr, "Warning: failed to delete snapshot: %v\n", err)
266
+ slog.Warn("Failed to delete snapshot", "error", err)
269
267
  }
270
268
 
271
269
  useSnapshot = true
272
270
  } else {
273
271
  // No snapshot - fall back to checking all git changes
274
- fmt.Fprintln(os.Stderr, "Checking all changes (unstaged + staged)...")
272
+ slog.Info("Checking all changes (unstaged + staged)")
275
273
  diffResult, err := gitpkg.GetAllChanges()
276
274
  if err != nil {
277
275
  return fmt.Errorf("error getting git diff: %w", err)
@@ -282,14 +280,14 @@ func Run() error {
282
280
 
283
281
  if len(changesToCheck) == 0 {
284
282
  if useSnapshot {
285
- fmt.Fprintln(os.Stderr, "No changes detected since snapshot")
283
+ fmt.Fprint(os.Stdout, "No changes detected since snapshot\n")
286
284
  } else {
287
- fmt.Fprintln(os.Stderr, "No changes to check")
285
+ fmt.Fprint(os.Stdout, "No changes to check\n")
288
286
  }
289
287
  return nil
290
288
  }
291
289
 
292
- fmt.Fprintf(os.Stderr, "Scanning %d changed lines...\n\n", len(changesToCheck))
290
+ slog.Info("Scanning changed lines", "count", len(changesToCheck))
293
291
 
294
292
  // Get API key once upfront before checking
295
293
  apiKey, err := config.GetAPIKey()
@@ -337,6 +335,6 @@ func Run() error {
337
335
  }
338
336
 
339
337
  // No violations found - output success message
340
- fmt.Fprintln(os.Stderr, "No policy violations found")
338
+ fmt.Fprint(os.Stdout, "No policy violations found")
341
339
  return nil
342
340
  }
package/go.mod CHANGED
@@ -43,6 +43,7 @@ require (
43
43
  github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
44
44
  github.com/yusufpapurcu/wmi v1.2.4 // indirect
45
45
  github.com/zalando/go-keyring v0.2.6 // indirect
46
- golang.org/x/sys v0.36.0 // indirect
46
+ golang.org/x/sys v0.38.0 // indirect
47
+ golang.org/x/term v0.37.0 // indirect
47
48
  golang.org/x/text v0.27.0 // indirect
48
49
  )
package/go.sum CHANGED
@@ -97,6 +97,10 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
97
97
  golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
98
98
  golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
99
99
  golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
100
+ golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
101
+ golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
102
+ golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
103
+ golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
100
104
  golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
101
105
  golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
102
106
  golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
package/main.go CHANGED
@@ -2,19 +2,30 @@ package main
2
2
 
3
3
  import (
4
4
  "encoding/json"
5
+ "flag"
5
6
  "fmt"
6
7
  "io"
8
+ "log/slog"
7
9
  "os"
10
+ "path/filepath"
11
+ "strings"
8
12
 
9
13
  "github.com/tanagram/cli/commands"
10
14
  "github.com/tanagram/cli/metrics"
11
15
  "github.com/tanagram/cli/tui"
12
16
  "github.com/tanagram/cli/utils"
17
+ "golang.org/x/term"
13
18
  )
14
19
 
15
20
  // Version is set in install.js via `-X` ldflags
16
21
  var Version = "dev"
17
22
 
23
+ var (
24
+ flagLogLevel = flag.String("log-level", "info", "log level: debug, info, warn, error")
25
+ flagLogFormat = flag.String("log-format", "text", "log format: text or json")
26
+ flagLogFile = flag.String("log-file", "", "log output file path (defaults to stderr if not specified)")
27
+ )
28
+
18
29
  func main() {
19
30
  metrics.SetVersion(Version)
20
31
  metrics.Init()
@@ -30,16 +41,50 @@ func main() {
30
41
  os.Exit(exitCode)
31
42
  }()
32
43
 
44
+ flag.Parse()
45
+
33
46
  // Get subcommand (default to "run" if none provided)
34
47
  subcommand := "run"
35
- if len(os.Args) > 1 {
36
- subcommand = os.Args[1]
48
+ args := flag.Args()
49
+ if len(args) > 0 {
50
+ subcommand = args[0]
37
51
  }
38
52
 
39
53
  metrics.Track("cli.start", map[string]interface{}{
40
54
  "subcommand": subcommand,
41
55
  })
42
56
 
57
+ var logOutput io.Writer = os.Stderr
58
+ if utils.GetParentProcess() == "claude" {
59
+ // We use "exit-code 2" behavior for claude: https://code.claude.com/docs/en/hooks#simple:-exit-code
60
+ // Claude expects communication (i.e. `stop` hook output) to come on `stderr`,
61
+ // Which means we want to send log output (i.e. not output-to-Claude) to `stdout`.
62
+ logOutput = os.Stdout
63
+ }
64
+ if *flagLogFile != "" {
65
+ logPath, logErr := createLogFile(*flagLogFile)
66
+ if logErr != nil {
67
+ slog.Error("Error setting up logging", "error", logErr)
68
+ exitCode = 1
69
+ return
70
+ }
71
+ logFile, logErr := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
72
+ if logErr != nil {
73
+ slog.Error("Error opening log file", "error", logErr)
74
+ exitCode = 1
75
+ return
76
+ }
77
+ defer logFile.Close()
78
+ logOutput = logFile
79
+ }
80
+ isTTY := term.IsTerminal(int(os.Stdout.Fd()))
81
+ logger := newLogger(*flagLogLevel, *flagLogFormat, logOutput, isTTY)
82
+ slog.SetDefault(logger)
83
+
84
+ slog.Info("Running CLI with args",
85
+ "args", os.Args[1:],
86
+ )
87
+
43
88
  // THIS IS A HUGE HACK
44
89
  // Cursor runs its hooks in the ~/.cursor directory as cwd
45
90
  // Claude runs its hooks directly as a subprocess, with your terminal directory as cwd
@@ -54,11 +99,11 @@ func main() {
54
99
  WorkspaceRoots []string `json:"workspace_roots"`
55
100
  }
56
101
  if err := json.Unmarshal(input, &payload); err != nil {
57
- fmt.Fprintf(os.Stderr, "Error parsing input: %v\n", err)
102
+ slog.Error("Error parsing input", "error", err)
58
103
  } else if len(payload.WorkspaceRoots) > 0 {
59
104
  os.Chdir(payload.WorkspaceRoots[0])
60
105
  } else {
61
- fmt.Fprintf(os.Stderr, "No workspace roots found\n")
106
+ slog.Warn("No workspace roots found")
62
107
  }
63
108
  }
64
109
  }
@@ -71,7 +116,7 @@ func main() {
71
116
  })
72
117
  // Auto-setup hooks on first run
73
118
  if err := commands.EnsureHooksConfigured(); err != nil {
74
- fmt.Fprintf(os.Stderr, "Error: %v\n", err)
119
+ slog.Error("Failed to configure hooks", "error", err)
75
120
  exitCode = 1
76
121
  return
77
122
  }
@@ -87,7 +132,7 @@ func main() {
87
132
  })
88
133
  // Auto-setup hooks on first run
89
134
  if err := commands.EnsureHooksConfigured(); err != nil {
90
- fmt.Fprintf(os.Stderr, "Error: %v\n", err)
135
+ slog.Error("Failed to configure hooks", "error", err)
91
136
  exitCode = 1
92
137
  return
93
138
  }
@@ -98,7 +143,7 @@ func main() {
98
143
  })
99
144
  // Auto-setup hooks on first run
100
145
  if err := commands.EnsureHooksConfigured(); err != nil {
101
- fmt.Fprintf(os.Stderr, "Error: %v\n", err)
146
+ slog.Error("Failed to configure hooks", "error", err)
102
147
  exitCode = 1
103
148
  return
104
149
  }
@@ -152,7 +197,7 @@ func main() {
152
197
  })
153
198
  choice, err := tui.RunWelcomeScreen()
154
199
  if err != nil {
155
- fmt.Fprintf(os.Stderr, "Error: %v\n", err)
200
+ slog.Error("Welcome screen failed", "error", err)
156
201
  exitCode = 1
157
202
  return
158
203
  }
@@ -183,7 +228,7 @@ func main() {
183
228
  })
184
229
  err := tui.RunPuzzleEditor()
185
230
  if err != nil {
186
- fmt.Fprintf(os.Stderr, "Error: %v\n", err)
231
+ slog.Error("Puzzle editor failed", "error", err)
187
232
  exitCode = 1
188
233
  return
189
234
  }
@@ -206,7 +251,7 @@ func main() {
206
251
  "command": subcommand,
207
252
  "error": err.Error(),
208
253
  })
209
- fmt.Fprintf(os.Stderr, "Error: %v\n", err)
254
+ slog.Error("Command failed", "command", subcommand, "error", err)
210
255
  exitCode = 1
211
256
  return
212
257
  }
@@ -266,3 +311,58 @@ HOOK WORKFLOW:
266
311
  `
267
312
  fmt.Print(help)
268
313
  }
314
+
315
+ func newLogger(levelStr, format string, output io.Writer, isTTY bool) *slog.Logger {
316
+ var lvl slog.Level
317
+ switch strings.ToLower(levelStr) {
318
+ case "debug":
319
+ lvl = slog.LevelDebug
320
+ case "info":
321
+ lvl = slog.LevelInfo
322
+ case "warn", "warning":
323
+ lvl = slog.LevelWarn
324
+ case "error":
325
+ lvl = slog.LevelError
326
+ default:
327
+ lvl = slog.LevelInfo
328
+ }
329
+
330
+ opts := &slog.HandlerOptions{
331
+ Level: lvl,
332
+ }
333
+
334
+ var h slog.Handler
335
+ switch strings.ToLower(format) {
336
+ case "json":
337
+ h = slog.NewJSONHandler(output, opts)
338
+ default:
339
+ if isTTY {
340
+ opts.ReplaceAttr = func(groups []string, a slog.Attr) slog.Attr {
341
+ if a.Key == slog.TimeKey || a.Key == slog.LevelKey {
342
+ return slog.Attr{}
343
+ }
344
+ return a
345
+ }
346
+ }
347
+ h = slog.NewTextHandler(output, opts)
348
+ }
349
+
350
+ return slog.New(h)
351
+ }
352
+
353
+ func createLogFile(logFilePath string) (string, error) {
354
+ logPath := logFilePath
355
+ if strings.HasPrefix(logPath, "~/") {
356
+ home, err := os.UserHomeDir()
357
+ if err != nil {
358
+ return "", fmt.Errorf("getting home directory: %w", err)
359
+ }
360
+ logPath = filepath.Join(home, logPath[2:])
361
+ }
362
+
363
+ if err := os.MkdirAll(filepath.Dir(logPath), 0755); err != nil {
364
+ return "", fmt.Errorf("creating log directory: %w", err)
365
+ }
366
+
367
+ return logPath, nil
368
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanagram/cli",
3
- "version": "0.4.13",
3
+ "version": "0.4.14",
4
4
  "description": "Tanagram - Catch sloppy code before it ships",
5
5
  "main": "index.js",
6
6
  "bin": {
package/utils/process.go CHANGED
@@ -30,9 +30,10 @@ func GetParentProcess() string {
30
30
  return
31
31
  }
32
32
 
33
- // Cursor runs hooks via `node` or `zsh` (I've seen both) in ~/.cursor
33
+ // Cursor runs hooks via `node` or a shell (I've seen both) in ~/.cursor
34
34
  // Handle this case and make life easier for users of this function.
35
- if name == "node" || name == "zsh" {
35
+ // `pwsh` is powershell: https://chatgpt.com/share/6927b015-2738-8000-ab6d-792e05c9401b
36
+ if name == "node" || name == "zsh" || name == "bash" || name == "fish" || name == "sh" || strings.Contains(name, "pwsh") {
36
37
  cwd, err := parent.Cwd()
37
38
  if err == nil && strings.Contains(cwd, ".cursor") {
38
39
  parentProcessName = "cursor"