@tanagram/cli 0.4.10 → 0.4.13

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 CHANGED
@@ -1,4 +1,4 @@
1
- # Tanagram
1
+ # Tanagram CLI
2
2
 
3
3
  A lightweight Go CLI that enforces policies from `AGENTS.md` files on your local git changes.
4
4
 
@@ -23,8 +23,10 @@ webui/src/Button.tsx:42 - [No hardcoded colors] Don't use hard-coded color value
23
23
  # 1. Install globally via npm
24
24
  npm install -g @tanagram/cli
25
25
 
26
- # 2. Setup Claude Code hook (automatic)
26
+ # 2. Automatically add a Claude Code hook
27
27
  tanagram config claude
28
+ # and/or if you use Cursor:
29
+ tanagram config cursor
28
30
 
29
31
  # 3. Run tanagram (will prompt for API key on first run)
30
32
  tanagram
@@ -45,28 +47,6 @@ Tanagram uses Claude AI (via Anthropic API) to extract policies from your instru
45
47
  2. Create an API key in the dashboard
46
48
  3. Run `tanagram` and enter your key when prompted
47
49
 
48
- ### Local Development
49
-
50
- ```bash
51
- cd cli
52
- npm install # Builds the Go binary
53
- ./bin/tanagram
54
- ```
55
-
56
- ### Install Locally for Testing
57
-
58
- Install globally from the local directory to test as if it were published:
59
-
60
- ```bash
61
- cd /Users/molinar/tanagram/cli
62
- npm install -g .
63
- ```
64
-
65
- Then run from anywhere:
66
- ```bash
67
- tanagram
68
- ```
69
-
70
50
  ## Usage
71
51
 
72
52
  ```bash
@@ -113,35 +93,26 @@ tanagram config list
113
93
  **Manual setup (alternative):**
114
94
  If you prefer to manually edit your settings, add this to your `~/.claude/settings.json` (user settings) or `.claude/settings.json` (project settings):
115
95
 
116
- ```json
117
- "hooks": {
118
- "PostToolUse": [
119
- {
120
- "matcher": "Edit|Write",
121
- "hooks": [
122
- {
123
- "type": "command",
124
- "command": "tanagram"
125
- }
126
- ]
127
- }
128
- ]
129
- }
130
- ```
131
-
132
- For example, your full `settings.json` file might look like this:
133
-
134
96
  ```json
135
97
  {
136
- "alwaysThinkingEnabled": true,
137
98
  "hooks": {
138
- "PostToolUse": [
99
+ "SessionStart": [
139
100
  {
140
- "matcher": "Edit|Write",
141
101
  "hooks": [
142
102
  {
143
- "type": "command",
144
- "command": "tanagram"
103
+ "command": "tanagram snapshot",
104
+ "type": "command"
105
+ }
106
+ ],
107
+ "matcher": "startup|clear"
108
+ }
109
+ ],
110
+ "Stop": [
111
+ {
112
+ "hooks": [
113
+ {
114
+ "command": "tanagram",
115
+ "type": "command"
145
116
  }
146
117
  ]
147
118
  }
@@ -152,6 +123,45 @@ For example, your full `settings.json` file might look like this:
152
123
 
153
124
  If you have existing hooks, you can merge this hook into your existing config.
154
125
 
126
+ ### Cursor Hook
127
+
128
+ Install the CLI as a Cursor Code [hook](https://cursor.com/docs/agent/hooks) to have Cursor automatically iterate on Tanagram's output.
129
+
130
+ **Easy setup (recommended):**
131
+ ```bash
132
+ tanagram config cursor
133
+ ```
134
+
135
+ This automatically adds the hook to your `~/.cursor/hooks.json`. It's safe to run multiple times and will preserve any existing settings.
136
+
137
+ **Check hook status:**
138
+ ```bash
139
+ tanagram config list
140
+ ```
141
+
142
+ **Manual setup (alternative):**
143
+ If you prefer to manually edit your settings, add this to your `~/.cursor/hooks.json` (user settings) or `.cursor/hooks.json` (project settings):
144
+
145
+ ```json
146
+ {
147
+ "hooks": {
148
+ "beforeSubmitPrompt": [
149
+ {
150
+ "command": "tanagram snapshot"
151
+ }
152
+ ],
153
+ "stop": [
154
+ {
155
+ "command": "tanagram"
156
+ }
157
+ ]
158
+ },
159
+ "version": 1
160
+ }
161
+ ```
162
+
163
+ If you have existing hooks, you can merge this hook into your existing config.
164
+
155
165
 
156
166
  ## How It Works
157
167
 
@@ -171,24 +181,6 @@ Policies are cached in `.tanagram/cache.gob` at your git repository root. Add th
171
181
  .tanagram/
172
182
  ```
173
183
 
174
- ## Fully LLM-Based Architecture
175
-
176
- Tanagram uses **100% LLM-powered** policy extraction and enforcement:
177
-
178
- ### Extraction Phase
179
- Claude AI extracts **ALL** policies from instruction files:
180
- - No classification needed (no MUST_NOT_USE, MUST_USE, etc.)
181
- - No regex pattern generation
182
- - Simple: Just extract policy names and descriptions
183
- - Fast: Simpler prompts = faster responses
184
-
185
- ### Detection Phase
186
- Claude AI analyzes code changes against all policies:
187
- - **Semantic understanding** - Not just pattern matching
188
- - **Context-aware** - Understands code intent and structure
189
- - **Language-agnostic** - Works with any programming language
190
- - **Detailed reasoning** - Explains why code violates each policy
191
-
192
184
  ### What Can Be Enforced
193
185
 
194
186
  **Everything!** Because the LLM reads and understands code like a human:
@@ -210,7 +202,7 @@ Claude AI analyzes code changes against all policies:
210
202
  - Won't flag Go code for missing Python type hints
211
203
  - Understands JavaScript !== Python !== Go
212
204
 
213
- ## Exit Codes
205
+ ### Exit Codes
214
206
 
215
207
  - `0` - No violations found
216
208
  - `2` - Violations found (triggers Claude Code automatic fix behavior)
@@ -246,6 +238,28 @@ Then run `tanagram` to enforce them locally!
246
238
 
247
239
  **Note:** For `.mdc` files, Tanagram extracts policies from the markdown content only (YAML frontmatter is used by Cursor and ignored during policy extraction).
248
240
 
241
+ ## Tanagram Web Integration
242
+
243
+ You can also use [Tanagram](https://tanagram.ai) to manage policies across your organization and enforce them on PRs.
244
+ If you have policies defined online, you can enforce them while you develop locally with the CLI as well.
245
+
246
+ ```bash
247
+ # Connect your account
248
+ tanagram login
249
+
250
+ # Download policies from your Tanagram account and cache them locally
251
+ tanagram sync
252
+ ```
253
+
254
+ For customers with an on-prem installation, set the `TANAGRAM_WEB_HOSTNAME` environment variable to the URL of your Tanagram instance — for example:
255
+
256
+ ```bash
257
+ export TANAGRAM_WEB_HOSTNAME=https://yourcompany.tanagram.ai
258
+
259
+ tanagram login
260
+ tanagram sync
261
+ ```
262
+
249
263
  ---
250
264
 
251
265
  Built by [@fluttermatt](https://x.com/fluttermatt) and the [Tanagram](https://tanagram.ai/) team. Talk to us [on Twitter](https://x.com/tanagram_) or email: founders AT tanagram.ai
package/api/client.go CHANGED
@@ -5,7 +5,9 @@ import (
5
5
  "fmt"
6
6
  "io"
7
7
  "net/http"
8
+ "net/url"
8
9
  "os"
10
+ "strings"
9
11
  "time"
10
12
 
11
13
  "github.com/tanagram/cli/auth"
@@ -50,8 +52,16 @@ type PolicyListResponse struct {
50
52
 
51
53
  // getAPIBaseURL returns the API base URL
52
54
  func getAPIBaseURL() string {
53
- if url := os.Getenv("TANAGRAM_API_URL"); url != "" {
54
- return url
55
+ if urlStr := os.Getenv("TANAGRAM_WEB_HOSTNAME"); urlStr != "" {
56
+ u, err := url.Parse(urlStr)
57
+ if err != nil {
58
+ return urlStr
59
+ }
60
+ if !strings.HasPrefix(u.Host, "api-") {
61
+ u.Host = "api-" + u.Host
62
+ return u.String()
63
+ }
64
+ return urlStr
55
65
  }
56
66
  return "https://api.tanagram.ai"
57
67
  }
@@ -0,0 +1,53 @@
1
+ package api
2
+
3
+ import (
4
+ "os"
5
+ "testing"
6
+ )
7
+
8
+ func TestGetAPIBaseURL(t *testing.T) {
9
+ // Save original env var
10
+ orig := os.Getenv("TANAGRAM_WEB_HOSTNAME")
11
+ defer os.Setenv("TANAGRAM_WEB_HOSTNAME", orig)
12
+
13
+ tests := []struct {
14
+ name string
15
+ envValue string
16
+ want string
17
+ }{
18
+ {
19
+ name: "default",
20
+ envValue: "",
21
+ want: "https://api.tanagram.ai",
22
+ },
23
+ {
24
+ name: "with api- prefix",
25
+ envValue: "https://api-runway.tanagram.ai",
26
+ want: "https://api-runway.tanagram.ai",
27
+ },
28
+ {
29
+ name: "without api- prefix",
30
+ envValue: "https://runway.tanagram.ai",
31
+ want: "https://api-runway.tanagram.ai",
32
+ },
33
+ {
34
+ name: "http without api- prefix",
35
+ envValue: "http://localhost:8080",
36
+ want: "http://api-localhost:8080",
37
+ },
38
+ }
39
+
40
+ for _, tt := range tests {
41
+ t.Run(tt.name, func(t *testing.T) {
42
+ if tt.envValue == "" {
43
+ os.Unsetenv("TANAGRAM_WEB_HOSTNAME")
44
+ } else {
45
+ os.Setenv("TANAGRAM_WEB_HOSTNAME", tt.envValue)
46
+ }
47
+
48
+ if got := getAPIBaseURL(); got != tt.want {
49
+ t.Errorf("getAPIBaseURL() = %v, want %v", got, tt.want)
50
+ }
51
+ })
52
+ }
53
+ }
@@ -22,36 +22,36 @@ func ConfigClaude(settingsPath string) error {
22
22
  settings["hooks"] = hooks
23
23
  }
24
24
 
25
- // Check if PreToolUse exists
26
- preToolUse, preToolUseExist := hooks["PreToolUse"].([]interface{})
27
- if !preToolUseExist {
28
- preToolUse = []interface{}{}
25
+ // Check if SessionStart exists
26
+ sessionStart, sessionStartExist := hooks["SessionStart"].([]interface{})
27
+ if !sessionStartExist {
28
+ sessionStart = []interface{}{}
29
29
  }
30
30
 
31
- // Check if PostToolUse exists
32
- postToolUse, postToolUseExist := hooks["PostToolUse"].([]interface{})
33
- if !postToolUseExist {
34
- postToolUse = []interface{}{}
31
+ // Check if Stop exists
32
+ stopHooks, stopHooksExist := hooks["Stop"].([]interface{})
33
+ if !stopHooksExist {
34
+ stopHooks = []interface{}{}
35
35
  }
36
36
 
37
37
  // Check if tanagram hooks already exist
38
- preHookExists := false
39
- postHookExists := false
38
+ sessionStartHookExists := false
39
+ stopHookExists := false
40
40
 
41
- for _, hook := range preToolUse {
41
+ for _, hook := range sessionStart {
42
42
  hookMap, ok := hook.(map[string]interface{})
43
43
  if !ok {
44
44
  continue
45
45
  }
46
46
 
47
- if matcher, ok := hookMap["matcher"].(string); ok && matcher == "Edit|Write" {
47
+ if matcher, ok := hookMap["matcher"].(string); ok && matcher == "startup|clear" {
48
48
  innerHooks, ok := hookMap["hooks"].([]interface{})
49
49
  if ok {
50
50
  for _, innerHook := range innerHooks {
51
51
  innerHookMap, ok := innerHook.(map[string]interface{})
52
52
  if ok {
53
- if cmd, ok := innerHookMap["command"].(string); ok && cmd == "tanagram snapshot" {
54
- preHookExists = true
53
+ if cmd, ok := innerHookMap["command"].(string); ok && strings.HasSuffix(cmd, "tanagram snapshot") {
54
+ sessionStartHookExists = true
55
55
  break
56
56
  }
57
57
  }
@@ -60,37 +60,34 @@ func ConfigClaude(settingsPath string) error {
60
60
  }
61
61
  }
62
62
 
63
- for _, hook := range postToolUse {
63
+ for _, hook := range stopHooks {
64
64
  hookMap, ok := hook.(map[string]interface{})
65
65
  if !ok {
66
66
  continue
67
67
  }
68
68
 
69
- if matcher, ok := hookMap["matcher"].(string); ok && matcher == "Edit|Write" {
70
- innerHooks, ok := hookMap["hooks"].([]interface{})
71
- if ok {
72
- for _, innerHook := range innerHooks {
73
- innerHookMap, ok := innerHook.(map[string]interface{})
74
- if ok {
75
- if cmd, ok := innerHookMap["command"].(string); ok && cmd == "tanagram" {
76
- postHookExists = true
77
- break
78
- }
69
+ innerHooks, ok := hookMap["hooks"].([]interface{})
70
+ if ok {
71
+ for _, innerHook := range innerHooks {
72
+ innerHookMap, ok := innerHook.(map[string]interface{})
73
+ if ok {
74
+ if cmd, ok := innerHookMap["command"].(string); ok && strings.HasSuffix(cmd, "tanagram") {
75
+ stopHookExists = true
76
+ break
79
77
  }
80
78
  }
81
79
  }
82
-
83
80
  }
84
81
  }
85
82
 
86
- if preHookExists && postHookExists {
83
+ if sessionStartHookExists && stopHookExists {
87
84
  fmt.Printf("✓ Tanagram hooks are already configured in %s\n", settingsPath)
88
85
  return nil
89
86
  }
90
87
 
91
- if !preHookExists {
92
- preHook := map[string]interface{}{
93
- "matcher": "Edit|Write",
88
+ if !sessionStartHookExists {
89
+ startHook := map[string]interface{}{
90
+ "matcher": "startup|clear",
94
91
  "hooks": []interface{}{
95
92
  map[string]interface{}{
96
93
  "type": "command",
@@ -98,13 +95,12 @@ func ConfigClaude(settingsPath string) error {
98
95
  },
99
96
  },
100
97
  }
101
- preToolUse = append(preToolUse, preHook)
102
- hooks["PreToolUse"] = preToolUse
98
+ sessionStart = append(sessionStart, startHook)
99
+ hooks["SessionStart"] = sessionStart
103
100
  }
104
101
 
105
- if !postHookExists {
106
- postHook := map[string]interface{}{
107
- "matcher": "Edit|Write",
102
+ if !stopHookExists {
103
+ stopHook := map[string]interface{}{
108
104
  "hooks": []interface{}{
109
105
  map[string]interface{}{
110
106
  "type": "command",
@@ -112,8 +108,8 @@ func ConfigClaude(settingsPath string) error {
112
108
  },
113
109
  },
114
110
  }
115
- postToolUse = append(postToolUse, postHook)
116
- hooks["PostToolUse"] = postToolUse
111
+ stopHooks = append(stopHooks, stopHook)
112
+ hooks["Stop"] = stopHooks
117
113
  }
118
114
 
119
115
  if err := saveConfig(settingsPath, settings); err != nil {
@@ -122,8 +118,8 @@ func ConfigClaude(settingsPath string) error {
122
118
 
123
119
  fmt.Printf("✓ Tanagram hooks added to %s\n", settingsPath)
124
120
  fmt.Println("\nClaude Code will now:")
125
- fmt.Println(" - Snapshot file state before each Edit/Write (PreToolUse)")
126
- fmt.Println(" - Check only Claude's changes after Edit/Write (PostToolUse)")
121
+ fmt.Println(" - Snapshot file state at session start (SessionStart)")
122
+ fmt.Println(" - Check only Claude's changes after agent completes (Stop)")
127
123
  fmt.Println(" - Send policy violations to Claude for automatic fixing")
128
124
  fmt.Println("\nThis prevents false positives from user-written code!")
129
125
 
@@ -172,7 +168,7 @@ func ConfigCursor(hooksPath string) error {
172
168
  continue
173
169
  }
174
170
 
175
- if cmd, ok := hookMap["command"].(string); ok && cmd == "tanagram snapshot" {
171
+ if cmd, ok := hookMap["command"].(string); ok && strings.HasSuffix(cmd, "tanagram snapshot") {
176
172
  beforeSubmitHookExists = true
177
173
  break
178
174
  }
@@ -184,7 +180,7 @@ func ConfigCursor(hooksPath string) error {
184
180
  continue
185
181
  }
186
182
 
187
- if cmd, ok := hookMap["command"].(string); ok && cmd == "tanagram" {
183
+ if cmd, ok := hookMap["command"].(string); ok && strings.HasSuffix(cmd, "tanagram") {
188
184
  stopHookExists = true
189
185
  break
190
186
  }
@@ -264,13 +260,13 @@ func ConfigList() error {
264
260
 
265
261
  // ClaudeHookStatus represents the status of a hook configuration
266
262
  type ClaudeHookStatus struct {
267
- FileExists bool
268
- PreHookExists bool
269
- PostHookExists bool
270
- IsUpToDate bool
271
- PreCommand string
272
- PostCommand string
273
- Error error
263
+ FileExists bool
264
+ SessionStartHookExists bool
265
+ StopHookExists bool
266
+ IsUpToDate bool
267
+ SessionStartCommand string
268
+ StopCommand string
269
+ Error error
274
270
  }
275
271
 
276
272
  // CursorHookStatus represents the status of a Cursor hook configuration
@@ -287,13 +283,13 @@ type CursorHookStatus struct {
287
283
  // checkClaudeHookStatus checks the status of Tanagram hook in a settings file
288
284
  func checkClaudeHookStatus(settingsPath string) ClaudeHookStatus {
289
285
  status := ClaudeHookStatus{
290
- FileExists: false,
291
- PreHookExists: false,
292
- PostHookExists: false,
293
- IsUpToDate: false,
294
- PreCommand: "",
295
- PostCommand: "",
296
- Error: nil,
286
+ FileExists: false,
287
+ SessionStartHookExists: false,
288
+ StopHookExists: false,
289
+ IsUpToDate: false,
290
+ SessionStartCommand: "",
291
+ StopCommand: "",
292
+ Error: nil,
297
293
  }
298
294
 
299
295
  if _, err := os.Stat(settingsPath); os.IsNotExist(err) {
@@ -313,15 +309,15 @@ func checkClaudeHookStatus(settingsPath string) ClaudeHookStatus {
313
309
  return status
314
310
  }
315
311
 
316
- if preToolUse, ok := hooks["PreToolUse"].([]interface{}); ok {
317
- for _, hook := range preToolUse {
312
+ if sessionStart, ok := hooks["SessionStart"].([]interface{}); ok {
313
+ for _, hook := range sessionStart {
318
314
  hookMap, ok := hook.(map[string]interface{})
319
315
  if !ok {
320
316
  continue
321
317
  }
322
318
 
323
319
  matcher, ok := hookMap["matcher"].(string)
324
- if !ok || matcher != "Edit|Write" {
320
+ if !ok || matcher != "startup|clear" {
325
321
  continue
326
322
  }
327
323
 
@@ -338,25 +334,20 @@ func checkClaudeHookStatus(settingsPath string) ClaudeHookStatus {
338
334
 
339
335
  cmd, cmdOk := innerHookMap["command"].(string)
340
336
  if cmdOk && strings.Contains(cmd, "tanagram snapshot") {
341
- status.PreHookExists = true
342
- status.PreCommand = cmd
337
+ status.SessionStartHookExists = true
338
+ status.SessionStartCommand = cmd
343
339
  }
344
340
  }
345
341
  }
346
342
  }
347
343
 
348
- if postToolUse, ok := hooks["PostToolUse"].([]interface{}); ok {
349
- for _, hook := range postToolUse {
344
+ if stopHooks, ok := hooks["Stop"].([]interface{}); ok {
345
+ for _, hook := range stopHooks {
350
346
  hookMap, ok := hook.(map[string]interface{})
351
347
  if !ok {
352
348
  continue
353
349
  }
354
350
 
355
- matcher, ok := hookMap["matcher"].(string)
356
- if !ok || matcher != "Edit|Write" {
357
- continue
358
- }
359
-
360
351
  innerHooks, ok := hookMap["hooks"].([]interface{})
361
352
  if !ok {
362
353
  continue
@@ -371,15 +362,15 @@ func checkClaudeHookStatus(settingsPath string) ClaudeHookStatus {
371
362
  cmd, cmdOk := innerHookMap["command"].(string)
372
363
  hookType, typeOk := innerHookMap["type"].(string)
373
364
 
374
- if cmdOk && cmd == "tanagram" && typeOk && hookType == "command" {
375
- status.PostHookExists = true
376
- status.PostCommand = cmd
365
+ if cmdOk && strings.HasSuffix(cmd, "tanagram") && typeOk && hookType == "command" {
366
+ status.StopHookExists = true
367
+ status.StopCommand = cmd
377
368
  }
378
369
  }
379
370
  }
380
371
  }
381
372
 
382
- if status.PreHookExists && status.PostHookExists {
373
+ if status.SessionStartHookExists && status.StopHookExists {
383
374
  status.IsUpToDate = true
384
375
  }
385
376
 
@@ -423,7 +414,7 @@ func checkCursorHookStatus(hooksPath string) CursorHookStatus {
423
414
  }
424
415
 
425
416
  cmd, cmdOk := hookMap["command"].(string)
426
- if cmdOk && cmd == "tanagram snapshot" {
417
+ if cmdOk && strings.HasSuffix(cmd, "tanagram snapshot") {
427
418
  status.BeforeSubmitExists = true
428
419
  status.BeforeSubmitCommand = cmd
429
420
  }
@@ -438,7 +429,7 @@ func checkCursorHookStatus(hooksPath string) CursorHookStatus {
438
429
  }
439
430
 
440
431
  cmd, cmdOk := hookMap["command"].(string)
441
- if cmdOk && cmd == "tanagram" {
432
+ if cmdOk && strings.HasSuffix(cmd, "tanagram") {
442
433
  status.StopExists = true
443
434
  status.StopCommand = cmd
444
435
  }
@@ -465,7 +456,7 @@ func printClaudeHookStatus(status ClaudeHookStatus, path string) {
465
456
  return
466
457
  }
467
458
 
468
- if !status.PreHookExists && !status.PostHookExists {
459
+ if !status.SessionStartHookExists && !status.StopHookExists {
469
460
  fmt.Printf(" ○ Not configured\n")
470
461
  fmt.Printf(" → Run: tanagram config claude\n")
471
462
  return
@@ -473,20 +464,20 @@ func printClaudeHookStatus(status ClaudeHookStatus, path string) {
473
464
 
474
465
  if status.IsUpToDate {
475
466
  fmt.Printf(" ✓ Configured and up to date\n")
476
- fmt.Printf(" PreToolUse: %s\n", status.PreCommand)
477
- fmt.Printf(" PostToolUse: %s\n", status.PostCommand)
467
+ fmt.Printf(" SessionStart: %s\n", status.SessionStartCommand)
468
+ fmt.Printf(" Stop: %s\n", status.StopCommand)
478
469
  fmt.Printf(" Location: %s\n", path)
479
470
  } else {
480
471
  fmt.Printf(" ⚠ Configured but incomplete\n")
481
- if status.PreHookExists {
482
- fmt.Printf(" ✓ PreToolUse: %s\n", status.PreCommand)
472
+ if status.SessionStartHookExists {
473
+ fmt.Printf(" ✓ SessionStart: %s\n", status.SessionStartCommand)
483
474
  } else {
484
- fmt.Printf(" ✗ PreToolUse: missing\n")
475
+ fmt.Printf(" ✗ SessionStart: missing\n")
485
476
  }
486
- if status.PostHookExists {
487
- fmt.Printf(" ✓ PostToolUse: %s\n", status.PostCommand)
477
+ if status.StopHookExists {
478
+ fmt.Printf(" ✓ Stop: %s\n", status.StopCommand)
488
479
  } else {
489
- fmt.Printf(" ✗ PostToolUse: missing\n")
480
+ fmt.Printf(" ✗ Stop: missing\n")
490
481
  }
491
482
  fmt.Printf(" → Run: tanagram config claude\n")
492
483
  }
@@ -550,7 +541,7 @@ func ensureClaudeConfigured() error {
550
541
  }
551
542
 
552
543
  // If file doesn't exist or hooks aren't configured, set them up
553
- if !status.FileExists || !status.PreHookExists || !status.PostHookExists {
544
+ if !status.FileExists || !status.SessionStartHookExists || !status.StopHookExists {
554
545
  fmt.Println("Setting up Claude Code integration...")
555
546
  if err := ConfigClaude(settingsPath); err != nil {
556
547
  // Don't fail the command if hook setup fails - just warn
@@ -4,6 +4,7 @@ import (
4
4
  "encoding/json"
5
5
  "os"
6
6
  "path/filepath"
7
+ "strings"
7
8
  "testing"
8
9
  )
9
10
 
@@ -31,12 +32,12 @@ func TestConfigClaude(t *testing.T) {
31
32
  }
32
33
 
33
34
  settings := readSettings(t, settingsPath)
34
- pre, post := checkHooks(settings)
35
- if !pre {
36
- t.Error("PreToolUse hook not created")
35
+ sessionStart, stop := checkHooks(settings)
36
+ if !sessionStart {
37
+ t.Error("SessionStart hook not created")
37
38
  }
38
- if !post {
39
- t.Error("PostToolUse hook not created")
39
+ if !stop {
40
+ t.Error("Stop hook not created")
40
41
  }
41
42
  })
42
43
 
@@ -47,22 +48,22 @@ func TestConfigClaude(t *testing.T) {
47
48
  }
48
49
 
49
50
  settings := readSettings(t, settingsPath)
50
- pre, post := checkHooks(settings)
51
- if !pre || !post {
51
+ sessionStart, stop := checkHooks(settings)
52
+ if !sessionStart || !stop {
52
53
  t.Error("Hooks missing after second run")
53
54
  }
54
55
 
55
56
  // Check that we didn't duplicate them (rudimentary check: count of items)
56
57
  hooks := settings["hooks"].(map[string]interface{})
57
- preToolUse := hooks["PreToolUse"].([]interface{})
58
- postToolUse := hooks["PostToolUse"].([]interface{})
58
+ sessionStartHooks := hooks["SessionStart"].([]interface{})
59
+ stopHooks := hooks["Stop"].([]interface{})
59
60
 
60
61
  // We expect exactly 1 hook in each since we started fresh
61
- if len(preToolUse) != 1 {
62
- t.Errorf("Expected 1 PreToolUse hook, got %d", len(preToolUse))
62
+ if len(sessionStartHooks) != 1 {
63
+ t.Errorf("Expected 1 SessionStart hook, got %d", len(sessionStartHooks))
63
64
  }
64
- if len(postToolUse) != 1 {
65
- t.Errorf("Expected 1 PostToolUse hook, got %d", len(postToolUse))
65
+ if len(stopHooks) != 1 {
66
+ t.Errorf("Expected 1 Stop hook, got %d", len(stopHooks))
66
67
  }
67
68
  })
68
69
 
@@ -70,9 +71,9 @@ func TestConfigClaude(t *testing.T) {
70
71
  // Reset with a file that has other hooks
71
72
  otherHook := map[string]interface{}{
72
73
  "hooks": map[string]interface{}{
73
- "PreToolUse": []interface{}{
74
+ "SessionStart": []interface{}{
74
75
  map[string]interface{}{
75
- "matcher": "Read",
76
+ "matcher": "startup|clear",
76
77
  "hooks": []interface{}{
77
78
  map[string]interface{}{"command": "echo existing"},
78
79
  },
@@ -100,19 +101,19 @@ func TestConfigClaude(t *testing.T) {
100
101
  }
101
102
 
102
103
  settings := readSettings(t, settingsPath)
103
- pre, post := checkHooks(settings)
104
- if !pre || !post {
104
+ sessionStart, stop := checkHooks(settings)
105
+ if !sessionStart || !stop {
105
106
  t.Error("Tanagram hooks missing when merging with existing config")
106
107
  }
107
108
 
108
109
  // Check if existing hook is still there
109
110
  hooks := settings["hooks"].(map[string]interface{})
110
- preToolUse := hooks["PreToolUse"].([]interface{})
111
+ sessionStartHooks := hooks["SessionStart"].([]interface{})
111
112
 
112
113
  foundExisting := false
113
- for _, h := range preToolUse {
114
+ for _, h := range sessionStartHooks {
114
115
  hMap := h.(map[string]interface{})
115
- if hMap["matcher"] == "Read" {
116
+ if hMap["matcher"] == "startup|clear" {
116
117
  // Check for the specific command to be sure it's the right one
117
118
  if commands, ok := hMap["hooks"].([]interface{}); ok {
118
119
  for _, cmd := range commands {
@@ -133,6 +134,85 @@ func TestConfigClaude(t *testing.T) {
133
134
  t.Error("Existing hook was lost")
134
135
  }
135
136
  })
137
+
138
+ t.Run("Preserve existing absolute path hooks", func(t *testing.T) {
139
+ // Reset with a file that has absolute path hooks
140
+ absHook := map[string]interface{}{
141
+ "hooks": map[string]interface{}{
142
+ "SessionStart": []interface{}{
143
+ map[string]interface{}{
144
+ "matcher": "startup|clear",
145
+ "hooks": []interface{}{
146
+ map[string]interface{}{"command": "/absolute/path/to/tanagram snapshot"},
147
+ },
148
+ },
149
+ },
150
+ "Stop": []interface{}{
151
+ map[string]interface{}{
152
+ "hooks": []interface{}{
153
+ map[string]interface{}{
154
+ "type": "command",
155
+ "command": "/absolute/path/to/tanagram",
156
+ },
157
+ },
158
+ },
159
+ },
160
+ },
161
+ }
162
+
163
+ // Clean up previous run
164
+ os.RemoveAll(filepath.Dir(settingsPath))
165
+
166
+ dir := filepath.Dir(settingsPath)
167
+ if err := os.MkdirAll(dir, 0755); err != nil {
168
+ t.Fatalf("Failed to create dir: %v", err)
169
+ }
170
+
171
+ // Write absHook to settingsPath
172
+ data, _ := json.Marshal(absHook)
173
+ if err := os.WriteFile(settingsPath, data, 0644); err != nil {
174
+ t.Fatalf("Failed to write settings: %v", err)
175
+ }
176
+
177
+ if err := ConfigClaude(settingsPath); err != nil {
178
+ t.Fatalf("ConfigClaude() failed: %v", err)
179
+ }
180
+
181
+ settings := readSettings(t, settingsPath)
182
+
183
+ // Check duplication
184
+ hooks := settings["hooks"].(map[string]interface{})
185
+ sessionStartHooks := hooks["SessionStart"].([]interface{})
186
+ stopHooks := hooks["Stop"].([]interface{})
187
+
188
+ // We expect exactly 1 hook since we started with 1 and it should match
189
+ if len(sessionStartHooks) != 1 {
190
+ t.Errorf("Expected 1 SessionStart hook, got %d", len(sessionStartHooks))
191
+ }
192
+ if len(stopHooks) != 1 {
193
+ t.Errorf("Expected 1 Stop hook, got %d", len(stopHooks))
194
+ }
195
+
196
+ // Check content
197
+ h := sessionStartHooks[0].(map[string]interface{})
198
+ innerHooks := h["hooks"].([]interface{})
199
+ cmdMap := innerHooks[0].(map[string]interface{})
200
+ cmd := cmdMap["command"].(string)
201
+
202
+ if cmd != "/absolute/path/to/tanagram snapshot" {
203
+ t.Errorf("Expected command to be preserved as '/absolute/path/to/tanagram snapshot', got '%s'", cmd)
204
+ }
205
+
206
+ // Check post content
207
+ hPost := stopHooks[0].(map[string]interface{})
208
+ innerHooksPost := hPost["hooks"].([]interface{})
209
+ cmdMapPost := innerHooksPost[0].(map[string]interface{})
210
+ cmdPost := cmdMapPost["command"].(string)
211
+
212
+ if cmdPost != "/absolute/path/to/tanagram" {
213
+ t.Errorf("Expected command to be preserved as '/absolute/path/to/tanagram', got '%s'", cmdPost)
214
+ }
215
+ })
136
216
  }
137
217
 
138
218
  func TestConfigCursor(t *testing.T) {
@@ -243,6 +323,69 @@ func TestConfigCursor(t *testing.T) {
243
323
  t.Error("Existing hook was lost")
244
324
  }
245
325
  })
326
+
327
+ t.Run("Preserve existing absolute path hooks", func(t *testing.T) {
328
+ // Reset with a file that has absolute path hooks
329
+ absHook := map[string]interface{}{
330
+ "hooks": map[string]interface{}{
331
+ "beforeSubmitPrompt": []interface{}{
332
+ map[string]interface{}{"command": "/absolute/path/to/tanagram snapshot"},
333
+ },
334
+ "stop": []interface{}{
335
+ map[string]interface{}{"command": "/absolute/path/to/tanagram"},
336
+ },
337
+ },
338
+ }
339
+
340
+ // Clean up previous run
341
+ os.RemoveAll(filepath.Dir(hooksPath))
342
+
343
+ dir := filepath.Dir(hooksPath)
344
+ if err := os.MkdirAll(dir, 0755); err != nil {
345
+ t.Fatalf("Failed to create dir: %v", err)
346
+ }
347
+
348
+ // Write absHook to hooksPath
349
+ data, _ := json.Marshal(absHook)
350
+ if err := os.WriteFile(hooksPath, data, 0644); err != nil {
351
+ t.Fatalf("Failed to write settings: %v", err)
352
+ }
353
+
354
+ if err := ConfigCursor(hooksPath); err != nil {
355
+ t.Fatalf("ConfigCursor() failed: %v", err)
356
+ }
357
+
358
+ settings := readSettings(t, hooksPath)
359
+
360
+ // Check duplication
361
+ hooks := settings["hooks"].(map[string]interface{})
362
+ beforeSubmitHooks := hooks["beforeSubmitPrompt"].([]interface{})
363
+ stopHooks := hooks["stop"].([]interface{})
364
+
365
+ // We expect exactly 1 hook since we started with 1 and it should match
366
+ if len(beforeSubmitHooks) != 1 {
367
+ t.Errorf("Expected 1 beforeSubmitPrompt hook, got %d", len(beforeSubmitHooks))
368
+ }
369
+ if len(stopHooks) != 1 {
370
+ t.Errorf("Expected 1 stop hook, got %d", len(stopHooks))
371
+ }
372
+
373
+ // Check content
374
+ h := beforeSubmitHooks[0].(map[string]interface{})
375
+ cmd, _ := h["command"].(string)
376
+
377
+ if cmd != "/absolute/path/to/tanagram snapshot" {
378
+ t.Errorf("Expected command to be preserved as '/absolute/path/to/tanagram snapshot', got '%s'", cmd)
379
+ }
380
+
381
+ // Check stop content
382
+ hStop := stopHooks[0].(map[string]interface{})
383
+ cmdStop, _ := hStop["command"].(string)
384
+
385
+ if cmdStop != "/absolute/path/to/tanagram" {
386
+ t.Errorf("Expected command to be preserved as '/absolute/path/to/tanagram', got '%s'", cmdStop)
387
+ }
388
+ })
246
389
  }
247
390
 
248
391
  func TestEnsureHooksConfigured(t *testing.T) {
@@ -277,12 +420,12 @@ func TestEnsureHooksConfigured(t *testing.T) {
277
420
  // Check Claude settings
278
421
  claudeSettingsPath := filepath.Join(tempHome, ".claude", "settings.json")
279
422
  claudeSettings := readSettings(t, claudeSettingsPath)
280
- pre, post := checkHooks(claudeSettings)
281
- if !pre {
282
- t.Error("PreToolUse hook not created")
423
+ sessionStart, stop := checkHooks(claudeSettings)
424
+ if !sessionStart {
425
+ t.Error("SessionStart hook not created")
283
426
  }
284
- if !post {
285
- t.Error("PostToolUse hook not created")
427
+ if !stop {
428
+ t.Error("Stop hook not created")
286
429
  }
287
430
 
288
431
  // Check Cursor hooks
@@ -316,16 +459,16 @@ func checkHooks(settings map[string]interface{}) (bool, bool) {
316
459
  return false, false
317
460
  }
318
461
 
319
- preExists := false
320
- if preToolUse, ok := hooks["PreToolUse"].([]interface{}); ok {
321
- for _, h := range preToolUse {
462
+ sessionStartExists := false
463
+ if sessionStart, ok := hooks["SessionStart"].([]interface{}); ok {
464
+ for _, h := range sessionStart {
322
465
  if hMap, ok := h.(map[string]interface{}); ok {
323
- if hMap["matcher"] == "Edit|Write" {
466
+ if hMap["matcher"] == "startup|clear" {
324
467
  if innerHooks, ok := hMap["hooks"].([]interface{}); ok {
325
468
  for _, ih := range innerHooks {
326
469
  if ihMap, ok := ih.(map[string]interface{}); ok {
327
- if ihMap["command"] == "tanagram snapshot" {
328
- preExists = true
470
+ if cmd, ok := ihMap["command"].(string); ok && strings.HasSuffix(cmd, "tanagram snapshot") {
471
+ sessionStartExists = true
329
472
  }
330
473
  }
331
474
  }
@@ -335,17 +478,15 @@ func checkHooks(settings map[string]interface{}) (bool, bool) {
335
478
  }
336
479
  }
337
480
 
338
- postExists := false
339
- if postToolUse, ok := hooks["PostToolUse"].([]interface{}); ok {
340
- for _, h := range postToolUse {
481
+ stopExists := false
482
+ if stopHooks, ok := hooks["Stop"].([]interface{}); ok {
483
+ for _, h := range stopHooks {
341
484
  if hMap, ok := h.(map[string]interface{}); ok {
342
- if hMap["matcher"] == "Edit|Write" {
343
- if innerHooks, ok := hMap["hooks"].([]interface{}); ok {
344
- for _, ih := range innerHooks {
345
- if ihMap, ok := ih.(map[string]interface{}); ok {
346
- if ihMap["command"] == "tanagram" {
347
- postExists = true
348
- }
485
+ if innerHooks, ok := hMap["hooks"].([]interface{}); ok {
486
+ for _, ih := range innerHooks {
487
+ if ihMap, ok := ih.(map[string]interface{}); ok {
488
+ if cmd, ok := ihMap["command"].(string); ok && strings.HasSuffix(cmd, "tanagram") {
489
+ stopExists = true
349
490
  }
350
491
  }
351
492
  }
@@ -354,7 +495,7 @@ func checkHooks(settings map[string]interface{}) (bool, bool) {
354
495
  }
355
496
  }
356
497
 
357
- return preExists, postExists
498
+ return sessionStartExists, stopExists
358
499
  }
359
500
 
360
501
  func checkCursorHooks(settings map[string]interface{}) (bool, bool) {
@@ -367,7 +508,7 @@ func checkCursorHooks(settings map[string]interface{}) (bool, bool) {
367
508
  if beforeSubmit, ok := hooks["beforeSubmitPrompt"].([]interface{}); ok {
368
509
  for _, h := range beforeSubmit {
369
510
  if hMap, ok := h.(map[string]interface{}); ok {
370
- if cmd, ok := hMap["command"].(string); ok && cmd == "tanagram snapshot" {
511
+ if cmd, ok := hMap["command"].(string); ok && strings.HasSuffix(cmd, "tanagram snapshot") {
371
512
  beforeSubmitExists = true
372
513
  }
373
514
  }
@@ -378,7 +519,7 @@ func checkCursorHooks(settings map[string]interface{}) (bool, bool) {
378
519
  if stop, ok := hooks["stop"].([]interface{}); ok {
379
520
  for _, h := range stop {
380
521
  if hMap, ok := h.(map[string]interface{}); ok {
381
- if cmd, ok := hMap["command"].(string); ok && cmd == "tanagram" {
522
+ if cmd, ok := hMap["command"].(string); ok && strings.HasSuffix(cmd, "tanagram") {
382
523
  stopExists = true
383
524
  }
384
525
  }
package/commands/login.go CHANGED
@@ -19,7 +19,7 @@ import (
19
19
  // getWebAppURL returns the web app URL based on environment
20
20
  func getWebAppURL() string {
21
21
  // Check environment variable first
22
- if url := os.Getenv("TANAGRAM_WEB_URL"); url != "" {
22
+ if url := os.Getenv("TANAGRAM_WEB_HOSTNAME"); url != "" {
23
23
  return url
24
24
  }
25
25
 
@@ -79,7 +79,7 @@ func Login() error {
79
79
 
80
80
  // Send success message to browser
81
81
  w.Header().Set("Content-Type", "text/html; charset=utf-8")
82
- fmt.Fprintf(w, `
82
+ fmt.Fprint(w, `
83
83
  <!DOCTYPE html>
84
84
  <html lang="en">
85
85
  <head>
@@ -10,11 +10,11 @@ import (
10
10
  func TestSnapshot_Cursor(t *testing.T) {
11
11
  // Save original functions
12
12
  origFindGitRoot := findGitRoot
13
- origCreateSnapshot := createSnapshot
13
+ origCreateSnapshot := createOptimized
14
14
  origGetParentProcess := getParentProcess
15
15
  defer func() {
16
16
  findGitRoot = origFindGitRoot
17
- createSnapshot = origCreateSnapshot
17
+ createOptimized = origCreateSnapshot
18
18
  getParentProcess = origGetParentProcess
19
19
  }()
20
20
 
@@ -22,7 +22,7 @@ func TestSnapshot_Cursor(t *testing.T) {
22
22
  findGitRoot = func() (string, error) {
23
23
  return "/tmp/mock-git-root", nil
24
24
  }
25
- createSnapshot = func(root string) error {
25
+ createOptimized = func(root string, files []string) error {
26
26
  return nil
27
27
  }
28
28
 
package/git/diff.go CHANGED
@@ -66,6 +66,40 @@ func GetAllChanges() (*DiffResult, error) {
66
66
  return result, nil
67
67
  }
68
68
 
69
+ // DiffFiles compares two files using git diff --no-index
70
+ func DiffFiles(oldFile, newFile, displayPath string) (*DiffResult, error) {
71
+ cmd := exec.Command("git", "diff", "--no-index", "--unified=0", oldFile, newFile)
72
+ output, err := cmd.Output()
73
+
74
+ // git diff --no-index returns exit code 1 if there are differences
75
+ // It returns 0 if identical
76
+ if err != nil {
77
+ if exitError, ok := err.(*exec.ExitError); ok {
78
+ if exitError.ExitCode() != 1 {
79
+ // Anything other than 0 or 1 is an actual error
80
+ return nil, fmt.Errorf("failed to run git diff: %w", err)
81
+ }
82
+ // Exit code 1 means differences found, proceed to parse
83
+ } else {
84
+ return nil, fmt.Errorf("failed to run git diff: %w", err)
85
+ }
86
+ }
87
+
88
+ // Parse the diff output
89
+ result, err := parseDiff(string(output))
90
+ if err != nil {
91
+ return nil, err
92
+ }
93
+
94
+ // Fix up the file paths in the result to match the displayPath
95
+ // git diff --no-index outputs temp file paths like "b/tmp/..."
96
+ for i := range result.Changes {
97
+ result.Changes[i].File = displayPath
98
+ }
99
+
100
+ return result, nil
101
+ }
102
+
69
103
  // parseDiff parses unified diff format and extracts changed lines
70
104
  func parseDiff(diffText string) (*DiffResult, error) {
71
105
  result := &DiffResult{
package/main.go CHANGED
@@ -255,8 +255,8 @@ HOOK WORKFLOW:
255
255
  When configured with 'tanagram config claude' or 'tanagram config cursor':
256
256
 
257
257
  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
258
+ 1. SessionStart: Creates snapshot before Claude makes changes
259
+ 2. Stop: Checks only Claude's changes against policies
260
260
 
261
261
  Cursor:
262
262
  1. beforeSubmitPrompt: Creates snapshot before agent starts working
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanagram/cli",
3
- "version": "0.4.10",
3
+ "version": "0.4.13",
4
4
  "description": "Tanagram - Catch sloppy code before it ships",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -12,6 +12,7 @@ import (
12
12
  "strings"
13
13
  "time"
14
14
 
15
+ "github.com/tanagram/cli/git"
15
16
  "github.com/tanagram/cli/storage"
16
17
  )
17
18
 
@@ -21,6 +22,7 @@ type FileState struct {
21
22
  Hash string `json:"hash"`
22
23
  Size int64 `json:"size"`
23
24
  ModifiedTime time.Time `json:"modified_time"`
25
+ Content string `json:"content,omitempty"`
24
26
  }
25
27
 
26
28
  // Snapshot represents a snapshot of the working directory
@@ -179,11 +181,18 @@ func CreateOptimized(gitRoot string, targetFiles []string) error {
179
181
  continue
180
182
  }
181
183
 
184
+ // Read content
185
+ contentBytes, err := os.ReadFile(fullPath)
186
+ if err != nil {
187
+ continue
188
+ }
189
+
182
190
  snapshot.Files[relPath] = &FileState{
183
191
  Path: relPath,
184
192
  Hash: hash,
185
193
  Size: info.Size(),
186
194
  ModifiedTime: info.ModTime(),
195
+ Content: string(contentBytes),
187
196
  }
188
197
  }
189
198
 
@@ -490,31 +499,113 @@ type ChangedLine struct {
490
499
  func GetChangedLinesForChecker(gitRoot string, snapshot *Snapshot, compareResult *CompareResult) ([]ChangedLine, error) {
491
500
  var changes []ChangedLine
492
501
 
493
- // For each modified file, include all non-empty lines
494
- // (we can't easily determine which specific lines changed without doing a full diff)
502
+ // For modified files, we diff against the snapshot version
495
503
  for _, relPath := range compareResult.ModifiedFiles {
496
504
  fullPath := filepath.Join(gitRoot, relPath)
497
505
 
498
- // Read new content
506
+ // Get New Content (Current)
499
507
  newData, err := os.ReadFile(fullPath)
500
508
  if err != nil {
501
509
  continue
502
510
  }
503
-
504
- // Skip binary files
505
511
  if !isText(newData) {
506
512
  continue
507
513
  }
508
514
 
509
- newLines := strings.Split(string(newData), "\n")
515
+ // Get Old Content (Snapshot)
516
+ var oldData []byte
517
+
518
+ if fileState, ok := snapshot.Files[relPath]; ok && fileState.Content != "" {
519
+ // Was dirty -> use stored content
520
+ oldData = []byte(fileState.Content)
521
+ } else {
522
+ // Was clean -> use git show
523
+ // If it wasn't in snapshot.Files but is in ModifiedFiles, it must have been clean at HEAD
524
+ // and now is modified.
525
+ // Note: If snapshot.Files has it but Content is empty, that means it was clean but we stored it?
526
+ // No, CreateOptimized only stores Content for dirty files.
527
+ // But wait, CreateOptimized only adds files to snapshot.Files if they are dirty or targetted.
528
+ // If a file is NOT in snapshot.Files, it is assumed clean at HeadCommit.
529
+
530
+ // However, compareResult.ModifiedFiles includes files that:
531
+ // 1. Were in snapshot (hash changed)
532
+ // 2. Were NOT in snapshot (current hash != HEAD hash)
533
+
534
+ // Case 1: In snapshot
535
+ if fileState, ok := snapshot.Files[relPath]; ok {
536
+ if fileState.Content != "" {
537
+ oldData = []byte(fileState.Content)
538
+ } else {
539
+ // It was in snapshot but no content? This happens if it was > 1MB
540
+ // or if we used the legacy Create method which doesn't store content.
541
+ // In that case we fall back to reading from HEAD (imperfect but best effort)
542
+ // OR we just treat all lines as changed (current behavior) if we can't get old content.
543
+
544
+ // Try to get from HEAD just in case it matches
545
+ hash, _ := getHashAtCommit(gitRoot, snapshot.HeadCommit, relPath)
546
+ if hash == fileState.Hash {
547
+ // It matches HEAD, so we can get content from HEAD
548
+ out, err := runGit(gitRoot, "show", fmt.Sprintf("%s:%s", snapshot.HeadCommit, relPath))
549
+ if err == nil {
550
+ oldData = []byte(out)
551
+ }
552
+ }
553
+ }
554
+ } else {
555
+ // Case 2: Not in snapshot (was clean at HEAD)
556
+ out, err := runGit(gitRoot, "show", fmt.Sprintf("%s:%s", snapshot.HeadCommit, relPath))
557
+ if err == nil {
558
+ oldData = []byte(out)
559
+ }
560
+ }
561
+ }
562
+
563
+ // If we couldn't get old data, fallback to treating all lines as new (safest)
564
+ if len(oldData) == 0 {
565
+ newLines := strings.Split(string(newData), "\n")
566
+ for i, line := range newLines {
567
+ if strings.TrimSpace(line) != "" {
568
+ changes = append(changes, ChangedLine{
569
+ File: relPath,
570
+ LineNumber: i + 1,
571
+ Content: line,
572
+ ChangeType: "+",
573
+ })
574
+ }
575
+ }
576
+ continue
577
+ }
510
578
 
511
- // Include all non-empty lines from modified files
512
- for i, line := range newLines {
513
- if strings.TrimSpace(line) != "" {
579
+ // Do the diff
580
+ // Write to temp files
581
+ tmpOld, err := os.CreateTemp("", "tanagram-old-*")
582
+ if err != nil {
583
+ continue
584
+ }
585
+ defer os.Remove(tmpOld.Name())
586
+ tmpOld.Write(oldData)
587
+ tmpOld.Close()
588
+
589
+ tmpNew, err := os.CreateTemp("", "tanagram-new-*")
590
+ if err != nil {
591
+ continue
592
+ }
593
+ defer os.Remove(tmpNew.Name())
594
+ tmpNew.Write(newData)
595
+ tmpNew.Close()
596
+
597
+ diffResult, err := git.DiffFiles(tmpOld.Name(), tmpNew.Name(), relPath)
598
+ if err != nil {
599
+ continue
600
+ }
601
+
602
+ // Convert git.ChangedLine to snapshot.ChangedLine
603
+ for _, c := range diffResult.Changes {
604
+ if c.ChangeType == "+" {
514
605
  changes = append(changes, ChangedLine{
515
606
  File: relPath,
516
- LineNumber: i + 1,
517
- Content: line,
607
+ LineNumber: c.LineNumber,
608
+ Content: c.Content,
518
609
  ChangeType: "+",
519
610
  })
520
611
  }