@tanagram/cli 0.4.19 → 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.
package/commands/run.go CHANGED
@@ -9,6 +9,7 @@ import (
9
9
  "sync"
10
10
  "time"
11
11
 
12
+ "github.com/getsentry/sentry-go"
12
13
  "github.com/tanagram/cli/checker"
13
14
  "github.com/tanagram/cli/config"
14
15
  "github.com/tanagram/cli/extractor"
@@ -37,16 +38,132 @@ func spinner(stop chan bool, message string) {
37
38
  }
38
39
  }
39
40
 
41
+ // syncPolicies extracts policies from changed files using the LLM
42
+ func syncPolicies(ctx context.Context, filesToSync []string, gitRoot string, cache *storage.Cache) error {
43
+ syncSpan := sentry.StartSpan(ctx, "commands.auto_sync_policies")
44
+ defer syncSpan.Finish()
45
+ syncSpan.SetData("files_to_sync", len(filesToSync))
46
+
47
+ apiKey, err := config.GetAPIKey()
48
+ if err != nil {
49
+ return err
50
+ }
51
+
52
+ slog.Info("Syncing policies with LLM", "files_to_process", len(filesToSync))
53
+
54
+ syncStart := time.Now()
55
+
56
+ type syncResult struct {
57
+ file string
58
+ relPath string
59
+ policies []parser.Policy
60
+ err error
61
+ }
62
+
63
+ results := make(chan syncResult, len(filesToSync))
64
+ var wg sync.WaitGroup
65
+
66
+ stop := make(chan bool)
67
+ var completed int
68
+ var mu sync.Mutex
69
+ go func() {
70
+ for {
71
+ select {
72
+ case <-stop:
73
+ slog.Info("spinner stopped")
74
+ return
75
+ default:
76
+ mu.Lock()
77
+ c := completed
78
+ mu.Unlock()
79
+ slog.Info("Processing files", "completed", c, "total", len(filesToSync))
80
+ time.Sleep(100 * time.Millisecond)
81
+ }
82
+ }
83
+ }()
84
+
85
+ for _, file := range filesToSync {
86
+ wg.Add(1)
87
+ go func(file string) {
88
+ defer wg.Done()
89
+ relPath, _ := filepath.Rel(gitRoot, file)
90
+ policies, err := extractor.ExtractPoliciesFromFile(ctx, file, apiKey)
91
+ results <- syncResult{file, relPath, policies, err}
92
+ }(file)
93
+ }
94
+
95
+ go func() {
96
+ wg.Wait()
97
+ close(results)
98
+ }()
99
+
100
+ totalPolicies := 0
101
+ for result := range results {
102
+ if result.err != nil {
103
+ stop <- true
104
+ close(stop)
105
+ time.Sleep(50 * time.Millisecond)
106
+ mu.Lock()
107
+ slog.Error("Failed to process file", "file", result.relPath)
108
+ mu.Unlock()
109
+ return fmt.Errorf("failed to extract policies from %s: %w", result.file, result.err)
110
+ }
111
+
112
+ if err := cache.UpdateFile(result.file, result.policies); err != nil {
113
+ stop <- true
114
+ close(stop)
115
+ time.Sleep(50 * time.Millisecond)
116
+ return fmt.Errorf("failed to update cache for %s: %w", result.file, err)
117
+ }
118
+
119
+ totalPolicies += len(result.policies)
120
+
121
+ mu.Lock()
122
+ completed++
123
+ slog.Info("Processed file", "file", result.relPath, "policies", len(result.policies))
124
+ mu.Unlock()
125
+ }
126
+
127
+ stop <- true
128
+ close(stop)
129
+ time.Sleep(50 * time.Millisecond)
130
+
131
+ if err := cache.Save(); err != nil {
132
+ return fmt.Errorf("failed to save cache: %w", err)
133
+ }
134
+
135
+ syncDuration := time.Since(syncStart)
136
+ slog.Info("Sync complete", "policies_synced", totalPolicies, "files_synced", len(filesToSync))
137
+
138
+ metrics.Track("cli.sync.complete", map[string]interface{}{
139
+ "files_synced": len(filesToSync),
140
+ "policies_synced": totalPolicies,
141
+ "duration_seconds": syncDuration.Seconds(),
142
+ })
143
+
144
+ return nil
145
+ }
146
+
40
147
  // Run executes the main policy check with auto-sync
41
- func Run() error {
148
+ func Run(ctx context.Context) error {
149
+ span := sentry.StartSpan(ctx, "command.run")
150
+ defer span.Finish()
151
+ ctx = span.Context()
152
+
153
+ utils.AddBreadcrumb("run", "Starting policy check", sentry.LevelInfo, nil)
154
+
42
155
  // Find git root
156
+ findGitRootSpan := sentry.StartSpan(ctx, "storage.find_git_root")
43
157
  gitRoot, err := storage.FindGitRoot()
158
+ findGitRootSpan.Finish()
44
159
  if err != nil {
45
160
  return err
46
161
  }
47
162
 
48
163
  // Find all instruction files
164
+ findFilesSpan := sentry.StartSpan(ctx, "commands.find_instruction_files")
49
165
  instructionFiles, err := FindInstructionFiles(gitRoot)
166
+ findFilesSpan.Finish()
50
167
  if err != nil {
51
168
  return err
52
169
  }
@@ -56,7 +173,9 @@ func Run() error {
56
173
  }
57
174
 
58
175
  // Load cache
176
+ loadCacheSpan := sentry.StartSpan(ctx, "storage.load_cache")
59
177
  cache, err := storage.LoadCache(gitRoot)
178
+ loadCacheSpan.Finish()
60
179
  if err != nil {
61
180
  return fmt.Errorf("failed to load cache: %w", err)
62
181
  }
@@ -75,122 +194,21 @@ func Run() error {
75
194
 
76
195
  // Auto-sync only the files that changed
77
196
  if len(filesToSync) > 0 {
78
- // Get API key once upfront before parallel processing
79
- apiKey, err := config.GetAPIKey()
80
- if err != nil {
197
+ if err := syncPolicies(ctx, filesToSync, gitRoot, cache); err != nil {
81
198
  return err
82
199
  }
83
-
84
- slog.Info("Syncing policies with LLM", "files_to_process", len(filesToSync))
85
-
86
- syncStart := time.Now()
87
- ctx := context.Background()
88
-
89
- // Result type for collecting sync results
90
- type syncResult struct {
91
- file string
92
- relPath string
93
- policies []parser.Policy
94
- err error
95
- }
96
-
97
- // Channel to collect results
98
- results := make(chan syncResult, len(filesToSync))
99
- var wg sync.WaitGroup
100
-
101
- // Start spinner
102
- stop := make(chan bool)
103
- var completed int
104
- var mu sync.Mutex
105
- go func() {
106
- for {
107
- select {
108
- case <-stop:
109
- slog.Info("spinner stopped")
110
- return
111
- default:
112
- mu.Lock()
113
- c := completed
114
- mu.Unlock()
115
- slog.Info("Processing files", "completed", c, "total", len(filesToSync))
116
- time.Sleep(100 * time.Millisecond)
117
- }
118
- }
119
- }()
120
-
121
- // Launch goroutines only for changed files
122
- for _, file := range filesToSync {
123
- wg.Add(1)
124
- go func(file string) {
125
- defer wg.Done()
126
- relPath, _ := filepath.Rel(gitRoot, file)
127
- policies, err := extractor.ExtractPoliciesFromFile(ctx, file, apiKey)
128
- results <- syncResult{file, relPath, policies, err}
129
- }(file)
130
- }
131
-
132
- // Close results channel when all goroutines complete
133
- go func() {
134
- wg.Wait()
135
- close(results)
136
- }()
137
-
138
- // Collect results
139
- totalPolicies := 0
140
- for result := range results {
141
- if result.err != nil {
142
- stop <- true
143
- close(stop)
144
- time.Sleep(50 * time.Millisecond)
145
- mu.Lock()
146
- slog.Error("Failed to process file", "file", result.relPath)
147
- mu.Unlock()
148
- return fmt.Errorf("failed to extract policies from %s: %w", result.file, result.err)
149
- }
150
-
151
- if err := cache.UpdateFile(result.file, result.policies); err != nil {
152
- stop <- true
153
- close(stop)
154
- time.Sleep(50 * time.Millisecond)
155
- return fmt.Errorf("failed to update cache for %s: %w", result.file, err)
156
- }
157
-
158
- totalPolicies += len(result.policies)
159
-
160
- // Atomic update of counter and output (prevents race with spinner)
161
- mu.Lock()
162
- completed++
163
- slog.Info("Processed file", "file", result.relPath, "policies", len(result.policies))
164
- mu.Unlock()
165
- }
166
-
167
- // Stop spinner
168
- stop <- true
169
- close(stop)
170
- time.Sleep(50 * time.Millisecond)
171
-
172
- if err := cache.Save(); err != nil {
173
- return fmt.Errorf("failed to save cache: %w", err)
174
- }
175
-
176
- syncDuration := time.Since(syncStart)
177
- slog.Info("Sync complete", "policies_synced", totalPolicies, "files_synced", len(filesToSync))
178
-
179
- // Track sync metrics
180
- metrics.Track("cli.sync.complete", map[string]interface{}{
181
- "files_synced": len(filesToSync),
182
- "policies_synced": totalPolicies,
183
- "duration_seconds": syncDuration.Seconds(),
184
- })
185
200
  }
186
201
 
187
202
  // Load local policies from cache
203
+ loadPoliciesSpan := sentry.StartSpan(ctx, "cache.get_all_policies")
188
204
  localPolicies, err := cache.GetAllPolicies()
205
+ loadPoliciesSpan.Finish()
189
206
  if err != nil {
190
207
  return fmt.Errorf("failed to load policies from cache: %w", err)
191
208
  }
192
209
 
193
210
  // Try to load cloud policies for current repo
211
+ cloudPoliciesSpan := sentry.StartSpan(ctx, "storage.load_cloud_policies")
194
212
  cloudPolicies := []parser.Policy{}
195
213
  cloudStorage := storage.NewCloudPolicyStorage(gitRoot)
196
214
 
@@ -206,6 +224,7 @@ func Run() error {
206
224
  slog.Info("Loaded cloud policies", "count", len(cloudPolicies), "owner", repoInfo.Owner, "repo", repoInfo.Name)
207
225
  }
208
226
  }
227
+ cloudPoliciesSpan.Finish()
209
228
  // If repo detection failed, silently continue with local-only policies
210
229
 
211
230
  // Merge local and cloud policies (cloud takes precedence)
@@ -227,55 +246,9 @@ func Run() error {
227
246
  }
228
247
 
229
248
  // Check if a snapshot exists (from PreToolUse hook)
230
- var changesToCheck []gitpkg.ChangedLine
231
- useSnapshot := false
232
-
233
- if snapshot.Exists(gitRoot) {
234
- slog.Info("Snapshot detected - checking only Claude's changes")
235
-
236
- // Load snapshot
237
- snap, err := snapshot.Load(gitRoot)
238
- if err != nil {
239
- return fmt.Errorf("failed to load snapshot: %w", err)
240
- }
241
-
242
- // Compare current state to snapshot
243
- compareResult, err := snapshot.Compare(gitRoot, snap)
244
- if err != nil {
245
- return fmt.Errorf("failed to compare to snapshot: %w", err)
246
- }
247
-
248
- // Get changed lines
249
- snapshotChanges, err := snapshot.GetChangedLinesForChecker(gitRoot, snap, compareResult)
250
- if err != nil {
251
- return fmt.Errorf("failed to get changed lines from snapshot: %w", err)
252
- }
253
-
254
- // Convert snapshot.ChangedLine to git.ChangedLine
255
- for _, sc := range snapshotChanges {
256
- changesToCheck = append(changesToCheck, gitpkg.ChangedLine{
257
- File: sc.File,
258
- LineNumber: sc.LineNumber,
259
- Content: sc.Content,
260
- ChangeType: sc.ChangeType,
261
- })
262
- }
263
-
264
- // Delete snapshot after using it
265
- if err := snapshot.Delete(gitRoot); err != nil {
266
- slog.Warn("Failed to delete snapshot", "error", err)
267
- }
268
-
269
- useSnapshot = true
270
- } else {
271
- // No snapshot - fall back to checking all git changes
272
- slog.Info("Checking all changes (unstaged + staged)")
273
- diffResult, err := gitpkg.GetAllChanges()
274
- if err != nil {
275
- return fmt.Errorf("error getting git diff: %w", err)
276
- }
277
-
278
- changesToCheck = diffResult.Changes
249
+ changesToCheck, useSnapshot, err := getChangesToCheck(ctx, gitRoot)
250
+ if err != nil {
251
+ return err
279
252
  }
280
253
 
281
254
  if len(changesToCheck) == 0 {
@@ -287,6 +260,11 @@ func Run() error {
287
260
  return nil
288
261
  }
289
262
 
263
+ utils.AddBreadcrumb("run", "Scanning changes", sentry.LevelInfo, map[string]interface{}{
264
+ "changes_count": len(changesToCheck),
265
+ "use_snapshot": useSnapshot,
266
+ })
267
+
290
268
  slog.Info("Scanning changed lines", "count", len(changesToCheck))
291
269
 
292
270
  // Get API key once upfront before checking
@@ -296,10 +274,13 @@ func Run() error {
296
274
  }
297
275
 
298
276
  // Check changes against policies (both regex and LLM-based)
299
- ctx := context.Background()
277
+ checkSpan := sentry.StartSpan(ctx, "checker.check_changes")
278
+ checkSpan.SetData("changes_count", len(changesToCheck))
279
+ checkSpan.SetData("policies_count", len(policies))
300
280
  checkStart := time.Now()
301
281
  result := checker.CheckChanges(ctx, changesToCheck, policies, apiKey)
302
282
  checkDuration := time.Since(checkStart)
283
+ checkSpan.Finish()
303
284
 
304
285
  // Track policy check results (similar to policy.execute.result in github-app)
305
286
  metrics.Track("cli.policy.check.result", map[string]interface{}{
@@ -311,6 +292,11 @@ func Run() error {
311
292
  "used_snapshot": useSnapshot,
312
293
  })
313
294
 
295
+ utils.AddBreadcrumb("run", "Policy check complete", sentry.LevelInfo, map[string]interface{}{
296
+ "violations_found": len(result.Violations),
297
+ "duration_seconds": checkDuration.Seconds(),
298
+ })
299
+
314
300
  // Handle results based on whether violations were found
315
301
  if len(result.Violations) > 0 {
316
302
  // Determine parent process to decide output format
@@ -321,6 +307,10 @@ func Run() error {
321
307
  claudeInstructions := checker.FormatClaudeInstructions(result)
322
308
  fmt.Fprint(os.Stderr, claudeInstructions)
323
309
 
310
+ // Finish span and flush Sentry before exit (os.Exit bypasses deferred functions)
311
+ span.Finish()
312
+ sentry.Flush(2 * time.Second)
313
+
324
314
  // Exit with code 2 to trigger Claude Code hook behavior
325
315
  os.Exit(2)
326
316
  } else if parentProcess == "cursor" {
@@ -338,3 +328,51 @@ func Run() error {
338
328
  fmt.Fprint(os.Stdout, "No policy violations found")
339
329
  return nil
340
330
  }
331
+
332
+ func getChangesToCheck(ctx context.Context, gitRoot string) ([]gitpkg.ChangedLine, bool, error) {
333
+ span := sentry.StartSpan(ctx, "commands.get_changes_to_check")
334
+ defer span.Finish()
335
+
336
+ if snapshot.Exists(gitRoot) {
337
+ slog.Info("Snapshot detected - checking only Claude's changes")
338
+
339
+ snap, err := snapshot.Load(gitRoot)
340
+ if err != nil {
341
+ return nil, false, fmt.Errorf("failed to load snapshot: %w", err)
342
+ }
343
+
344
+ compareResult, err := snapshot.Compare(gitRoot, snap)
345
+ if err != nil {
346
+ return nil, false, fmt.Errorf("failed to compare to snapshot: %w", err)
347
+ }
348
+
349
+ snapshotChanges, err := snapshot.GetChangedLinesForChecker(gitRoot, snap, compareResult)
350
+ if err != nil {
351
+ return nil, false, fmt.Errorf("failed to get changed lines from snapshot: %w", err)
352
+ }
353
+
354
+ var changes []gitpkg.ChangedLine
355
+ for _, sc := range snapshotChanges {
356
+ changes = append(changes, gitpkg.ChangedLine{
357
+ File: sc.File,
358
+ LineNumber: sc.LineNumber,
359
+ Content: sc.Content,
360
+ ChangeType: sc.ChangeType,
361
+ })
362
+ }
363
+
364
+ if err := snapshot.Delete(gitRoot); err != nil {
365
+ slog.Warn("Failed to delete snapshot", "error", err)
366
+ }
367
+
368
+ return changes, true, nil
369
+ }
370
+
371
+ slog.Info("Checking all changes (unstaged + staged)")
372
+ diffResult, err := gitpkg.GetAllChanges()
373
+ if err != nil {
374
+ return nil, false, fmt.Errorf("error getting git diff: %w", err)
375
+ }
376
+
377
+ return diffResult.Changes, false, nil
378
+ }
@@ -1,10 +1,12 @@
1
1
  package commands
2
2
 
3
3
  import (
4
+ "context"
4
5
  "encoding/json"
5
6
  "fmt"
6
7
  "os"
7
8
 
9
+ "github.com/getsentry/sentry-go"
8
10
  "github.com/tanagram/cli/snapshot"
9
11
  "github.com/tanagram/cli/storage"
10
12
  "github.com/tanagram/cli/utils"
@@ -18,9 +20,14 @@ var (
18
20
  )
19
21
 
20
22
  // Snapshot creates a snapshot of the current working directory state
21
- func Snapshot() error {
23
+ func Snapshot(ctx context.Context) error {
24
+ span := sentry.StartSpan(ctx, "command.snapshot")
25
+ defer span.Finish()
26
+
22
27
  // Find git root
28
+ findGitRootSpan := sentry.StartSpan(span.Context(), "storage.find_git_root")
23
29
  gitRoot, err := findGitRoot()
30
+ findGitRootSpan.Finish()
24
31
  if err != nil {
25
32
  return err
26
33
  }
@@ -46,9 +53,12 @@ func Snapshot() error {
46
53
  }
47
54
 
48
55
  // Create snapshot
56
+ createSnapshotSpan := sentry.StartSpan(span.Context(), "snapshot.create_optimized")
49
57
  if err := createOptimized(gitRoot, targetFiles); err != nil {
58
+ createSnapshotSpan.Finish()
50
59
  return fmt.Errorf("failed to create snapshot: %w", err)
51
60
  }
61
+ createSnapshotSpan.Finish()
52
62
 
53
63
  parentProcess := getParentProcess()
54
64
  if parentProcess == "Cursor" || parentProcess == "cursor" {
@@ -2,6 +2,7 @@ package commands
2
2
 
3
3
  import (
4
4
  "bytes"
5
+ "context"
5
6
  "io"
6
7
  "os"
7
8
  "testing"
@@ -65,7 +66,7 @@ func TestSnapshot_Cursor(t *testing.T) {
65
66
  r, w, _ := os.Pipe()
66
67
  os.Stdout = w
67
68
 
68
- err := Snapshot()
69
+ err := Snapshot(context.Background())
69
70
 
70
71
  // Restore stdout
71
72
  w.Close()
package/commands/sync.go CHANGED
@@ -8,15 +8,23 @@ import (
8
8
  "sync"
9
9
  "time"
10
10
 
11
+ "github.com/getsentry/sentry-go"
11
12
  "github.com/tanagram/cli/api"
12
13
  "github.com/tanagram/cli/config"
13
14
  "github.com/tanagram/cli/extractor"
14
15
  "github.com/tanagram/cli/parser"
15
16
  "github.com/tanagram/cli/storage"
17
+ "github.com/tanagram/cli/utils"
16
18
  )
17
19
 
18
20
  // Sync manually syncs all instruction files to the cache
19
- func Sync() error {
21
+ func Sync(ctx context.Context) error {
22
+ span := sentry.StartSpan(ctx, "command.sync")
23
+ defer span.Finish()
24
+ ctx = span.Context()
25
+
26
+ utils.AddBreadcrumb("sync", "Starting policy sync", sentry.LevelInfo, nil)
27
+
20
28
  // Get API key first before doing any work
21
29
  apiKey, err := getAPIKey()
22
30
  if err != nil {
@@ -24,13 +32,21 @@ func Sync() error {
24
32
  }
25
33
 
26
34
  // Find git root
35
+ findGitRootSpan := sentry.StartSpan(ctx, "storage.find_git_root")
27
36
  gitRoot, err := storage.FindGitRoot()
37
+ findGitRootSpan.Finish()
28
38
  if err != nil {
29
39
  return err
30
40
  }
31
41
 
42
+ utils.AddBreadcrumb("sync", "Found git root", sentry.LevelInfo, map[string]interface{}{
43
+ "git_root": gitRoot,
44
+ })
45
+
32
46
  // Find all instruction files
47
+ findFilesSpan := sentry.StartSpan(ctx, "commands.find_instruction_files")
33
48
  instructionFiles, err := FindInstructionFiles(gitRoot)
49
+ findFilesSpan.Finish()
34
50
  if err != nil {
35
51
  return err
36
52
  }
@@ -42,7 +58,9 @@ func Sync() error {
42
58
  fmt.Printf("Found %d instruction file(s)\n", len(instructionFiles))
43
59
 
44
60
  // Load or create cache
61
+ loadCacheSpan := sentry.StartSpan(ctx, "storage.load_cache")
45
62
  cache, err := storage.LoadCache(gitRoot)
63
+ loadCacheSpan.Finish()
46
64
  if err != nil {
47
65
  return fmt.Errorf("failed to load cache: %w", err)
48
66
  }
@@ -74,9 +92,40 @@ func Sync() error {
74
92
  }
75
93
 
76
94
  // Parse and sync only changed files using LLM in parallel
77
- fmt.Printf("\nSyncing policies with LLM (processing %d changed file(s) in parallel)...\n", len(filesToSync))
95
+ totalPolicies, err := extractPoliciesParallel(ctx, filesToSync, gitRoot, apiKey, cache)
96
+ if err != nil {
97
+ return err
98
+ }
99
+
100
+ // Save cache
101
+ if err := cache.Save(); err != nil {
102
+ return fmt.Errorf("failed to save cache: %w", err)
103
+ }
104
+
105
+ utils.AddBreadcrumb("sync", "Local sync complete", sentry.LevelInfo, map[string]interface{}{
106
+ "policies_synced": totalPolicies,
107
+ "files_synced": len(filesToSync),
108
+ })
109
+
110
+ fmt.Printf("\n✓ Synced %d policies from %d changed file(s)\n", totalPolicies, len(filesToSync))
111
+
112
+ // Also sync cloud policies if user is authenticated
113
+ if err := syncCloudPolicies(gitRoot); err != nil {
114
+ // Don't fail the whole sync if cloud sync fails - just warn
115
+ fmt.Printf("\nWarning: Could not sync cloud policies: %v\n", err)
116
+ fmt.Println("(Run 'tanagram login' to sync policies from cloud)")
117
+ }
118
+
119
+ return nil
120
+ }
121
+
122
+ // extractPoliciesParallel extracts policies from files in parallel using the LLM
123
+ func extractPoliciesParallel(ctx context.Context, filesToSync []string, gitRoot, apiKey string, cache *storage.Cache) (int, error) {
124
+ span := sentry.StartSpan(ctx, "extractor.extract_policies_parallel")
125
+ defer span.Finish()
126
+ span.SetData("files_to_sync", len(filesToSync))
78
127
 
79
- ctx := context.Background()
128
+ fmt.Printf("\nSyncing policies with LLM (processing %d changed file(s) in parallel)...\n", len(filesToSync))
80
129
 
81
130
  // Result type for collecting sync results
82
131
  type syncResult struct {
@@ -130,24 +179,27 @@ func Sync() error {
130
179
  close(results)
131
180
  }()
132
181
 
182
+ // stopSpinner stops the spinner goroutine safely
183
+ stopSpinner := func() {
184
+ stop <- true
185
+ close(stop)
186
+ time.Sleep(50 * time.Millisecond)
187
+ }
188
+
133
189
  // Collect results
134
190
  totalPolicies := 0
135
191
  for result := range results {
136
192
  if result.err != nil {
137
- stop <- true
138
- close(stop)
139
- time.Sleep(50 * time.Millisecond)
193
+ stopSpinner()
140
194
  mu.Lock()
141
195
  fmt.Printf("\r\033[K✗ Failed to process %s\n", result.relPath)
142
196
  mu.Unlock()
143
- return fmt.Errorf("failed to extract policies from %s: %w", result.file, result.err)
197
+ return 0, fmt.Errorf("failed to extract policies from %s: %w", result.file, result.err)
144
198
  }
145
199
 
146
200
  if err := cache.UpdateFile(result.file, result.policies); err != nil {
147
- stop <- true
148
- close(stop)
149
- time.Sleep(50 * time.Millisecond)
150
- return fmt.Errorf("failed to update cache for %s: %w", result.file, err)
201
+ stopSpinner()
202
+ return 0, fmt.Errorf("failed to update cache for %s: %w", result.file, err)
151
203
  }
152
204
 
153
205
  totalPolicies += len(result.policies)
@@ -160,25 +212,9 @@ func Sync() error {
160
212
  }
161
213
 
162
214
  // Stop spinner
163
- stop <- true
164
- close(stop)
165
- time.Sleep(50 * time.Millisecond)
215
+ stopSpinner()
166
216
 
167
- // Save cache
168
- if err := cache.Save(); err != nil {
169
- return fmt.Errorf("failed to save cache: %w", err)
170
- }
171
-
172
- fmt.Printf("\n✓ Synced %d policies from %d changed file(s)\n", totalPolicies, len(filesToSync))
173
-
174
- // Also sync cloud policies if user is authenticated
175
- if err := syncCloudPolicies(gitRoot); err != nil {
176
- // Don't fail the whole sync if cloud sync fails - just warn
177
- fmt.Printf("\nWarning: Could not sync cloud policies: %v\n", err)
178
- fmt.Println("(Run 'tanagram login' to sync policies from cloud)")
179
- }
180
-
181
- return nil
217
+ return totalPolicies, nil
182
218
  }
183
219
 
184
220
  // FindInstructionFiles searches for instruction files in the git repository