@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 +21 -23
- package/go.mod +2 -1
- package/go.sum +4 -0
- package/main.go +110 -10
- package/package.json +1 -1
- package/utils/process.go +3 -2
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
|
-
|
|
30
|
+
slog.Info("\r")
|
|
30
31
|
return
|
|
31
32
|
default:
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
109
|
+
slog.Info("spinner stopped")
|
|
111
110
|
return
|
|
112
111
|
default:
|
|
113
112
|
mu.Lock()
|
|
114
113
|
c := completed
|
|
115
114
|
mu.Unlock()
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
203
|
+
slog.Warn("Failed to load cloud policies", "error", err)
|
|
206
204
|
cloudPolicies = []parser.Policy{}
|
|
207
205
|
} else if len(cloudPolicies) > 0 {
|
|
208
|
-
|
|
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
|
-
|
|
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
|
-
|
|
224
|
+
slog.Info("Total policies", "total", totalMerged, "local", totalLocal, "cloud", totalCloud, "after_merge", totalMerged)
|
|
227
225
|
} else {
|
|
228
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
283
|
+
fmt.Fprint(os.Stdout, "No changes detected since snapshot\n")
|
|
286
284
|
} else {
|
|
287
|
-
fmt.
|
|
285
|
+
fmt.Fprint(os.Stdout, "No changes to check\n")
|
|
288
286
|
}
|
|
289
287
|
return nil
|
|
290
288
|
}
|
|
291
289
|
|
|
292
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
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
|
|
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
|
-
|
|
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"
|