@tanagram/cli 0.4.11 → 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/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
  }
@@ -255,8 +300,8 @@ HOOK WORKFLOW:
255
300
  When configured with 'tanagram config claude' or 'tanagram config cursor':
256
301
 
257
302
  Claude Code:
258
- 1. PreToolUse (Edit|Write): Creates snapshot before Claude makes changes
259
- 2. PostToolUse (Edit|Write): Checks only Claude's changes against policies
303
+ 1. SessionStart: Creates snapshot before Claude makes changes
304
+ 2. Stop: Checks only Claude's changes against policies
260
305
 
261
306
  Cursor:
262
307
  1. beforeSubmitPrompt: Creates snapshot before agent starts working
@@ -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.11",
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"