@tanagram/cli 0.4.18 → 0.4.20

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.
@@ -1,15 +1,21 @@
1
1
  package commands
2
2
 
3
3
  import (
4
+ "context"
4
5
  "encoding/json"
5
6
  "fmt"
6
7
  "os"
7
8
  "path/filepath"
8
9
  "strings"
10
+
11
+ "github.com/getsentry/sentry-go"
9
12
  )
10
13
 
11
14
  // ConfigClaude sets up the Claude Code hook in the specified settings file
12
- func ConfigClaude(settingsPath string) error {
15
+ func ConfigClaude(ctx context.Context, settingsPath string) error {
16
+ span := sentry.StartSpan(ctx, "command.config_claude")
17
+ defer span.Finish()
18
+
13
19
  settings, err := loadConfig(settingsPath)
14
20
  if err != nil {
15
21
  return fmt.Errorf("failed to load Claude settings: %w", err)
@@ -127,7 +133,9 @@ func ConfigClaude(settingsPath string) error {
127
133
  }
128
134
 
129
135
  // ConfigCursor sets up the Cursor hook in the specified settings file
130
- func ConfigCursor(hooksPath string) error {
136
+ func ConfigCursor(ctx context.Context, hooksPath string) error {
137
+ span := sentry.StartSpan(ctx, "command.config_cursor")
138
+ defer span.Finish()
131
139
 
132
140
  hooksConfig, err := loadConfig(hooksPath)
133
141
  if err != nil {
@@ -222,7 +230,10 @@ func ConfigCursor(hooksPath string) error {
222
230
  }
223
231
 
224
232
  // ConfigList shows where Tanagram hooks are installed
225
- func ConfigList() error {
233
+ func ConfigList(ctx context.Context) error {
234
+ span := sentry.StartSpan(ctx, "command.config_list")
235
+ defer span.Finish()
236
+
226
237
  // Check user settings (~/.claude/settings.json)
227
238
  userSettingsPath, err := GetHomeConfigPath(".claude", "settings.json")
228
239
  if err != nil {
@@ -526,7 +537,7 @@ func printCursorHookStatus(status CursorHookStatus, path string) {
526
537
  // ensureClaudeConfigured checks if Claude Code hooks are configured,
527
538
  // and automatically sets them up if not. This is called on first run
528
539
  // of commands that need Claude Code integration.
529
- func ensureClaudeConfigured() error {
540
+ func ensureClaudeConfigured(ctx context.Context) error {
530
541
  settingsPath, err := GetHomeConfigPath(".claude", "settings.json")
531
542
  if err != nil {
532
543
  // Silently skip if we can't get home dir - not critical
@@ -543,7 +554,7 @@ func ensureClaudeConfigured() error {
543
554
  // If file doesn't exist or hooks aren't configured, set them up
544
555
  if !status.FileExists || !status.SessionStartHookExists || !status.StopHookExists {
545
556
  fmt.Println("Setting up Claude Code integration...")
546
- if err := ConfigClaude(settingsPath); err != nil {
557
+ if err := ConfigClaude(ctx, settingsPath); err != nil {
547
558
  // Don't fail the command if hook setup fails - just warn
548
559
  fmt.Fprintf(os.Stderr, "Warning: Failed to setup Claude Code hooks: %v\n", err)
549
560
  fmt.Fprintf(os.Stderr, "You can manually setup hooks later with: tanagram config claude\n\n")
@@ -558,7 +569,7 @@ func ensureClaudeConfigured() error {
558
569
  // ensureCursorConfigured checks if Cursor hooks are configured,
559
570
  // and automatically sets them up if not. This is called on first run
560
571
  // of commands that need Cursor integration.
561
- func ensureCursorConfigured() error {
572
+ func ensureCursorConfigured(ctx context.Context) error {
562
573
  hooksPath, err := GetHomeConfigPath(".cursor", "hooks.json")
563
574
  if err != nil {
564
575
  // Silently skip if we can't get home dir - not critical
@@ -575,7 +586,7 @@ func ensureCursorConfigured() error {
575
586
  // If file doesn't exist or hooks aren't configured, set them up
576
587
  if !status.FileExists || !status.BeforeSubmitExists || !status.StopExists {
577
588
  fmt.Println("Setting up Cursor integration...")
578
- if err := ConfigCursor(hooksPath); err != nil {
589
+ if err := ConfigCursor(ctx, hooksPath); err != nil {
579
590
  // Don't fail the command if hook setup fails - just warn
580
591
  fmt.Fprintf(os.Stderr, "Warning: Failed to setup Cursor hooks: %v\n", err)
581
592
  fmt.Fprintf(os.Stderr, "You can manually setup hooks later with: tanagram config cursor\n\n")
@@ -589,11 +600,11 @@ func ensureCursorConfigured() error {
589
600
 
590
601
  // EnsureHooksConfigured ensures that both Claude Code and Cursor hooks are configured.
591
602
  // This is a convenience function for commands that need both integrations.
592
- func EnsureHooksConfigured() error {
593
- if err := ensureClaudeConfigured(); err != nil {
603
+ func EnsureHooksConfigured(ctx context.Context) error {
604
+ if err := ensureClaudeConfigured(ctx); err != nil {
594
605
  return err
595
606
  }
596
- if err := ensureCursorConfigured(); err != nil {
607
+ if err := ensureCursorConfigured(ctx); err != nil {
597
608
  return err
598
609
  }
599
610
  return nil
@@ -1,6 +1,7 @@
1
1
  package commands
2
2
 
3
3
  import (
4
+ "context"
4
5
  "encoding/json"
5
6
  "os"
6
7
  "path/filepath"
@@ -27,7 +28,7 @@ func TestConfigClaude(t *testing.T) {
27
28
  }()
28
29
 
29
30
  t.Run("Create new config", func(t *testing.T) {
30
- if err := ConfigClaude(settingsPath); err != nil {
31
+ if err := ConfigClaude(context.Background(), settingsPath); err != nil {
31
32
  t.Fatalf("ConfigClaude() failed: %v", err)
32
33
  }
33
34
 
@@ -43,7 +44,7 @@ func TestConfigClaude(t *testing.T) {
43
44
 
44
45
  t.Run("Idempotency", func(t *testing.T) {
45
46
  // Run it again (first time was in "Create new config")
46
- if err := ConfigClaude(settingsPath); err != nil {
47
+ if err := ConfigClaude(context.Background(), settingsPath); err != nil {
47
48
  t.Fatalf("ConfigClaude() failed on second run: %v", err)
48
49
  }
49
50
 
@@ -96,7 +97,7 @@ func TestConfigClaude(t *testing.T) {
96
97
  t.Fatalf("Failed to write settings: %v", err)
97
98
  }
98
99
 
99
- if err := ConfigClaude(settingsPath); err != nil {
100
+ if err := ConfigClaude(context.Background(), settingsPath); err != nil {
100
101
  t.Fatalf("ConfigClaude() failed: %v", err)
101
102
  }
102
103
 
@@ -174,7 +175,7 @@ func TestConfigClaude(t *testing.T) {
174
175
  t.Fatalf("Failed to write settings: %v", err)
175
176
  }
176
177
 
177
- if err := ConfigClaude(settingsPath); err != nil {
178
+ if err := ConfigClaude(context.Background(), settingsPath); err != nil {
178
179
  t.Fatalf("ConfigClaude() failed: %v", err)
179
180
  }
180
181
 
@@ -234,7 +235,7 @@ func TestConfigCursor(t *testing.T) {
234
235
  }()
235
236
 
236
237
  t.Run("Create new config", func(t *testing.T) {
237
- if err := ConfigCursor(hooksPath); err != nil {
238
+ if err := ConfigCursor(context.Background(), hooksPath); err != nil {
238
239
  t.Fatalf("ConfigCursor() failed: %v", err)
239
240
  }
240
241
 
@@ -250,7 +251,7 @@ func TestConfigCursor(t *testing.T) {
250
251
 
251
252
  t.Run("Idempotency", func(t *testing.T) {
252
253
  // Run it again
253
- if err := ConfigCursor(hooksPath); err != nil {
254
+ if err := ConfigCursor(context.Background(), hooksPath); err != nil {
254
255
  t.Fatalf("ConfigCursor() failed on second run: %v", err)
255
256
  }
256
257
 
@@ -297,7 +298,7 @@ func TestConfigCursor(t *testing.T) {
297
298
  t.Fatalf("Failed to write settings: %v", err)
298
299
  }
299
300
 
300
- if err := ConfigCursor(hooksPath); err != nil {
301
+ if err := ConfigCursor(context.Background(), hooksPath); err != nil {
301
302
  t.Fatalf("ConfigCursor() failed: %v", err)
302
303
  }
303
304
 
@@ -351,7 +352,7 @@ func TestConfigCursor(t *testing.T) {
351
352
  t.Fatalf("Failed to write settings: %v", err)
352
353
  }
353
354
 
354
- if err := ConfigCursor(hooksPath); err != nil {
355
+ if err := ConfigCursor(context.Background(), hooksPath); err != nil {
355
356
  t.Fatalf("ConfigCursor() failed: %v", err)
356
357
  }
357
358
 
@@ -413,7 +414,7 @@ func TestEnsureHooksConfigured(t *testing.T) {
413
414
  }()
414
415
 
415
416
  t.Run("Fresh configuration", func(t *testing.T) {
416
- if err := EnsureHooksConfigured(); err != nil {
417
+ if err := EnsureHooksConfigured(context.Background()); err != nil {
417
418
  t.Fatalf("EnsureHooksConfigured() failed: %v", err)
418
419
  }
419
420
 
package/commands/list.go CHANGED
@@ -1,33 +1,45 @@
1
1
  package commands
2
2
 
3
3
  import (
4
+ "context"
4
5
  "fmt"
5
6
 
7
+ "github.com/getsentry/sentry-go"
6
8
  gitpkg "github.com/tanagram/cli/git"
7
9
  "github.com/tanagram/cli/storage"
8
10
  )
9
11
 
10
12
  // List displays all cached policies (both local and cloud)
11
- func List() error {
13
+ func List(ctx context.Context) error {
14
+ span := sentry.StartSpan(ctx, "command.list")
15
+ defer span.Finish()
16
+
12
17
  // Find git root
18
+ findGitRootSpan := sentry.StartSpan(span.Context(), "storage.find_git_root")
13
19
  gitRoot, err := storage.FindGitRoot()
20
+ findGitRootSpan.Finish()
14
21
  if err != nil {
15
22
  return err
16
23
  }
17
24
 
18
25
  // Load local cache
26
+ loadCacheSpan := sentry.StartSpan(span.Context(), "storage.load_cache")
19
27
  cache, err := storage.LoadCache(gitRoot)
28
+ loadCacheSpan.Finish()
20
29
  if err != nil {
21
30
  return fmt.Errorf("failed to load cache: %w", err)
22
31
  }
23
32
 
24
33
  // Load local policies from cache
34
+ getPoliciesSpan := sentry.StartSpan(span.Context(), "cache.get_all_policies")
25
35
  localPolicies, err := cache.GetAllPolicies()
36
+ getPoliciesSpan.Finish()
26
37
  if err != nil {
27
38
  return fmt.Errorf("failed to load local policies: %w", err)
28
39
  }
29
40
 
30
41
  // Try to load cloud policies for current repo
42
+ cloudPoliciesSpan := sentry.StartSpan(span.Context(), "storage.load_cloud_policies")
31
43
  cloudPolicies := []storage.SimplifiedPolicy{}
32
44
  var repoInfo *gitpkg.RepoInfo
33
45
 
@@ -40,6 +52,7 @@ func List() error {
40
52
  cloudPolicies = repoFile.Policies
41
53
  }
42
54
  }
55
+ cloudPoliciesSpan.Finish()
43
56
 
44
57
  // Check if we have any policies to display
45
58
  if len(localPolicies) == 0 && len(cloudPolicies) == 0 {
package/commands/login.go CHANGED
@@ -12,8 +12,11 @@ import (
12
12
  "os"
13
13
  "time"
14
14
 
15
+ "github.com/getsentry/sentry-go"
16
+ "github.com/golang-jwt/jwt/v5"
15
17
  "github.com/pkg/browser"
16
18
  "github.com/tanagram/cli/auth"
19
+ "github.com/tanagram/cli/utils"
17
20
  )
18
21
 
19
22
  // getWebAppURL returns the web app URL based on environment
@@ -45,7 +48,12 @@ func generateCodeChallenge(verifier string) string {
45
48
  }
46
49
 
47
50
  // Login implements the OAuth PKCE flow for CLI authentication
48
- func Login() error {
51
+ func Login(ctx context.Context) error {
52
+ span := sentry.StartSpan(ctx, "command.login")
53
+ defer span.Finish()
54
+
55
+ utils.AddBreadcrumb("auth", "Starting OAuth PKCE login flow", sentry.LevelInfo, nil)
56
+
49
57
  // Generate PKCE parameters
50
58
  codeVerifier, err := generateCodeVerifier()
51
59
  if err != nil {
@@ -301,15 +309,19 @@ func Login() error {
301
309
  fmt.Println("Waiting for authentication...")
302
310
 
303
311
  // Wait for callback or timeout
312
+ waitSpan := sentry.StartSpan(span.Context(), "auth.wait_for_callback")
304
313
  var authCode string
305
314
  select {
306
315
  case authCode = <-codeChan:
307
316
  // Success
308
317
  case err := <-errChan:
318
+ waitSpan.Finish()
309
319
  return err
310
320
  case <-time.After(5 * time.Minute):
321
+ waitSpan.Finish()
311
322
  return fmt.Errorf("authentication timeout - please try again")
312
323
  }
324
+ waitSpan.Finish()
313
325
 
314
326
  // Shutdown the callback server
315
327
  ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
@@ -328,9 +340,15 @@ func Login() error {
328
340
  // Store session JWT in OS keyring
329
341
  fmt.Println("Storing session token securely...")
330
342
 
343
+ storeTokenSpan := sentry.StartSpan(span.Context(), "auth.store_token")
331
344
  if err := auth.SetAccessToken(sessionJWT); err != nil {
345
+ storeTokenSpan.Finish()
332
346
  return fmt.Errorf("failed to store session token: %w", err)
333
347
  }
348
+ storeTokenSpan.Finish()
349
+
350
+ // Set user context in Sentry from JWT claims
351
+ SetUserContextFromJWT(sessionJWT)
334
352
 
335
353
  fmt.Println("\n✓ Successfully logged in to Tanagram")
336
354
  fmt.Printf("Session token stored securely in OS keyring\n")
@@ -338,4 +356,42 @@ func Login() error {
338
356
  return nil
339
357
  }
340
358
 
359
+ type jwtClaims struct {
360
+ Subject string
361
+ Email string
362
+ }
363
+
364
+ // SetUserContextFromJWT parses JWT claims and sets Sentry user context
365
+ func SetUserContextFromJWT(sessionJWT string) {
366
+ if claims := parseJWTClaims(sessionJWT); claims != nil {
367
+ utils.SetUserContext(claims.Subject, claims.Email)
368
+ utils.AddBreadcrumb("auth", "Login successful", sentry.LevelInfo, map[string]interface{}{
369
+ "user_id": claims.Subject,
370
+ })
371
+ }
372
+ }
373
+
374
+ func parseJWTClaims(tokenString string) *jwtClaims {
375
+ parser := jwt.NewParser()
376
+ token, _, err := parser.ParseUnverified(tokenString, jwt.MapClaims{})
377
+ if err != nil {
378
+ return nil
379
+ }
380
+
381
+ claims, ok := token.Claims.(jwt.MapClaims)
382
+ if !ok {
383
+ return nil
384
+ }
385
+
386
+ result := &jwtClaims{}
387
+ if sub, ok := claims["sub"].(string); ok {
388
+ result.Subject = sub
389
+ }
390
+ if email, ok := claims["email"].(string); ok {
391
+ result.Email = email
392
+ }
393
+
394
+ return result
395
+ }
396
+
341
397
  // Removed exchangeCodeForTokens - we receive the session JWT directly from the web app
@@ -0,0 +1,132 @@
1
+ package commands
2
+
3
+ import (
4
+ "testing"
5
+ "time"
6
+
7
+ "github.com/getsentry/sentry-go"
8
+ "github.com/golang-jwt/jwt/v5"
9
+ )
10
+
11
+ func TestParseJWTClaims(t *testing.T) {
12
+ t.Run("valid JWT with sub and email", func(t *testing.T) {
13
+ // Create a real JWT token
14
+ token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
15
+ "sub": "user_12345",
16
+ "email": "test@example.com",
17
+ "iat": time.Now().Unix(),
18
+ "exp": time.Now().Add(time.Hour).Unix(),
19
+ })
20
+
21
+ // Sign with a test secret
22
+ tokenString, err := token.SignedString([]byte("test-secret"))
23
+ if err != nil {
24
+ t.Fatalf("Failed to sign token: %v", err)
25
+ }
26
+
27
+ claims := parseJWTClaims(tokenString)
28
+ if claims == nil {
29
+ t.Fatal("Expected claims, got nil")
30
+ }
31
+
32
+ if claims.Subject != "user_12345" {
33
+ t.Errorf("Expected Subject 'user_12345', got '%s'", claims.Subject)
34
+ }
35
+
36
+ if claims.Email != "test@example.com" {
37
+ t.Errorf("Expected Email 'test@example.com', got '%s'", claims.Email)
38
+ }
39
+ })
40
+
41
+ t.Run("valid JWT with only sub", func(t *testing.T) {
42
+ token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
43
+ "sub": "user_67890",
44
+ "iat": time.Now().Unix(),
45
+ })
46
+
47
+ tokenString, err := token.SignedString([]byte("test-secret"))
48
+ if err != nil {
49
+ t.Fatalf("Failed to sign token: %v", err)
50
+ }
51
+
52
+ claims := parseJWTClaims(tokenString)
53
+ if claims == nil {
54
+ t.Fatal("Expected claims, got nil")
55
+ }
56
+
57
+ if claims.Subject != "user_67890" {
58
+ t.Errorf("Expected Subject 'user_67890', got '%s'", claims.Subject)
59
+ }
60
+
61
+ if claims.Email != "" {
62
+ t.Errorf("Expected empty Email, got '%s'", claims.Email)
63
+ }
64
+ })
65
+
66
+ t.Run("invalid JWT string", func(t *testing.T) {
67
+ claims := parseJWTClaims("not-a-valid-jwt")
68
+ if claims != nil {
69
+ t.Error("Expected nil for invalid JWT, got claims")
70
+ }
71
+ })
72
+
73
+ t.Run("empty JWT string", func(t *testing.T) {
74
+ claims := parseJWTClaims("")
75
+ if claims != nil {
76
+ t.Error("Expected nil for empty string, got claims")
77
+ }
78
+ })
79
+ }
80
+
81
+ func TestSetUserContextFromJWT(t *testing.T) {
82
+ t.Run("sets user context from valid JWT", func(t *testing.T) {
83
+ // Channel to capture the event
84
+ var capturedEvent *sentry.Event
85
+
86
+ // Initialize Sentry with a transport that captures events
87
+ err := sentry.Init(sentry.ClientOptions{
88
+ Dsn: "https://test@test.ingest.sentry.io/123",
89
+ Environment: "test",
90
+ BeforeSend: func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event {
91
+ capturedEvent = event
92
+ return nil // Don't actually send
93
+ },
94
+ })
95
+ if err != nil {
96
+ t.Fatalf("Failed to initialize Sentry: %v", err)
97
+ }
98
+ defer sentry.Flush(time.Second)
99
+
100
+ // Create a real JWT with user claims
101
+ token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
102
+ "sub": "user_abc123",
103
+ "email": "alice@tanagram.ai",
104
+ "iat": time.Now().Unix(),
105
+ "exp": time.Now().Add(time.Hour).Unix(),
106
+ })
107
+
108
+ tokenString, err := token.SignedString([]byte("test-secret"))
109
+ if err != nil {
110
+ t.Fatalf("Failed to sign token: %v", err)
111
+ }
112
+
113
+ // Call the function under test
114
+ SetUserContextFromJWT(tokenString)
115
+
116
+ // Capture an event to verify the user context was set
117
+ sentry.CaptureMessage("test event")
118
+ sentry.Flush(time.Second)
119
+
120
+ if capturedEvent == nil {
121
+ t.Fatal("Expected event to be captured")
122
+ }
123
+
124
+ if capturedEvent.User.ID != "user_abc123" {
125
+ t.Errorf("Expected user ID 'user_abc123', got '%s'", capturedEvent.User.ID)
126
+ }
127
+
128
+ if capturedEvent.User.Email != "alice@tanagram.ai" {
129
+ t.Errorf("Expected email 'alice@tanagram.ai', got '%s'", capturedEvent.User.Email)
130
+ }
131
+ })
132
+ }