@tanagram/cli 0.1.33 → 0.2.1
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/config.go +147 -68
- package/commands/run.go +61 -10
- package/commands/snapshot.go +25 -0
- package/install.js +3 -1
- package/main.go +24 -1
- package/metrics/metrics.go +7 -2
- package/package.json +2 -1
- package/snapshot/snapshot.go +418 -0
package/commands/config.go
CHANGED
|
@@ -45,30 +45,58 @@ func ConfigClaude() error {
|
|
|
45
45
|
settings["hooks"] = hooks
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
// Check if PreToolUse exists
|
|
49
|
+
preToolUse, preToolUseExist := hooks["PreToolUse"].([]interface{})
|
|
50
|
+
if !preToolUseExist {
|
|
51
|
+
preToolUse = []interface{}{}
|
|
52
|
+
}
|
|
53
|
+
|
|
48
54
|
// Check if PostToolUse exists
|
|
49
55
|
postToolUse, postToolUseExist := hooks["PostToolUse"].([]interface{})
|
|
50
56
|
if !postToolUseExist {
|
|
51
57
|
postToolUse = []interface{}{}
|
|
52
58
|
}
|
|
53
59
|
|
|
54
|
-
// Check if tanagram
|
|
55
|
-
|
|
60
|
+
// Check if tanagram hooks already exist
|
|
61
|
+
preHookExists := false
|
|
62
|
+
postHookExists := false
|
|
63
|
+
|
|
64
|
+
for _, hook := range preToolUse {
|
|
65
|
+
hookMap, ok := hook.(map[string]interface{})
|
|
66
|
+
if !ok {
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if matcher, ok := hookMap["matcher"].(string); ok && matcher == "Edit|Write" {
|
|
71
|
+
innerHooks, ok := hookMap["hooks"].([]interface{})
|
|
72
|
+
if ok {
|
|
73
|
+
for _, innerHook := range innerHooks {
|
|
74
|
+
innerHookMap, ok := innerHook.(map[string]interface{})
|
|
75
|
+
if ok {
|
|
76
|
+
if cmd, ok := innerHookMap["command"].(string); ok && cmd == "tanagram snapshot" {
|
|
77
|
+
preHookExists = true
|
|
78
|
+
break
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
56
86
|
for _, hook := range postToolUse {
|
|
57
87
|
hookMap, ok := hook.(map[string]interface{})
|
|
58
88
|
if !ok {
|
|
59
89
|
continue
|
|
60
90
|
}
|
|
61
91
|
|
|
62
|
-
// Check if this is the Edit|Write matcher
|
|
63
92
|
if matcher, ok := hookMap["matcher"].(string); ok && matcher == "Edit|Write" {
|
|
64
|
-
// Check if tanagram is in the hooks array
|
|
65
93
|
innerHooks, ok := hookMap["hooks"].([]interface{})
|
|
66
94
|
if ok {
|
|
67
95
|
for _, innerHook := range innerHooks {
|
|
68
96
|
innerHookMap, ok := innerHook.(map[string]interface{})
|
|
69
97
|
if ok {
|
|
70
98
|
if cmd, ok := innerHookMap["command"].(string); ok && cmd == "tanagram" {
|
|
71
|
-
|
|
99
|
+
postHookExists = true
|
|
72
100
|
break
|
|
73
101
|
}
|
|
74
102
|
}
|
|
@@ -77,24 +105,38 @@ func ConfigClaude() error {
|
|
|
77
105
|
}
|
|
78
106
|
}
|
|
79
107
|
|
|
80
|
-
if
|
|
81
|
-
fmt.Println("✓ Tanagram
|
|
108
|
+
if preHookExists && postHookExists {
|
|
109
|
+
fmt.Println("✓ Tanagram hooks are already configured in ~/.claude/settings.json")
|
|
82
110
|
return nil
|
|
83
111
|
}
|
|
84
112
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
113
|
+
if !preHookExists {
|
|
114
|
+
preHook := map[string]interface{}{
|
|
115
|
+
"matcher": "Edit|Write",
|
|
116
|
+
"hooks": []interface{}{
|
|
117
|
+
map[string]interface{}{
|
|
118
|
+
"type": "command",
|
|
119
|
+
"command": "tanagram snapshot",
|
|
120
|
+
},
|
|
92
121
|
},
|
|
93
|
-
}
|
|
122
|
+
}
|
|
123
|
+
preToolUse = append(preToolUse, preHook)
|
|
124
|
+
hooks["PreToolUse"] = preToolUse
|
|
94
125
|
}
|
|
95
126
|
|
|
96
|
-
|
|
97
|
-
|
|
127
|
+
if !postHookExists {
|
|
128
|
+
postHook := map[string]interface{}{
|
|
129
|
+
"matcher": "Edit|Write",
|
|
130
|
+
"hooks": []interface{}{
|
|
131
|
+
map[string]interface{}{
|
|
132
|
+
"type": "command",
|
|
133
|
+
"command": "tanagram",
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
}
|
|
137
|
+
postToolUse = append(postToolUse, postHook)
|
|
138
|
+
hooks["PostToolUse"] = postToolUse
|
|
139
|
+
}
|
|
98
140
|
|
|
99
141
|
// Ensure .claude directory exists
|
|
100
142
|
claudeDir := filepath.Join(home, ".claude")
|
|
@@ -112,9 +154,12 @@ func ConfigClaude() error {
|
|
|
112
154
|
return fmt.Errorf("failed to write settings: %w", err)
|
|
113
155
|
}
|
|
114
156
|
|
|
115
|
-
fmt.Println("✓ Tanagram
|
|
116
|
-
fmt.Println("\nClaude Code will now
|
|
117
|
-
fmt.Println("
|
|
157
|
+
fmt.Println("✓ Tanagram hooks added to ~/.claude/settings.json")
|
|
158
|
+
fmt.Println("\nClaude Code will now:")
|
|
159
|
+
fmt.Println(" - Snapshot file state before each Edit/Write (PreToolUse)")
|
|
160
|
+
fmt.Println(" - Check only Claude's changes after Edit/Write (PostToolUse)")
|
|
161
|
+
fmt.Println(" - Send policy violations to Claude for automatic fixing")
|
|
162
|
+
fmt.Println("\nThis prevents false positives from user-written code!")
|
|
118
163
|
|
|
119
164
|
return nil
|
|
120
165
|
}
|
|
@@ -148,95 +193,120 @@ func ConfigList() error {
|
|
|
148
193
|
|
|
149
194
|
// HookStatus represents the status of a hook configuration
|
|
150
195
|
type HookStatus struct {
|
|
151
|
-
FileExists
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
196
|
+
FileExists bool
|
|
197
|
+
PreHookExists bool
|
|
198
|
+
PostHookExists bool
|
|
199
|
+
IsUpToDate bool
|
|
200
|
+
PreCommand string
|
|
201
|
+
PostCommand string
|
|
202
|
+
Error error
|
|
156
203
|
}
|
|
157
204
|
|
|
158
205
|
// checkHookStatus checks the status of Tanagram hook in a settings file
|
|
159
206
|
func checkHookStatus(settingsPath string) HookStatus {
|
|
160
207
|
status := HookStatus{
|
|
161
|
-
FileExists:
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
208
|
+
FileExists: false,
|
|
209
|
+
PreHookExists: false,
|
|
210
|
+
PostHookExists: false,
|
|
211
|
+
IsUpToDate: false,
|
|
212
|
+
PreCommand: "",
|
|
213
|
+
PostCommand: "",
|
|
214
|
+
Error: nil,
|
|
166
215
|
}
|
|
167
216
|
|
|
168
|
-
// Check if file exists
|
|
169
217
|
if _, err := os.Stat(settingsPath); os.IsNotExist(err) {
|
|
170
218
|
return status
|
|
171
219
|
}
|
|
172
220
|
|
|
173
221
|
status.FileExists = true
|
|
174
222
|
|
|
175
|
-
// Read file
|
|
176
223
|
data, err := os.ReadFile(settingsPath)
|
|
177
224
|
if err != nil {
|
|
178
225
|
status.Error = err
|
|
179
226
|
return status
|
|
180
227
|
}
|
|
181
228
|
|
|
182
|
-
// Parse JSON
|
|
183
229
|
var settings map[string]interface{}
|
|
184
230
|
if err := json.Unmarshal(data, &settings); err != nil {
|
|
185
231
|
status.Error = fmt.Errorf("invalid JSON: %w", err)
|
|
186
232
|
return status
|
|
187
233
|
}
|
|
188
234
|
|
|
189
|
-
// Check for hooks
|
|
190
235
|
hooks, ok := settings["hooks"].(map[string]interface{})
|
|
191
236
|
if !ok {
|
|
192
237
|
return status
|
|
193
238
|
}
|
|
194
239
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
240
|
+
if preToolUse, ok := hooks["PreToolUse"].([]interface{}); ok {
|
|
241
|
+
for _, hook := range preToolUse {
|
|
242
|
+
hookMap, ok := hook.(map[string]interface{})
|
|
243
|
+
if !ok {
|
|
244
|
+
continue
|
|
245
|
+
}
|
|
199
246
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
continue
|
|
205
|
-
}
|
|
247
|
+
matcher, ok := hookMap["matcher"].(string)
|
|
248
|
+
if !ok || matcher != "Edit|Write" {
|
|
249
|
+
continue
|
|
250
|
+
}
|
|
206
251
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
}
|
|
252
|
+
innerHooks, ok := hookMap["hooks"].([]interface{})
|
|
253
|
+
if !ok {
|
|
254
|
+
continue
|
|
255
|
+
}
|
|
212
256
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
257
|
+
for _, innerHook := range innerHooks {
|
|
258
|
+
innerHookMap, ok := innerHook.(map[string]interface{})
|
|
259
|
+
if !ok {
|
|
260
|
+
continue
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
cmd, cmdOk := innerHookMap["command"].(string)
|
|
264
|
+
if cmdOk && strings.Contains(cmd, "tanagram snapshot") {
|
|
265
|
+
status.PreHookExists = true
|
|
266
|
+
status.PreCommand = cmd
|
|
267
|
+
}
|
|
268
|
+
}
|
|
217
269
|
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if postToolUse, ok := hooks["PostToolUse"].([]interface{}); ok {
|
|
273
|
+
for _, hook := range postToolUse {
|
|
274
|
+
hookMap, ok := hook.(map[string]interface{})
|
|
275
|
+
if !ok {
|
|
276
|
+
continue
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
matcher, ok := hookMap["matcher"].(string)
|
|
280
|
+
if !ok || matcher != "Edit|Write" {
|
|
281
|
+
continue
|
|
282
|
+
}
|
|
218
283
|
|
|
219
|
-
|
|
220
|
-
innerHookMap, ok := innerHook.(map[string]interface{})
|
|
284
|
+
innerHooks, ok := hookMap["hooks"].([]interface{})
|
|
221
285
|
if !ok {
|
|
222
286
|
continue
|
|
223
287
|
}
|
|
224
288
|
|
|
225
|
-
|
|
226
|
-
|
|
289
|
+
for _, innerHook := range innerHooks {
|
|
290
|
+
innerHookMap, ok := innerHook.(map[string]interface{})
|
|
291
|
+
if !ok {
|
|
292
|
+
continue
|
|
293
|
+
}
|
|
227
294
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
status.Command = cmd
|
|
295
|
+
cmd, cmdOk := innerHookMap["command"].(string)
|
|
296
|
+
hookType, typeOk := innerHookMap["type"].(string)
|
|
231
297
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
status.
|
|
298
|
+
if cmdOk && cmd == "tanagram" && typeOk && hookType == "command" {
|
|
299
|
+
status.PostHookExists = true
|
|
300
|
+
status.PostCommand = cmd
|
|
235
301
|
}
|
|
236
302
|
}
|
|
237
303
|
}
|
|
238
304
|
}
|
|
239
305
|
|
|
306
|
+
if status.PreHookExists && status.PostHookExists {
|
|
307
|
+
status.IsUpToDate = true
|
|
308
|
+
}
|
|
309
|
+
|
|
240
310
|
return status
|
|
241
311
|
}
|
|
242
312
|
|
|
@@ -253,7 +323,7 @@ func printHookStatus(status HookStatus, path string) {
|
|
|
253
323
|
return
|
|
254
324
|
}
|
|
255
325
|
|
|
256
|
-
if !status.
|
|
326
|
+
if !status.PreHookExists && !status.PostHookExists {
|
|
257
327
|
fmt.Printf(" ○ Not configured\n")
|
|
258
328
|
fmt.Printf(" → Run: tanagram config claude\n")
|
|
259
329
|
return
|
|
@@ -261,12 +331,21 @@ func printHookStatus(status HookStatus, path string) {
|
|
|
261
331
|
|
|
262
332
|
if status.IsUpToDate {
|
|
263
333
|
fmt.Printf(" ✓ Configured and up to date\n")
|
|
264
|
-
fmt.Printf("
|
|
334
|
+
fmt.Printf(" PreToolUse: %s\n", status.PreCommand)
|
|
335
|
+
fmt.Printf(" PostToolUse: %s\n", status.PostCommand)
|
|
265
336
|
fmt.Printf(" Location: %s\n", path)
|
|
266
337
|
} else {
|
|
267
|
-
fmt.Printf(" ⚠ Configured but
|
|
268
|
-
|
|
269
|
-
|
|
338
|
+
fmt.Printf(" ⚠ Configured but incomplete\n")
|
|
339
|
+
if status.PreHookExists {
|
|
340
|
+
fmt.Printf(" ✓ PreToolUse: %s\n", status.PreCommand)
|
|
341
|
+
} else {
|
|
342
|
+
fmt.Printf(" ✗ PreToolUse: missing\n")
|
|
343
|
+
}
|
|
344
|
+
if status.PostHookExists {
|
|
345
|
+
fmt.Printf(" ✓ PostToolUse: %s\n", status.PostCommand)
|
|
346
|
+
} else {
|
|
347
|
+
fmt.Printf(" ✗ PostToolUse: missing\n")
|
|
348
|
+
}
|
|
270
349
|
fmt.Printf(" → Run: tanagram config claude\n")
|
|
271
350
|
}
|
|
272
351
|
}
|
package/commands/run.go
CHANGED
|
@@ -14,6 +14,7 @@ import (
|
|
|
14
14
|
"github.com/tanagram/cli/git"
|
|
15
15
|
"github.com/tanagram/cli/metrics"
|
|
16
16
|
"github.com/tanagram/cli/parser"
|
|
17
|
+
"github.com/tanagram/cli/snapshot"
|
|
17
18
|
"github.com/tanagram/cli/storage"
|
|
18
19
|
)
|
|
19
20
|
|
|
@@ -197,19 +198,68 @@ func Run() error {
|
|
|
197
198
|
|
|
198
199
|
fmt.Printf("Loaded %d policies\n", len(policies))
|
|
199
200
|
|
|
200
|
-
//
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
201
|
+
// Check if a snapshot exists (from PreToolUse hook)
|
|
202
|
+
var changesToCheck []git.ChangedLine
|
|
203
|
+
useSnapshot := false
|
|
204
|
+
|
|
205
|
+
if snapshot.Exists(gitRoot) {
|
|
206
|
+
fmt.Println("Snapshot detected - checking only Claude's changes...")
|
|
207
|
+
|
|
208
|
+
// Load snapshot
|
|
209
|
+
snap, err := snapshot.Load(gitRoot)
|
|
210
|
+
if err != nil {
|
|
211
|
+
return fmt.Errorf("failed to load snapshot: %w", err)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Compare current state to snapshot
|
|
215
|
+
compareResult, err := snapshot.Compare(gitRoot, snap)
|
|
216
|
+
if err != nil {
|
|
217
|
+
return fmt.Errorf("failed to compare to snapshot: %w", err)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Get changed lines
|
|
221
|
+
snapshotChanges, err := snapshot.GetChangedLinesForChecker(gitRoot, snap, compareResult)
|
|
222
|
+
if err != nil {
|
|
223
|
+
return fmt.Errorf("failed to get changed lines from snapshot: %w", err)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Convert snapshot.ChangedLine to git.ChangedLine
|
|
227
|
+
for _, sc := range snapshotChanges {
|
|
228
|
+
changesToCheck = append(changesToCheck, git.ChangedLine{
|
|
229
|
+
File: sc.File,
|
|
230
|
+
LineNumber: sc.LineNumber,
|
|
231
|
+
Content: sc.Content,
|
|
232
|
+
ChangeType: sc.ChangeType,
|
|
233
|
+
})
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Delete snapshot after using it
|
|
237
|
+
if err := snapshot.Delete(gitRoot); err != nil {
|
|
238
|
+
fmt.Fprintf(os.Stderr, "Warning: failed to delete snapshot: %v\n", err)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
useSnapshot = true
|
|
242
|
+
} else {
|
|
243
|
+
// No snapshot - fall back to checking all git changes
|
|
244
|
+
fmt.Println("Checking all changes (unstaged + staged)...")
|
|
245
|
+
diffResult, err := git.GetAllChanges()
|
|
246
|
+
if err != nil {
|
|
247
|
+
return fmt.Errorf("error getting git diff: %w", err)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
changesToCheck = diffResult.Changes
|
|
205
251
|
}
|
|
206
252
|
|
|
207
|
-
if len(
|
|
208
|
-
|
|
253
|
+
if len(changesToCheck) == 0 {
|
|
254
|
+
if useSnapshot {
|
|
255
|
+
fmt.Println("No changes detected since snapshot")
|
|
256
|
+
} else {
|
|
257
|
+
fmt.Println("No changes to check")
|
|
258
|
+
}
|
|
209
259
|
return nil
|
|
210
260
|
}
|
|
211
261
|
|
|
212
|
-
fmt.Printf("Scanning %d changed lines...\n\n", len(
|
|
262
|
+
fmt.Printf("Scanning %d changed lines...\n\n", len(changesToCheck))
|
|
213
263
|
|
|
214
264
|
// Get API key once upfront before checking
|
|
215
265
|
apiKey, err := config.GetAPIKey()
|
|
@@ -220,16 +270,17 @@ func Run() error {
|
|
|
220
270
|
// Check changes against policies (both regex and LLM-based)
|
|
221
271
|
ctx := context.Background()
|
|
222
272
|
checkStart := time.Now()
|
|
223
|
-
result := checker.CheckChanges(ctx,
|
|
273
|
+
result := checker.CheckChanges(ctx, changesToCheck, policies, apiKey)
|
|
224
274
|
checkDuration := time.Since(checkStart)
|
|
225
275
|
|
|
226
276
|
// Track policy check results (similar to policy.execute.result in github-app)
|
|
227
277
|
metrics.Track("cli.policy.check.result", map[string]interface{}{
|
|
228
278
|
"policies_checked": len(policies),
|
|
229
|
-
"changes_checked": len(
|
|
279
|
+
"changes_checked": len(changesToCheck),
|
|
230
280
|
"violations_found": len(result.Violations),
|
|
231
281
|
"has_violations": len(result.Violations) > 0,
|
|
232
282
|
"duration_seconds": checkDuration.Seconds(),
|
|
283
|
+
"used_snapshot": useSnapshot,
|
|
233
284
|
})
|
|
234
285
|
|
|
235
286
|
// Handle results based on whether violations were found
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
package commands
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
|
|
6
|
+
"github.com/tanagram/cli/snapshot"
|
|
7
|
+
"github.com/tanagram/cli/storage"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
// Snapshot creates a snapshot of the current working directory state
|
|
11
|
+
func Snapshot() error {
|
|
12
|
+
// Find git root
|
|
13
|
+
gitRoot, err := storage.FindGitRoot()
|
|
14
|
+
if err != nil {
|
|
15
|
+
return err
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Create snapshot
|
|
19
|
+
if err := snapshot.Create(gitRoot); err != nil {
|
|
20
|
+
return fmt.Errorf("failed to create snapshot: %w", err)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Silent success - no output for hooks
|
|
24
|
+
return nil
|
|
25
|
+
}
|
package/install.js
CHANGED
|
@@ -4,6 +4,7 @@ const { execSync } = require('child_process');
|
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const os = require('os');
|
|
7
|
+
const pkg = require('./package.json');
|
|
7
8
|
|
|
8
9
|
const GO_VERSION = '1.21.0';
|
|
9
10
|
const GO_CACHE_DIR = path.join(os.homedir(), '.tanagram', `go-${GO_VERSION}`);
|
|
@@ -80,7 +81,8 @@ function buildBinary(goCommand) {
|
|
|
80
81
|
console.log('🔧 Building Tanagram CLI...');
|
|
81
82
|
|
|
82
83
|
try {
|
|
83
|
-
|
|
84
|
+
const ldflags = `-X 'main.Version=${pkg.version}'`;
|
|
85
|
+
execSync(`"${goCommand}" build -ldflags "${ldflags}" -o "${binaryPath}" .`, {
|
|
84
86
|
cwd: __dirname,
|
|
85
87
|
stdio: 'inherit',
|
|
86
88
|
env: {
|
package/main.go
CHANGED
|
@@ -9,7 +9,11 @@ import (
|
|
|
9
9
|
"github.com/tanagram/cli/tui"
|
|
10
10
|
)
|
|
11
11
|
|
|
12
|
+
// Version is set in install.js via `-X` ldflags
|
|
13
|
+
var Version = "dev"
|
|
14
|
+
|
|
12
15
|
func main() {
|
|
16
|
+
metrics.SetVersion(Version)
|
|
13
17
|
metrics.Init()
|
|
14
18
|
|
|
15
19
|
exitCode := 0
|
|
@@ -40,6 +44,11 @@ func main() {
|
|
|
40
44
|
"command": "run",
|
|
41
45
|
})
|
|
42
46
|
err = commands.Run()
|
|
47
|
+
case "snapshot":
|
|
48
|
+
metrics.Track("cli.command.execute", map[string]interface{}{
|
|
49
|
+
"command": "snapshot",
|
|
50
|
+
})
|
|
51
|
+
err = commands.Snapshot()
|
|
43
52
|
case "sync":
|
|
44
53
|
metrics.Track("cli.command.execute", map[string]interface{}{
|
|
45
54
|
"command": "sync",
|
|
@@ -109,6 +118,9 @@ func main() {
|
|
|
109
118
|
return
|
|
110
119
|
}
|
|
111
120
|
return
|
|
121
|
+
case "version", "-v", "--version":
|
|
122
|
+
fmt.Println(Version)
|
|
123
|
+
return
|
|
112
124
|
case "help", "-h", "--help":
|
|
113
125
|
printHelp()
|
|
114
126
|
return
|
|
@@ -138,19 +150,23 @@ USAGE:
|
|
|
138
150
|
|
|
139
151
|
COMMANDS:
|
|
140
152
|
run Check git changes against policies (default)
|
|
153
|
+
snapshot Create a snapshot of current file state (used by PreToolUse hook)
|
|
141
154
|
sync Manually sync instruction files to cache
|
|
142
155
|
list Show all cached policies
|
|
143
156
|
config claude Setup Claude Code hook automatically
|
|
144
157
|
config list Show hook installation status
|
|
158
|
+
version Show the CLI version
|
|
145
159
|
help Show this help message
|
|
146
160
|
|
|
147
161
|
EXAMPLES:
|
|
148
162
|
tanagram # Check changes (auto-syncs if files changed)
|
|
149
163
|
tanagram run # Same as above
|
|
164
|
+
tanagram snapshot # Create snapshot before making changes
|
|
150
165
|
tanagram sync # Manually sync policies
|
|
151
166
|
tanagram list # View all cached policies
|
|
152
|
-
tanagram config claude # Setup Claude Code
|
|
167
|
+
tanagram config claude # Setup Claude Code hooks in ~/.claude/settings.json
|
|
153
168
|
tanagram config list # Show where hooks are installed
|
|
169
|
+
tanagram version # Show the version
|
|
154
170
|
|
|
155
171
|
INSTRUCTION FILES:
|
|
156
172
|
Tanagram looks for instruction files in your git repository:
|
|
@@ -158,6 +174,13 @@ INSTRUCTION FILES:
|
|
|
158
174
|
- Cursor rules: .cursor/rules/*.mdc
|
|
159
175
|
|
|
160
176
|
Policies are cached and automatically resynced when files change.
|
|
177
|
+
|
|
178
|
+
HOOK WORKFLOW:
|
|
179
|
+
When configured with 'tanagram config claude', two hooks are installed:
|
|
180
|
+
1. PreToolUse (Edit|Write): Creates snapshot before Claude makes changes
|
|
181
|
+
2. PostToolUse (Edit|Write): Checks only Claude's changes against policies
|
|
182
|
+
|
|
183
|
+
This prevents false positives from user-written code!
|
|
161
184
|
`
|
|
162
185
|
fmt.Print(help)
|
|
163
186
|
}
|
package/metrics/metrics.go
CHANGED
|
@@ -14,8 +14,14 @@ var (
|
|
|
14
14
|
// Injected at build time via -ldflags
|
|
15
15
|
posthogWriteKey = "phc_sMsUvf0nK50rZdztSlX9rDJqIreLcXj4dyGS0tORQpQ"
|
|
16
16
|
posthogHost = "https://us.i.posthog.com"
|
|
17
|
+
version = "dev"
|
|
17
18
|
)
|
|
18
19
|
|
|
20
|
+
// SetVersion sets the CLI version
|
|
21
|
+
func SetVersion(v string) {
|
|
22
|
+
version = v
|
|
23
|
+
}
|
|
24
|
+
|
|
19
25
|
// Init initializes the PostHog client
|
|
20
26
|
// Similar to PosthogService.py and webui/app/lib/posthog.ts
|
|
21
27
|
func Init() {
|
|
@@ -117,6 +123,5 @@ func getDeploymentEnv() string {
|
|
|
117
123
|
|
|
118
124
|
// getVersion returns the CLI version
|
|
119
125
|
func getVersion() string {
|
|
120
|
-
|
|
121
|
-
return "0.1.14"
|
|
126
|
+
return version
|
|
122
127
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tanagram/cli",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Tanagram - Catch sloppy code before it ships",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"llm/",
|
|
43
43
|
"metrics/",
|
|
44
44
|
"parser/",
|
|
45
|
+
"snapshot/",
|
|
45
46
|
"storage/",
|
|
46
47
|
"tui/",
|
|
47
48
|
"main.go",
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
package snapshot
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"crypto/md5"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"fmt"
|
|
7
|
+
"io"
|
|
8
|
+
"os"
|
|
9
|
+
"path/filepath"
|
|
10
|
+
"strings"
|
|
11
|
+
"time"
|
|
12
|
+
|
|
13
|
+
"github.com/tanagram/cli/storage"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
// FileState represents the state of a single file
|
|
17
|
+
type FileState struct {
|
|
18
|
+
Path string `json:"path"`
|
|
19
|
+
Hash string `json:"hash"`
|
|
20
|
+
Size int64 `json:"size"`
|
|
21
|
+
ModifiedTime time.Time `json:"modified_time"`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Snapshot represents a snapshot of the working directory
|
|
25
|
+
type Snapshot struct {
|
|
26
|
+
Timestamp time.Time `json:"timestamp"`
|
|
27
|
+
Files map[string]*FileState `json:"files"`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// SnapshotPath returns the path to the snapshot file
|
|
31
|
+
func SnapshotPath(gitRoot string) string {
|
|
32
|
+
return filepath.Join(gitRoot, ".tanagram", "snapshot.json")
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Create creates a new snapshot of all tracked and modified files
|
|
36
|
+
func Create(gitRoot string) error {
|
|
37
|
+
snapshot := &Snapshot{
|
|
38
|
+
Timestamp: time.Now(),
|
|
39
|
+
Files: make(map[string]*FileState),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Walk the git repository
|
|
43
|
+
err := filepath.Walk(gitRoot, func(path string, info os.FileInfo, err error) error {
|
|
44
|
+
if err != nil {
|
|
45
|
+
return err
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Skip .git directory and .tanagram directory
|
|
49
|
+
if info.IsDir() {
|
|
50
|
+
if info.Name() == ".git" || info.Name() == ".tanagram" || info.Name() == "node_modules" {
|
|
51
|
+
return filepath.SkipDir
|
|
52
|
+
}
|
|
53
|
+
return nil
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Get relative path
|
|
57
|
+
relPath, err := filepath.Rel(gitRoot, path)
|
|
58
|
+
if err != nil {
|
|
59
|
+
return err
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Calculate file hash
|
|
63
|
+
hash, err := hashFile(path)
|
|
64
|
+
if err != nil {
|
|
65
|
+
// Skip files we can't read (permissions, etc)
|
|
66
|
+
return nil
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
snapshot.Files[relPath] = &FileState{
|
|
70
|
+
Path: relPath,
|
|
71
|
+
Hash: hash,
|
|
72
|
+
Size: info.Size(),
|
|
73
|
+
ModifiedTime: info.ModTime(),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return nil
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
if err != nil {
|
|
80
|
+
return fmt.Errorf("failed to walk directory: %w", err)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Save snapshot
|
|
84
|
+
return Save(gitRoot, snapshot)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Load loads an existing snapshot
|
|
88
|
+
func Load(gitRoot string) (*Snapshot, error) {
|
|
89
|
+
path := SnapshotPath(gitRoot)
|
|
90
|
+
|
|
91
|
+
data, err := os.ReadFile(path)
|
|
92
|
+
if err != nil {
|
|
93
|
+
if os.IsNotExist(err) {
|
|
94
|
+
return nil, nil // No snapshot exists
|
|
95
|
+
}
|
|
96
|
+
return nil, fmt.Errorf("failed to read snapshot: %w", err)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
var snapshot Snapshot
|
|
100
|
+
if err := json.Unmarshal(data, &snapshot); err != nil {
|
|
101
|
+
return nil, fmt.Errorf("failed to parse snapshot: %w", err)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return &snapshot, nil
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Save saves a snapshot to disk
|
|
108
|
+
func Save(gitRoot string, snapshot *Snapshot) error {
|
|
109
|
+
path := SnapshotPath(gitRoot)
|
|
110
|
+
|
|
111
|
+
// Ensure .tanagram directory exists
|
|
112
|
+
tanagramDir := filepath.Join(gitRoot, ".tanagram")
|
|
113
|
+
if err := os.MkdirAll(tanagramDir, 0755); err != nil {
|
|
114
|
+
return fmt.Errorf("failed to create .tanagram directory: %w", err)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Marshal snapshot
|
|
118
|
+
data, err := json.MarshalIndent(snapshot, "", " ")
|
|
119
|
+
if err != nil {
|
|
120
|
+
return fmt.Errorf("failed to marshal snapshot: %w", err)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Write to file
|
|
124
|
+
if err := os.WriteFile(path, data, 0644); err != nil {
|
|
125
|
+
return fmt.Errorf("failed to write snapshot: %w", err)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return nil
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Delete removes the snapshot file
|
|
132
|
+
func Delete(gitRoot string) error {
|
|
133
|
+
path := SnapshotPath(gitRoot)
|
|
134
|
+
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
|
135
|
+
return fmt.Errorf("failed to delete snapshot: %w", err)
|
|
136
|
+
}
|
|
137
|
+
return nil
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// CompareResult represents the result of comparing two snapshots
|
|
141
|
+
type CompareResult struct {
|
|
142
|
+
ModifiedFiles []string
|
|
143
|
+
DeletedFiles []string
|
|
144
|
+
NewFiles []string
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Compare compares the current working directory to a snapshot
|
|
148
|
+
func Compare(gitRoot string, snapshot *Snapshot) (*CompareResult, error) {
|
|
149
|
+
result := &CompareResult{
|
|
150
|
+
ModifiedFiles: []string{},
|
|
151
|
+
DeletedFiles: []string{},
|
|
152
|
+
NewFiles: []string{},
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Get current file states
|
|
156
|
+
currentFiles := make(map[string]*FileState)
|
|
157
|
+
err := filepath.Walk(gitRoot, func(path string, info os.FileInfo, err error) error {
|
|
158
|
+
if err != nil {
|
|
159
|
+
return err
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Skip directories
|
|
163
|
+
if info.IsDir() {
|
|
164
|
+
if info.Name() == ".git" || info.Name() == ".tanagram" || info.Name() == "node_modules" {
|
|
165
|
+
return filepath.SkipDir
|
|
166
|
+
}
|
|
167
|
+
return nil
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Get relative path
|
|
171
|
+
relPath, err := filepath.Rel(gitRoot, path)
|
|
172
|
+
if err != nil {
|
|
173
|
+
return err
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Calculate file hash
|
|
177
|
+
hash, err := hashFile(path)
|
|
178
|
+
if err != nil {
|
|
179
|
+
return nil
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
currentFiles[relPath] = &FileState{
|
|
183
|
+
Path: relPath,
|
|
184
|
+
Hash: hash,
|
|
185
|
+
Size: info.Size(),
|
|
186
|
+
ModifiedTime: info.ModTime(),
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return nil
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
if err != nil {
|
|
193
|
+
return nil, fmt.Errorf("failed to walk directory: %w", err)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Find modified and new files
|
|
197
|
+
for path, currentState := range currentFiles {
|
|
198
|
+
if snapshotState, exists := snapshot.Files[path]; exists {
|
|
199
|
+
// File existed in snapshot - check if modified
|
|
200
|
+
if currentState.Hash != snapshotState.Hash {
|
|
201
|
+
result.ModifiedFiles = append(result.ModifiedFiles, path)
|
|
202
|
+
}
|
|
203
|
+
} else {
|
|
204
|
+
// File is new
|
|
205
|
+
result.NewFiles = append(result.NewFiles, path)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Find deleted files
|
|
210
|
+
for path := range snapshot.Files {
|
|
211
|
+
if _, exists := currentFiles[path]; !exists {
|
|
212
|
+
result.DeletedFiles = append(result.DeletedFiles, path)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return result, nil
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// GetChangedFileContents reads the contents of files that changed
|
|
220
|
+
func GetChangedFileContents(gitRoot string, compareResult *CompareResult) (map[string]string, error) {
|
|
221
|
+
contents := make(map[string]string)
|
|
222
|
+
|
|
223
|
+
// Read contents of modified and new files
|
|
224
|
+
allChangedFiles := append(compareResult.ModifiedFiles, compareResult.NewFiles...)
|
|
225
|
+
|
|
226
|
+
for _, relPath := range allChangedFiles {
|
|
227
|
+
fullPath := filepath.Join(gitRoot, relPath)
|
|
228
|
+
|
|
229
|
+
// Skip binary files and very large files
|
|
230
|
+
info, err := os.Stat(fullPath)
|
|
231
|
+
if err != nil {
|
|
232
|
+
continue
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Skip files larger than 1MB
|
|
236
|
+
if info.Size() > 1024*1024 {
|
|
237
|
+
continue
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
data, err := os.ReadFile(fullPath)
|
|
241
|
+
if err != nil {
|
|
242
|
+
continue
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Check if file is likely text
|
|
246
|
+
if isText(data) {
|
|
247
|
+
contents[relPath] = string(data)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return contents, nil
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// hashFile calculates the MD5 hash of a file
|
|
255
|
+
func hashFile(path string) (string, error) {
|
|
256
|
+
file, err := os.Open(path)
|
|
257
|
+
if err != nil {
|
|
258
|
+
return "", err
|
|
259
|
+
}
|
|
260
|
+
defer file.Close()
|
|
261
|
+
|
|
262
|
+
hash := md5.New()
|
|
263
|
+
if _, err := io.Copy(hash, file); err != nil {
|
|
264
|
+
return "", err
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return fmt.Sprintf("%x", hash.Sum(nil)), nil
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// isText checks if data is likely text (not binary)
|
|
271
|
+
func isText(data []byte) bool {
|
|
272
|
+
if len(data) == 0 {
|
|
273
|
+
return true
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Check first 512 bytes for null bytes
|
|
277
|
+
checkLen := len(data)
|
|
278
|
+
if checkLen > 512 {
|
|
279
|
+
checkLen = 512
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
for i := 0; i < checkLen; i++ {
|
|
283
|
+
if data[i] == 0 {
|
|
284
|
+
return false
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return true
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Exists checks if a snapshot exists
|
|
292
|
+
func Exists(gitRoot string) bool {
|
|
293
|
+
_, err := os.Stat(SnapshotPath(gitRoot))
|
|
294
|
+
return err == nil
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Note: We use a simple approach here - for modified files, we include ALL lines
|
|
298
|
+
// A more sophisticated approach would do line-by-line diffing to find exact changes
|
|
299
|
+
// But this works well enough for our use case since we only check files that changed
|
|
300
|
+
|
|
301
|
+
// GetChangedLinesForChecker extracts line-level changes and returns them in a format
|
|
302
|
+
// compatible with what the checker expects (same as git diff output)
|
|
303
|
+
type ChangedLine struct {
|
|
304
|
+
File string
|
|
305
|
+
LineNumber int
|
|
306
|
+
Content string
|
|
307
|
+
ChangeType string // "+" for added/modified
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
func GetChangedLinesForChecker(gitRoot string, snapshot *Snapshot, compareResult *CompareResult) ([]ChangedLine, error) {
|
|
311
|
+
var changes []ChangedLine
|
|
312
|
+
|
|
313
|
+
// For each modified file, include all non-empty lines
|
|
314
|
+
// (we can't easily determine which specific lines changed without doing a full diff)
|
|
315
|
+
for _, relPath := range compareResult.ModifiedFiles {
|
|
316
|
+
fullPath := filepath.Join(gitRoot, relPath)
|
|
317
|
+
|
|
318
|
+
// Read new content
|
|
319
|
+
newData, err := os.ReadFile(fullPath)
|
|
320
|
+
if err != nil {
|
|
321
|
+
continue
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Skip binary files
|
|
325
|
+
if !isText(newData) {
|
|
326
|
+
continue
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
newLines := strings.Split(string(newData), "\n")
|
|
330
|
+
|
|
331
|
+
// Include all non-empty lines from modified files
|
|
332
|
+
for i, line := range newLines {
|
|
333
|
+
if strings.TrimSpace(line) != "" {
|
|
334
|
+
changes = append(changes, ChangedLine{
|
|
335
|
+
File: relPath,
|
|
336
|
+
LineNumber: i + 1,
|
|
337
|
+
Content: line,
|
|
338
|
+
ChangeType: "+",
|
|
339
|
+
})
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// For new files, all lines are "added"
|
|
345
|
+
for _, relPath := range compareResult.NewFiles {
|
|
346
|
+
fullPath := filepath.Join(gitRoot, relPath)
|
|
347
|
+
|
|
348
|
+
data, err := os.ReadFile(fullPath)
|
|
349
|
+
if err != nil {
|
|
350
|
+
continue
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if !isText(data) {
|
|
354
|
+
continue
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
lines := strings.Split(string(data), "\n")
|
|
358
|
+
for i, line := range lines {
|
|
359
|
+
if strings.TrimSpace(line) != "" {
|
|
360
|
+
changes = append(changes, ChangedLine{
|
|
361
|
+
File: relPath,
|
|
362
|
+
LineNumber: i + 1,
|
|
363
|
+
Content: line,
|
|
364
|
+
ChangeType: "+",
|
|
365
|
+
})
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return changes, nil
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// init adds .tanagram to .gitignore if not already present
|
|
374
|
+
func EnsureGitIgnore(gitRoot string) error {
|
|
375
|
+
gitignorePath := filepath.Join(gitRoot, ".gitignore")
|
|
376
|
+
|
|
377
|
+
// Read existing .gitignore
|
|
378
|
+
var existingContent string
|
|
379
|
+
data, err := os.ReadFile(gitignorePath)
|
|
380
|
+
if err != nil && !os.IsNotExist(err) {
|
|
381
|
+
return fmt.Errorf("failed to read .gitignore: %w", err)
|
|
382
|
+
}
|
|
383
|
+
if err == nil {
|
|
384
|
+
existingContent = string(data)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Check if .tanagram is already in .gitignore
|
|
388
|
+
if strings.Contains(existingContent, ".tanagram") {
|
|
389
|
+
return nil
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Append .tanagram to .gitignore
|
|
393
|
+
f, err := os.OpenFile(gitignorePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
|
394
|
+
if err != nil {
|
|
395
|
+
return fmt.Errorf("failed to open .gitignore: %w", err)
|
|
396
|
+
}
|
|
397
|
+
defer f.Close()
|
|
398
|
+
|
|
399
|
+
if existingContent != "" && !strings.HasSuffix(existingContent, "\n") {
|
|
400
|
+
if _, err := f.WriteString("\n"); err != nil {
|
|
401
|
+
return err
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if _, err := f.WriteString(".tanagram/\n"); err != nil {
|
|
406
|
+
return fmt.Errorf("failed to write to .gitignore: %w", err)
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return nil
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
func init() {
|
|
413
|
+
// Try to add .tanagram to .gitignore when package is loaded
|
|
414
|
+
gitRoot, err := storage.FindGitRoot()
|
|
415
|
+
if err == nil {
|
|
416
|
+
_ = EnsureGitIgnore(gitRoot)
|
|
417
|
+
}
|
|
418
|
+
}
|