@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/README.md +80 -66
- package/api/client.go +12 -2
- package/api/client_test.go +53 -0
- package/commands/config.go +74 -83
- package/commands/config_test.go +185 -44
- package/commands/login.go +2 -2
- package/commands/run.go +21 -23
- package/commands/snapshot_test.go +3 -3
- package/go.mod +2 -1
- package/go.sum +4 -0
- package/main.go +112 -12
- package/package.json +1 -1
- package/utils/process.go +3 -2
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
|
}
|
|
@@ -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.
|
|
259
|
-
2.
|
|
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
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"
|