@tanagram/cli 0.1.6 → 0.1.8
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 +26 -2
- package/bin/tanagram +0 -0
- package/checker/matcher.go +50 -1
- package/checker/matcher_test.go +215 -0
- package/commands/run.go +14 -7
- package/commands/sync.go +3 -3
- package/main.go +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -90,16 +90,40 @@ tanagram help
|
|
|
90
90
|
|
|
91
91
|
**Smart Caching:** Policies are cached and automatically resynced when instruction files change (detected via MD5 hash).
|
|
92
92
|
|
|
93
|
-
|
|
93
|
+
### Commands
|
|
94
94
|
|
|
95
95
|
- **`run`** (default) - Check git changes against policies with auto-sync
|
|
96
96
|
- **`sync`** - Manually sync all instruction files to cache
|
|
97
97
|
- **`list`** - View all cached policies (shows enforceable vs unenforceable)
|
|
98
98
|
- **`help`** - Show usage information
|
|
99
99
|
|
|
100
|
+
### Claude Code Hook
|
|
101
|
+
You can install the CLI as a Claude Code [hook](https://code.claude.com/docs/en/hooks) to have Claude automatically iterate on Tanagram's output. Add the following to your `~/.claude/settings.json` (user settings) or `.claude/settings.json` (project settings):
|
|
102
|
+
|
|
103
|
+
```json
|
|
104
|
+
{
|
|
105
|
+
"hooks": {
|
|
106
|
+
"PostToolUse": [
|
|
107
|
+
{
|
|
108
|
+
"matcher": "Edit|Write",
|
|
109
|
+
"hooks": [
|
|
110
|
+
{
|
|
111
|
+
"type": "command",
|
|
112
|
+
"command": "tanagram"
|
|
113
|
+
}
|
|
114
|
+
]
|
|
115
|
+
}
|
|
116
|
+
]
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
If you have existing hooks, you can merge this hook into your existing config.
|
|
122
|
+
|
|
123
|
+
|
|
100
124
|
## How It Works
|
|
101
125
|
|
|
102
|
-
1. **Finds instruction files** - Searches for `AGENTS.md`, `POLICIES.md` in your git repository
|
|
126
|
+
1. **Finds instruction files** - Searches for `AGENTS.md`, `POLICIES.md`, `CLAUDE.md` in your git repository
|
|
103
127
|
2. **Checks cache** - Loads cached policies and MD5 hashes from `.tanagram/`
|
|
104
128
|
3. **Auto-syncs** - Detects file changes via MD5 and automatically resyncs if needed
|
|
105
129
|
4. **LLM extraction** - Uses Claude AI to extract ALL policies from instruction files
|
package/bin/tanagram
CHANGED
|
Binary file
|
package/checker/matcher.go
CHANGED
|
@@ -21,7 +21,7 @@ type Violation struct {
|
|
|
21
21
|
|
|
22
22
|
// CheckResult contains all violations found
|
|
23
23
|
type CheckResult struct {
|
|
24
|
-
Violations
|
|
24
|
+
Violations []Violation
|
|
25
25
|
TotalChecked int
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -89,3 +89,52 @@ func FormatViolations(result *CheckResult) string {
|
|
|
89
89
|
|
|
90
90
|
return output.String()
|
|
91
91
|
}
|
|
92
|
+
|
|
93
|
+
// FormatClaudeInstructions formats violations as actionable instructions for Claude Code
|
|
94
|
+
// to automatically fix via exit code 2 hook behavior
|
|
95
|
+
func FormatClaudeInstructions(result *CheckResult) string {
|
|
96
|
+
if len(result.Violations) == 0 {
|
|
97
|
+
return ""
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Group violations by file
|
|
101
|
+
violationsByFile := make(map[string][]Violation)
|
|
102
|
+
for _, v := range result.Violations {
|
|
103
|
+
violationsByFile[v.File] = append(violationsByFile[v.File], v)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Sort files for consistent output
|
|
107
|
+
files := make([]string, 0, len(violationsByFile))
|
|
108
|
+
for file := range violationsByFile {
|
|
109
|
+
files = append(files, file)
|
|
110
|
+
}
|
|
111
|
+
sort.Strings(files)
|
|
112
|
+
|
|
113
|
+
var output strings.Builder
|
|
114
|
+
output.WriteString("POLICY VIOLATIONS DETECTED - PLEASE FIX\n\n")
|
|
115
|
+
output.WriteString(fmt.Sprintf("Found %d policy violation(s) that need to be fixed:\n\n", len(result.Violations)))
|
|
116
|
+
|
|
117
|
+
for _, file := range files {
|
|
118
|
+
violations := violationsByFile[file]
|
|
119
|
+
|
|
120
|
+
// Sort violations by line number
|
|
121
|
+
sort.Slice(violations, func(i, j int) bool {
|
|
122
|
+
return violations[i].LineNumber < violations[j].LineNumber
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
output.WriteString(fmt.Sprintf("File: %s\n", file))
|
|
126
|
+
|
|
127
|
+
for _, v := range violations {
|
|
128
|
+
output.WriteString(fmt.Sprintf("\n Line %d:\n", v.LineNumber))
|
|
129
|
+
output.WriteString(fmt.Sprintf(" Code: %s\n", v.Code))
|
|
130
|
+
output.WriteString(fmt.Sprintf(" Policy: %s\n", v.PolicyName))
|
|
131
|
+
output.WriteString(fmt.Sprintf(" Issue: %s\n", v.Message))
|
|
132
|
+
output.WriteString(fmt.Sprintf(" Action: Review and fix this code to comply with the policy.\n"))
|
|
133
|
+
}
|
|
134
|
+
output.WriteString("\n")
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
output.WriteString("Please fix all violations listed above and ensure the code complies with all policies.\n")
|
|
138
|
+
|
|
139
|
+
return output.String()
|
|
140
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
package checker
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"strings"
|
|
5
|
+
"testing"
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
func TestFormatViolations_NoViolations(t *testing.T) {
|
|
9
|
+
result := &CheckResult{
|
|
10
|
+
Violations: []Violation{},
|
|
11
|
+
TotalChecked: 10,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
output := FormatViolations(result)
|
|
15
|
+
expected := "✓ No policy violations found"
|
|
16
|
+
|
|
17
|
+
if output != expected {
|
|
18
|
+
t.Errorf("Expected %q, got %q", expected, output)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
func TestFormatViolations_WithViolations(t *testing.T) {
|
|
23
|
+
result := &CheckResult{
|
|
24
|
+
Violations: []Violation{
|
|
25
|
+
{
|
|
26
|
+
File: "main.go",
|
|
27
|
+
LineNumber: 10,
|
|
28
|
+
PolicyName: "No hardcoded secrets",
|
|
29
|
+
Message: "Potential hardcoded API key detected",
|
|
30
|
+
Code: "apiKey := \"sk-1234567890\"",
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
File: "config.go",
|
|
34
|
+
LineNumber: 5,
|
|
35
|
+
PolicyName: "Use environment variables",
|
|
36
|
+
Message: "Configuration should use environment variables",
|
|
37
|
+
Code: "var token = \"abc123\"",
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
TotalChecked: 50,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
output := FormatViolations(result)
|
|
44
|
+
|
|
45
|
+
// Check that output contains expected elements
|
|
46
|
+
if !strings.Contains(output, "✗ Found 2 policy violation(s)") {
|
|
47
|
+
t.Errorf("Expected violation count in output, got: %s", output)
|
|
48
|
+
}
|
|
49
|
+
if !strings.Contains(output, "main.go") {
|
|
50
|
+
t.Errorf("Expected file name 'main.go' in output")
|
|
51
|
+
}
|
|
52
|
+
if !strings.Contains(output, "config.go") {
|
|
53
|
+
t.Errorf("Expected file name 'config.go' in output")
|
|
54
|
+
}
|
|
55
|
+
if !strings.Contains(output, "10:") {
|
|
56
|
+
t.Errorf("Expected line number 10 in output")
|
|
57
|
+
}
|
|
58
|
+
if !strings.Contains(output, "Potential hardcoded API key detected") {
|
|
59
|
+
t.Errorf("Expected violation message in output")
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
func TestFormatClaudeInstructions_NoViolations(t *testing.T) {
|
|
64
|
+
result := &CheckResult{
|
|
65
|
+
Violations: []Violation{},
|
|
66
|
+
TotalChecked: 10,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
output := FormatClaudeInstructions(result)
|
|
70
|
+
|
|
71
|
+
if output != "" {
|
|
72
|
+
t.Errorf("Expected empty string for no violations, got: %s", output)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
func TestFormatClaudeInstructions_WithViolations(t *testing.T) {
|
|
77
|
+
result := &CheckResult{
|
|
78
|
+
Violations: []Violation{
|
|
79
|
+
{
|
|
80
|
+
File: "main.go",
|
|
81
|
+
LineNumber: 10,
|
|
82
|
+
PolicyName: "No hardcoded secrets",
|
|
83
|
+
Message: "Potential hardcoded API key detected",
|
|
84
|
+
Code: "apiKey := \"sk-1234567890\"",
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
File: "main.go",
|
|
88
|
+
LineNumber: 15,
|
|
89
|
+
PolicyName: "No hardcoded secrets",
|
|
90
|
+
Message: "Potential hardcoded password detected",
|
|
91
|
+
Code: "password := \"secret123\"",
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
File: "config.go",
|
|
95
|
+
LineNumber: 5,
|
|
96
|
+
PolicyName: "Use environment variables",
|
|
97
|
+
Message: "Configuration should use environment variables",
|
|
98
|
+
Code: "var token = \"abc123\"",
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
TotalChecked: 50,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
output := FormatClaudeInstructions(result)
|
|
105
|
+
|
|
106
|
+
// Check header
|
|
107
|
+
if !strings.Contains(output, "POLICY VIOLATIONS DETECTED - PLEASE FIX") {
|
|
108
|
+
t.Errorf("Expected header in output")
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Check violation count
|
|
112
|
+
if !strings.Contains(output, "Found 3 policy violation(s)") {
|
|
113
|
+
t.Errorf("Expected violation count in output, got: %s", output)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Check file names
|
|
117
|
+
if !strings.Contains(output, "File: main.go") {
|
|
118
|
+
t.Errorf("Expected 'File: main.go' in output")
|
|
119
|
+
}
|
|
120
|
+
if !strings.Contains(output, "File: config.go") {
|
|
121
|
+
t.Errorf("Expected 'File: config.go' in output")
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Check line numbers
|
|
125
|
+
if !strings.Contains(output, "Line 10:") {
|
|
126
|
+
t.Errorf("Expected 'Line 10:' in output")
|
|
127
|
+
}
|
|
128
|
+
if !strings.Contains(output, "Line 15:") {
|
|
129
|
+
t.Errorf("Expected 'Line 15:' in output")
|
|
130
|
+
}
|
|
131
|
+
if !strings.Contains(output, "Line 5:") {
|
|
132
|
+
t.Errorf("Expected 'Line 5:' in output")
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Check policy names
|
|
136
|
+
if !strings.Contains(output, "Policy: No hardcoded secrets") {
|
|
137
|
+
t.Errorf("Expected policy name in output")
|
|
138
|
+
}
|
|
139
|
+
if !strings.Contains(output, "Policy: Use environment variables") {
|
|
140
|
+
t.Errorf("Expected policy name in output")
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Check code snippets
|
|
144
|
+
if !strings.Contains(output, "Code: apiKey := \"sk-1234567890\"") {
|
|
145
|
+
t.Errorf("Expected code snippet in output")
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Check issue messages
|
|
149
|
+
if !strings.Contains(output, "Issue: Potential hardcoded API key detected") {
|
|
150
|
+
t.Errorf("Expected issue message in output")
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Check action instruction
|
|
154
|
+
if !strings.Contains(output, "Action: Review and fix this code to comply with the policy.") {
|
|
155
|
+
t.Errorf("Expected action instruction in output")
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Check footer
|
|
159
|
+
if !strings.Contains(output, "Please fix all violations listed above and ensure the code complies with all policies.") {
|
|
160
|
+
t.Errorf("Expected footer instruction in output")
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
func TestFormatClaudeInstructions_MultipleFilesGroupedAndSorted(t *testing.T) {
|
|
165
|
+
result := &CheckResult{
|
|
166
|
+
Violations: []Violation{
|
|
167
|
+
{
|
|
168
|
+
File: "z_file.go",
|
|
169
|
+
LineNumber: 20,
|
|
170
|
+
PolicyName: "Policy Z",
|
|
171
|
+
Message: "Issue Z",
|
|
172
|
+
Code: "code z",
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
File: "a_file.go",
|
|
176
|
+
LineNumber: 30,
|
|
177
|
+
PolicyName: "Policy A2",
|
|
178
|
+
Message: "Issue A2",
|
|
179
|
+
Code: "code a2",
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
File: "a_file.go",
|
|
183
|
+
LineNumber: 10,
|
|
184
|
+
PolicyName: "Policy A1",
|
|
185
|
+
Message: "Issue A1",
|
|
186
|
+
Code: "code a1",
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
TotalChecked: 50,
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
output := FormatClaudeInstructions(result)
|
|
193
|
+
|
|
194
|
+
// Files should be sorted alphabetically
|
|
195
|
+
aFileIndex := strings.Index(output, "File: a_file.go")
|
|
196
|
+
zFileIndex := strings.Index(output, "File: z_file.go")
|
|
197
|
+
|
|
198
|
+
if aFileIndex == -1 || zFileIndex == -1 {
|
|
199
|
+
t.Errorf("Expected both file names in output")
|
|
200
|
+
}
|
|
201
|
+
if aFileIndex >= zFileIndex {
|
|
202
|
+
t.Errorf("Expected files to be sorted alphabetically (a_file.go before z_file.go)")
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Within a file, violations should be sorted by line number
|
|
206
|
+
line10Index := strings.Index(output, "Line 10:")
|
|
207
|
+
line30Index := strings.Index(output, "Line 30:")
|
|
208
|
+
|
|
209
|
+
if line10Index == -1 || line30Index == -1 {
|
|
210
|
+
t.Errorf("Expected both line numbers in output")
|
|
211
|
+
}
|
|
212
|
+
if line10Index >= line30Index {
|
|
213
|
+
t.Errorf("Expected violations to be sorted by line number (10 before 30)")
|
|
214
|
+
}
|
|
215
|
+
}
|
package/commands/run.go
CHANGED
|
@@ -47,7 +47,7 @@ func Run() error {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
if len(instructionFiles) == 0 {
|
|
50
|
-
return fmt.Errorf("no instruction files found (looking for AGENTS.md, POLICIES.md,
|
|
50
|
+
return fmt.Errorf("no instruction files found (looking for AGENTS.md, POLICIES.md, CLAUDE.md)")
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
// Load cache
|
|
@@ -196,14 +196,21 @@ func Run() error {
|
|
|
196
196
|
ctx := context.Background()
|
|
197
197
|
result := checker.CheckChanges(ctx, diffResult.Changes, policies)
|
|
198
198
|
|
|
199
|
-
//
|
|
200
|
-
output := checker.FormatViolations(result)
|
|
201
|
-
fmt.Print(output)
|
|
202
|
-
|
|
203
|
-
// Exit with error if violations found
|
|
199
|
+
// Handle results based on whether violations were found
|
|
204
200
|
if len(result.Violations) > 0 {
|
|
205
|
-
|
|
201
|
+
// Format and output violations to stderr
|
|
202
|
+
violationOutput := checker.FormatViolations(result)
|
|
203
|
+
fmt.Fprint(os.Stderr, violationOutput)
|
|
204
|
+
|
|
205
|
+
// Format and output Claude-friendly instructions to stderr
|
|
206
|
+
claudeInstructions := checker.FormatClaudeInstructions(result)
|
|
207
|
+
fmt.Fprint(os.Stderr, claudeInstructions)
|
|
208
|
+
|
|
209
|
+
// Exit with code 2 to trigger Claude Code hook behavior
|
|
210
|
+
os.Exit(2)
|
|
206
211
|
}
|
|
207
212
|
|
|
213
|
+
// No violations found - output success message
|
|
214
|
+
fmt.Println("✓ No policy violations found")
|
|
208
215
|
return nil
|
|
209
216
|
}
|
package/commands/sync.go
CHANGED
|
@@ -28,7 +28,7 @@ func Sync() error {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
if len(instructionFiles) == 0 {
|
|
31
|
-
return fmt.Errorf("no instruction files found (looking for AGENTS.md, POLICIES.md,
|
|
31
|
+
return fmt.Errorf("no instruction files found (looking for AGENTS.md, POLICIES.md, CLAUDE.md)")
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
fmt.Printf("Found %d instruction file(s)\n", len(instructionFiles))
|
|
@@ -137,12 +137,12 @@ func Sync() error {
|
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
// FindInstructionFiles searches for instruction files in the git repository
|
|
140
|
-
// Looks for: AGENTS.md, POLICIES.md,
|
|
140
|
+
// Looks for: AGENTS.md, POLICIES.md, CLAUDE.md
|
|
141
141
|
func FindInstructionFiles(gitRoot string) ([]string, error) {
|
|
142
142
|
var files []string
|
|
143
143
|
|
|
144
144
|
// Common instruction file names to look for
|
|
145
|
-
commonNames := []string{"AGENTS.md", "POLICIES.md"}
|
|
145
|
+
commonNames := []string{"AGENTS.md", "POLICIES.md", "CLAUDE.md"}
|
|
146
146
|
|
|
147
147
|
// Directories to skip
|
|
148
148
|
skipDirs := map[string]bool{
|
package/main.go
CHANGED
|
@@ -56,7 +56,7 @@ EXAMPLES:
|
|
|
56
56
|
tanagram list # View all cached policies
|
|
57
57
|
|
|
58
58
|
INSTRUCTION FILES:
|
|
59
|
-
Tanagram looks for instruction files like AGENTS.md or
|
|
59
|
+
Tanagram looks for instruction files like AGENTS.md, POLICIES.md, or CLAUDE.md
|
|
60
60
|
in your git repository. Policies are cached and automatically resynced
|
|
61
61
|
when files change.
|
|
62
62
|
`
|