@tanagram/cli 0.1.0 → 0.1.2
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/checker/llm_integration.go +2 -2
- package/checker/matcher.go +2 -2
- package/checker/violation_checker.go +2 -2
- package/commands/list.go +55 -0
- package/commands/run.go +118 -0
- package/commands/sync.go +124 -0
- package/extractor/extractor.go +106 -0
- package/extractor/file.go +19 -0
- package/fixtures/DEMO_AGENTS.md +6 -0
- package/fixtures/demo_test.tsx +6 -0
- package/go.mod +1 -1
- package/llm/client.go +64 -0
- package/llm/utils.go +66 -0
- package/main.go +1 -1
- package/package.json +9 -2
- package/storage/cache.go +198 -0
|
@@ -5,8 +5,8 @@ import (
|
|
|
5
5
|
"fmt"
|
|
6
6
|
"strings"
|
|
7
7
|
|
|
8
|
-
"github.com/tanagram/
|
|
9
|
-
"github.com/tanagram/
|
|
8
|
+
"github.com/tanagram/cli/git"
|
|
9
|
+
"github.com/tanagram/cli/parser"
|
|
10
10
|
)
|
|
11
11
|
|
|
12
12
|
// CheckChangesWithLLM checks code changes against ALL policies using LLM
|
package/checker/matcher.go
CHANGED
|
@@ -6,8 +6,8 @@ import (
|
|
|
6
6
|
"fmt"
|
|
7
7
|
"strings"
|
|
8
8
|
|
|
9
|
-
"github.com/tanagram/
|
|
10
|
-
"github.com/tanagram/
|
|
9
|
+
"github.com/tanagram/cli/llm"
|
|
10
|
+
"github.com/tanagram/cli/parser"
|
|
11
11
|
)
|
|
12
12
|
|
|
13
13
|
// ViolationCheck represents a single policy violation check result from the LLM
|
package/commands/list.go
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
package commands
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
|
|
6
|
+
"github.com/tanagram/cli/storage"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
// List displays all cached policies
|
|
10
|
+
func List() error {
|
|
11
|
+
// Find git root
|
|
12
|
+
gitRoot, err := storage.FindGitRoot()
|
|
13
|
+
if err != nil {
|
|
14
|
+
return fmt.Errorf("not in a git repository: %w", err)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Load cache
|
|
18
|
+
cache, err := storage.LoadCache(gitRoot)
|
|
19
|
+
if err != nil {
|
|
20
|
+
return fmt.Errorf("failed to load cache: %w", err)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if len(cache.Policies) == 0 {
|
|
24
|
+
fmt.Println("No cached policies found. Run 'tanagram sync' first.")
|
|
25
|
+
return nil
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
totalPolicies := 0
|
|
29
|
+
|
|
30
|
+
fmt.Println("Cached Policies (All enforced via LLM):")
|
|
31
|
+
|
|
32
|
+
// Display policies grouped by file
|
|
33
|
+
for filepath, serializablePolicies := range cache.Policies {
|
|
34
|
+
if len(serializablePolicies) == 0 {
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
fmt.Printf("📄 %s (%d policies)\n", filepath, len(serializablePolicies))
|
|
39
|
+
fmt.Println("─────────────────────────────────────────────────────────────")
|
|
40
|
+
|
|
41
|
+
for _, sp := range serializablePolicies {
|
|
42
|
+
totalPolicies++
|
|
43
|
+
fmt.Printf(" • %s: %s\n", sp.Name, sp.Message)
|
|
44
|
+
}
|
|
45
|
+
fmt.Println()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Summary
|
|
49
|
+
fmt.Println("═════════════════════════════════════════════════════════════")
|
|
50
|
+
fmt.Printf("Total: %d policies\n", totalPolicies)
|
|
51
|
+
fmt.Printf("Files: %d\n", len(cache.Policies))
|
|
52
|
+
fmt.Println("\nAll policies are enforced using LLM-based semantic analysis")
|
|
53
|
+
|
|
54
|
+
return nil
|
|
55
|
+
}
|
package/commands/run.go
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
package commands
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"fmt"
|
|
6
|
+
"os"
|
|
7
|
+
|
|
8
|
+
"github.com/tanagram/cli/checker"
|
|
9
|
+
"github.com/tanagram/cli/extractor"
|
|
10
|
+
"github.com/tanagram/cli/git"
|
|
11
|
+
"github.com/tanagram/cli/storage"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
// Run executes the main policy check with auto-sync
|
|
15
|
+
func Run() error {
|
|
16
|
+
// Find git root
|
|
17
|
+
gitRoot, err := storage.FindGitRoot()
|
|
18
|
+
if err != nil {
|
|
19
|
+
return fmt.Errorf("not in a git repository: %w", err)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Find all instruction files
|
|
23
|
+
instructionFiles, err := FindInstructionFiles(gitRoot)
|
|
24
|
+
if err != nil {
|
|
25
|
+
return err
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if len(instructionFiles) == 0 {
|
|
29
|
+
return fmt.Errorf("no instruction files found (looking for AGENTS.md, POLICIES.md, etc.)")
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Load cache
|
|
33
|
+
cache, err := storage.LoadCache(gitRoot)
|
|
34
|
+
if err != nil {
|
|
35
|
+
return fmt.Errorf("failed to load cache: %w", err)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Check each file for changes and auto-sync if needed
|
|
39
|
+
needsSync := false
|
|
40
|
+
for _, file := range instructionFiles {
|
|
41
|
+
changed, err := cache.HasChanged(file)
|
|
42
|
+
if err != nil {
|
|
43
|
+
return fmt.Errorf("failed to check if %s changed: %w", file, err)
|
|
44
|
+
}
|
|
45
|
+
if changed {
|
|
46
|
+
needsSync = true
|
|
47
|
+
break
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Auto-sync if any files changed
|
|
52
|
+
if needsSync {
|
|
53
|
+
fmt.Println("Instruction files changed, syncing with LLM...")
|
|
54
|
+
totalPolicies := 0
|
|
55
|
+
ctx := context.Background()
|
|
56
|
+
|
|
57
|
+
for _, file := range instructionFiles {
|
|
58
|
+
policies, err := extractor.ExtractPoliciesFromFile(ctx, file)
|
|
59
|
+
if err != nil {
|
|
60
|
+
return fmt.Errorf("failed to extract policies from %s: %w", file, err)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if err := cache.UpdateFile(file, policies); err != nil {
|
|
64
|
+
return fmt.Errorf("failed to update cache for %s: %w", file, err)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
totalPolicies += len(policies)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if err := cache.Save(); err != nil {
|
|
71
|
+
return fmt.Errorf("failed to save cache: %w", err)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
fmt.Printf("Synced %d policies from %d file(s)\n", totalPolicies, len(instructionFiles))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Load all policies from cache
|
|
78
|
+
policies, err := cache.GetAllPolicies()
|
|
79
|
+
if err != nil {
|
|
80
|
+
return fmt.Errorf("failed to load policies from cache: %w", err)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if len(policies) == 0 {
|
|
84
|
+
fmt.Println("No enforceable policies found")
|
|
85
|
+
return nil
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
fmt.Printf("Loaded %d policies\n", len(policies))
|
|
89
|
+
|
|
90
|
+
// Get all changes (unstaged + staged)
|
|
91
|
+
fmt.Println("Checking all changes (unstaged + staged)...")
|
|
92
|
+
diffResult, err := git.GetAllChanges()
|
|
93
|
+
if err != nil {
|
|
94
|
+
return fmt.Errorf("error getting git diff: %w", err)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if len(diffResult.Changes) == 0 {
|
|
98
|
+
fmt.Println("No changes to check")
|
|
99
|
+
return nil
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
fmt.Printf("Scanning %d changed lines...\n\n", len(diffResult.Changes))
|
|
103
|
+
|
|
104
|
+
// Check changes against policies (both regex and LLM-based)
|
|
105
|
+
ctx := context.Background()
|
|
106
|
+
result := checker.CheckChanges(ctx, diffResult.Changes, policies)
|
|
107
|
+
|
|
108
|
+
// Output results
|
|
109
|
+
output := checker.FormatViolations(result)
|
|
110
|
+
fmt.Print(output)
|
|
111
|
+
|
|
112
|
+
// Exit with error if violations found
|
|
113
|
+
if len(result.Violations) > 0 {
|
|
114
|
+
os.Exit(1)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return nil
|
|
118
|
+
}
|
package/commands/sync.go
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
package commands
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"fmt"
|
|
6
|
+
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
|
|
9
|
+
"github.com/tanagram/cli/extractor"
|
|
10
|
+
"github.com/tanagram/cli/storage"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
// Sync manually syncs all instruction files to the cache
|
|
14
|
+
func Sync() error {
|
|
15
|
+
// Find git root
|
|
16
|
+
gitRoot, err := storage.FindGitRoot()
|
|
17
|
+
if err != nil {
|
|
18
|
+
return fmt.Errorf("not in a git repository: %w", err)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Find all instruction files
|
|
22
|
+
instructionFiles, err := FindInstructionFiles(gitRoot)
|
|
23
|
+
if err != nil {
|
|
24
|
+
return err
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if len(instructionFiles) == 0 {
|
|
28
|
+
return fmt.Errorf("no instruction files found (looking for AGENTS.md, POLICIES.md, etc.)")
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
fmt.Printf("Found %d instruction file(s)\n", len(instructionFiles))
|
|
32
|
+
|
|
33
|
+
// Load or create cache
|
|
34
|
+
cache, err := storage.LoadCache(gitRoot)
|
|
35
|
+
if err != nil {
|
|
36
|
+
return fmt.Errorf("failed to load cache: %w", err)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Parse and sync each file using LLM
|
|
40
|
+
totalPolicies := 0
|
|
41
|
+
ctx := context.Background()
|
|
42
|
+
|
|
43
|
+
for _, file := range instructionFiles {
|
|
44
|
+
relPath, _ := filepath.Rel(gitRoot, file)
|
|
45
|
+
fmt.Printf("Syncing %s (using LLM)...\n", relPath)
|
|
46
|
+
|
|
47
|
+
policies, err := extractor.ExtractPoliciesFromFile(ctx, file)
|
|
48
|
+
if err != nil {
|
|
49
|
+
return fmt.Errorf("failed to extract policies from %s: %w", file, err)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if err := cache.UpdateFile(file, policies); err != nil {
|
|
53
|
+
return fmt.Errorf("failed to update cache for %s: %w", file, err)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
fmt.Printf(" ✓ Extracted %d policies\n", len(policies))
|
|
57
|
+
totalPolicies += len(policies)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Save cache
|
|
61
|
+
if err := cache.Save(); err != nil {
|
|
62
|
+
return fmt.Errorf("failed to save cache: %w", err)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
fmt.Printf("\nSync complete! Total: %d policies from %d file(s)\n", totalPolicies, len(instructionFiles))
|
|
66
|
+
return nil
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// FindInstructionFiles searches for instruction files in the git repository
|
|
70
|
+
// Looks for: AGENTS.md, POLICIES.md, and .md files in .tanagram/policies/
|
|
71
|
+
func FindInstructionFiles(gitRoot string) ([]string, error) {
|
|
72
|
+
var files []string
|
|
73
|
+
|
|
74
|
+
// Common instruction file names to look for
|
|
75
|
+
commonNames := []string{"AGENTS.md", "POLICIES.md"}
|
|
76
|
+
|
|
77
|
+
// Directories to skip
|
|
78
|
+
skipDirs := map[string]bool{
|
|
79
|
+
".git": true,
|
|
80
|
+
"node_modules": true,
|
|
81
|
+
"vendor": true,
|
|
82
|
+
".venv": true,
|
|
83
|
+
"venv": true,
|
|
84
|
+
"__pycache__": true,
|
|
85
|
+
".pytest_cache": true,
|
|
86
|
+
".mypy_cache": true,
|
|
87
|
+
"dist": true,
|
|
88
|
+
"build": true,
|
|
89
|
+
".tanagram": true,
|
|
90
|
+
".conductor": true, // Skip conductor directories
|
|
91
|
+
"repos": true, // Skip cloned repos
|
|
92
|
+
"monorepo_clones": true, // Skip cloned repos
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Search from git root down
|
|
96
|
+
err := filepath.Walk(gitRoot, func(path string, info os.FileInfo, err error) error {
|
|
97
|
+
if err != nil {
|
|
98
|
+
return err
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Skip common directories
|
|
102
|
+
if info.IsDir() && skipDirs[info.Name()] {
|
|
103
|
+
return filepath.SkipDir
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Check if this is one of our instruction files
|
|
107
|
+
if !info.IsDir() {
|
|
108
|
+
for _, name := range commonNames {
|
|
109
|
+
if info.Name() == name {
|
|
110
|
+
files = append(files, path)
|
|
111
|
+
break
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return nil
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
if err != nil {
|
|
120
|
+
return nil, fmt.Errorf("failed to search for instruction files: %w", err)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return files, nil
|
|
124
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
package extractor
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"fmt"
|
|
7
|
+
|
|
8
|
+
"github.com/tanagram/cli/llm"
|
|
9
|
+
"github.com/tanagram/cli/parser"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
// ExtractedPolicy represents a policy extracted by the LLM
|
|
13
|
+
// Simplified: No types or patterns, all enforcement is LLM-based
|
|
14
|
+
type ExtractedPolicy struct {
|
|
15
|
+
Name string `json:"name"`
|
|
16
|
+
Message string `json:"message"`
|
|
17
|
+
OriginalText string `json:"original_text"`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ExtractorResponse represents the JSON response from the LLM
|
|
21
|
+
type ExtractorResponse struct {
|
|
22
|
+
Policies []ExtractedPolicy `json:"policies"`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ExtractPolicies uses LLM to extract policies from instruction file content
|
|
26
|
+
func ExtractPolicies(ctx context.Context, content string) ([]parser.Policy, error) {
|
|
27
|
+
client, err := llm.NewClient()
|
|
28
|
+
if err != nil {
|
|
29
|
+
return nil, err
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
prompt := buildExtractionPrompt(content)
|
|
33
|
+
|
|
34
|
+
response, err := client.SendMessage(ctx, prompt)
|
|
35
|
+
if err != nil {
|
|
36
|
+
return nil, fmt.Errorf("failed to extract policies: %w", err)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Strip markdown code blocks if present
|
|
40
|
+
cleanedResponse := llm.StripMarkdownCodeBlocks(response)
|
|
41
|
+
|
|
42
|
+
// Parse JSON response
|
|
43
|
+
var extractorResponse ExtractorResponse
|
|
44
|
+
if err := json.Unmarshal([]byte(cleanedResponse), &extractorResponse); err != nil {
|
|
45
|
+
return nil, fmt.Errorf("failed to parse LLM response: %w\nResponse: %s", err, response)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Convert to parser.Policy format
|
|
49
|
+
policies := make([]parser.Policy, 0, len(extractorResponse.Policies))
|
|
50
|
+
for _, ep := range extractorResponse.Policies {
|
|
51
|
+
policies = append(policies, parser.Policy{
|
|
52
|
+
Name: ep.Name,
|
|
53
|
+
Message: ep.Message,
|
|
54
|
+
OriginalText: ep.OriginalText,
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return policies, nil
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// buildExtractionPrompt creates the prompt for policy extraction
|
|
62
|
+
func buildExtractionPrompt(content string) string {
|
|
63
|
+
return fmt.Sprintf(`You are a code policy extraction system. Extract ONLY coding policies that apply when developers write and edit code.
|
|
64
|
+
|
|
65
|
+
Analyze the following instruction file and extract policies that define:
|
|
66
|
+
- Code structure and organization rules
|
|
67
|
+
- Coding standards and style guidelines
|
|
68
|
+
- Language-specific patterns and practices
|
|
69
|
+
- File naming and directory conventions
|
|
70
|
+
- Error handling requirements
|
|
71
|
+
- Security practices in code
|
|
72
|
+
- Testing requirements
|
|
73
|
+
- Performance optimization rules
|
|
74
|
+
- Import/dependency management
|
|
75
|
+
|
|
76
|
+
DO NOT extract:
|
|
77
|
+
- Agent behavior instructions (how AI agents should operate)
|
|
78
|
+
- Workflow or process instructions
|
|
79
|
+
- Tool usage instructions
|
|
80
|
+
- Communication style guidelines
|
|
81
|
+
- Project management policies
|
|
82
|
+
- Non-code related guidelines
|
|
83
|
+
|
|
84
|
+
For each code policy, provide:
|
|
85
|
+
1. Name: Short descriptive name (2-5 words)
|
|
86
|
+
2. Message: The full policy text or a clear summary
|
|
87
|
+
3. OriginalText: The exact text from the file
|
|
88
|
+
|
|
89
|
+
Return ONLY valid JSON (no markdown, no backticks, no code fences):
|
|
90
|
+
{
|
|
91
|
+
"policies": [
|
|
92
|
+
{
|
|
93
|
+
"name": "Short policy name",
|
|
94
|
+
"message": "Clear policy description",
|
|
95
|
+
"original_text": "Original text from file"
|
|
96
|
+
}
|
|
97
|
+
]
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
Instruction File Content:
|
|
101
|
+
%s
|
|
102
|
+
|
|
103
|
+
Remember: Return ONLY the JSON object, no other text, no markdown formatting.`, content)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
package extractor
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"fmt"
|
|
6
|
+
"os"
|
|
7
|
+
|
|
8
|
+
"github.com/tanagram/cli/parser"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
// ExtractPoliciesFromFile reads a file and extracts policies using LLM
|
|
12
|
+
func ExtractPoliciesFromFile(ctx context.Context, filepath string) ([]parser.Policy, error) {
|
|
13
|
+
content, err := os.ReadFile(filepath)
|
|
14
|
+
if err != nil {
|
|
15
|
+
return nil, fmt.Errorf("failed to read file: %w", err)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return ExtractPolicies(ctx, string(content))
|
|
19
|
+
}
|
package/go.mod
CHANGED
package/llm/client.go
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
package llm
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"fmt"
|
|
6
|
+
"os"
|
|
7
|
+
|
|
8
|
+
"github.com/anthropics/anthropic-sdk-go"
|
|
9
|
+
"github.com/anthropics/anthropic-sdk-go/option"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
// Client handles communication with the Anthropic API
|
|
13
|
+
type Client struct {
|
|
14
|
+
client *anthropic.Client
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// NewClient creates a new Anthropic API client
|
|
18
|
+
func NewClient() (*Client, error) {
|
|
19
|
+
apiKey := os.Getenv("ANTHROPIC_API_KEY")
|
|
20
|
+
if apiKey == "" {
|
|
21
|
+
return nil, fmt.Errorf("ANTHROPIC_API_KEY environment variable not set. Please set it to use LLM-based policy extraction")
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
client := anthropic.NewClient(
|
|
25
|
+
option.WithAPIKey(apiKey),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
return &Client{
|
|
29
|
+
client: &client,
|
|
30
|
+
}, nil
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// SendMessage sends a message to Claude and returns the response
|
|
34
|
+
func (c *Client) SendMessage(ctx context.Context, prompt string) (string, error) {
|
|
35
|
+
message, err := c.client.Messages.New(ctx, anthropic.MessageNewParams{
|
|
36
|
+
MaxTokens: 8192,
|
|
37
|
+
Messages: []anthropic.MessageParam{
|
|
38
|
+
anthropic.NewUserMessage(anthropic.NewTextBlock(prompt)),
|
|
39
|
+
},
|
|
40
|
+
Model: anthropic.ModelClaudeHaiku4_5_20251001,
|
|
41
|
+
})
|
|
42
|
+
if err != nil {
|
|
43
|
+
return "", fmt.Errorf("failed to send message: %w", err)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Extract text from content blocks
|
|
47
|
+
if len(message.Content) == 0 {
|
|
48
|
+
return "", fmt.Errorf("empty response from API")
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Get text from all content blocks and concatenate
|
|
52
|
+
var result string
|
|
53
|
+
for _, block := range message.Content {
|
|
54
|
+
if block.Text != "" {
|
|
55
|
+
result += block.Text
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if result == "" {
|
|
60
|
+
return "", fmt.Errorf("no text content in response")
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return result, nil
|
|
64
|
+
}
|
package/llm/utils.go
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
package llm
|
|
2
|
+
|
|
3
|
+
import "strings"
|
|
4
|
+
|
|
5
|
+
// StripMarkdownCodeBlocks removes markdown code block markers (```json ... ```) from LLM responses
|
|
6
|
+
// This handles cases where Claude returns JSON wrapped in markdown formatting
|
|
7
|
+
// If multiple code blocks exist, extracts the last one (assumes LLM's final answer)
|
|
8
|
+
func StripMarkdownCodeBlocks(response string) string {
|
|
9
|
+
response = strings.TrimSpace(response)
|
|
10
|
+
|
|
11
|
+
// Find all code blocks and extract the last valid JSON one
|
|
12
|
+
// Pattern: ```json ... ``` or ``` ... ```
|
|
13
|
+
blocks := extractCodeBlocks(response)
|
|
14
|
+
if len(blocks) > 0 {
|
|
15
|
+
// Return the last block (LLM's final answer)
|
|
16
|
+
return blocks[len(blocks)-1]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// No code blocks found, return as-is
|
|
20
|
+
return response
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// extractCodeBlocks finds all markdown code blocks in the response
|
|
24
|
+
func extractCodeBlocks(text string) []string {
|
|
25
|
+
var blocks []string
|
|
26
|
+
lines := strings.Split(text, "\n")
|
|
27
|
+
|
|
28
|
+
var currentBlock strings.Builder
|
|
29
|
+
inBlock := false
|
|
30
|
+
|
|
31
|
+
for _, line := range lines {
|
|
32
|
+
trimmed := strings.TrimSpace(line)
|
|
33
|
+
|
|
34
|
+
// Check if this line is a code block delimiter
|
|
35
|
+
if trimmed == "```json" || trimmed == "```" {
|
|
36
|
+
if inBlock {
|
|
37
|
+
// Closing a block
|
|
38
|
+
content := strings.TrimSpace(currentBlock.String())
|
|
39
|
+
if content != "" {
|
|
40
|
+
blocks = append(blocks, content)
|
|
41
|
+
}
|
|
42
|
+
currentBlock.Reset()
|
|
43
|
+
inBlock = false
|
|
44
|
+
} else {
|
|
45
|
+
// Opening a block
|
|
46
|
+
inBlock = true
|
|
47
|
+
}
|
|
48
|
+
} else if inBlock {
|
|
49
|
+
// Inside a block, accumulate content
|
|
50
|
+
if currentBlock.Len() > 0 {
|
|
51
|
+
currentBlock.WriteString("\n")
|
|
52
|
+
}
|
|
53
|
+
currentBlock.WriteString(line)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Handle case where block wasn't closed
|
|
58
|
+
if inBlock && currentBlock.Len() > 0 {
|
|
59
|
+
content := strings.TrimSpace(currentBlock.String())
|
|
60
|
+
if content != "" {
|
|
61
|
+
blocks = append(blocks, content)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return blocks
|
|
66
|
+
}
|
package/main.go
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tanagram/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Tanagram - Catch sloppy code before it ships",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -30,12 +30,19 @@
|
|
|
30
30
|
"files": [
|
|
31
31
|
"bin/",
|
|
32
32
|
"checker/",
|
|
33
|
+
"cli/",
|
|
34
|
+
"commands/",
|
|
35
|
+
"extractor/",
|
|
36
|
+
"fixtures/",
|
|
33
37
|
"git/",
|
|
38
|
+
"llm/",
|
|
34
39
|
"parser/",
|
|
40
|
+
"storage/",
|
|
35
41
|
"main.go",
|
|
36
42
|
"go.mod",
|
|
37
43
|
"go.sum",
|
|
38
44
|
"install.js",
|
|
39
|
-
"README.md"
|
|
45
|
+
"README.md",
|
|
46
|
+
"LICENSE"
|
|
40
47
|
]
|
|
41
48
|
}
|
package/storage/cache.go
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
package storage
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"crypto/md5"
|
|
5
|
+
"encoding/gob"
|
|
6
|
+
"fmt"
|
|
7
|
+
"io"
|
|
8
|
+
"os"
|
|
9
|
+
"path/filepath"
|
|
10
|
+
|
|
11
|
+
"github.com/tanagram/cli/parser"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
const (
|
|
15
|
+
cacheDir = ".tanagram"
|
|
16
|
+
cacheFile = "cache.gob"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
// SerializablePolicy is a version of parser.Policy that can be encoded with gob
|
|
20
|
+
// Simplified: No types or patterns needed for LLM-only detection
|
|
21
|
+
type SerializablePolicy struct {
|
|
22
|
+
Name string
|
|
23
|
+
Message string
|
|
24
|
+
OriginalText string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Cache stores parsed policies and their MD5 hashes
|
|
28
|
+
type Cache struct {
|
|
29
|
+
FileMD5s map[string]string // filepath -> MD5 hash
|
|
30
|
+
Policies map[string][]SerializablePolicy // filepath -> policies
|
|
31
|
+
CachePath string // where the cache file is stored
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// NewCache creates a new empty cache
|
|
35
|
+
func NewCache(cachePath string) *Cache {
|
|
36
|
+
return &Cache{
|
|
37
|
+
FileMD5s: make(map[string]string),
|
|
38
|
+
Policies: make(map[string][]SerializablePolicy),
|
|
39
|
+
CachePath: cachePath,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// LoadCache loads the cache from disk, returns empty cache if not found
|
|
44
|
+
func LoadCache(gitRoot string) (*Cache, error) {
|
|
45
|
+
cachePath := filepath.Join(gitRoot, cacheDir, cacheFile)
|
|
46
|
+
|
|
47
|
+
// If cache doesn't exist, return empty cache
|
|
48
|
+
if _, err := os.Stat(cachePath); os.IsNotExist(err) {
|
|
49
|
+
return NewCache(cachePath), nil
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
file, err := os.Open(cachePath)
|
|
53
|
+
if err != nil {
|
|
54
|
+
return nil, fmt.Errorf("failed to open cache: %w", err)
|
|
55
|
+
}
|
|
56
|
+
defer file.Close()
|
|
57
|
+
|
|
58
|
+
var cache Cache
|
|
59
|
+
decoder := gob.NewDecoder(file)
|
|
60
|
+
if err := decoder.Decode(&cache); err != nil {
|
|
61
|
+
return nil, fmt.Errorf("failed to decode cache: %w", err)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
cache.CachePath = cachePath
|
|
65
|
+
return &cache, nil
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Save writes the cache to disk
|
|
69
|
+
func (c *Cache) Save() error {
|
|
70
|
+
// Create cache directory if it doesn't exist
|
|
71
|
+
dir := filepath.Dir(c.CachePath)
|
|
72
|
+
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
73
|
+
return fmt.Errorf("failed to create cache directory: %w", err)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
file, err := os.Create(c.CachePath)
|
|
77
|
+
if err != nil {
|
|
78
|
+
return fmt.Errorf("failed to create cache file: %w", err)
|
|
79
|
+
}
|
|
80
|
+
defer file.Close()
|
|
81
|
+
|
|
82
|
+
encoder := gob.NewEncoder(file)
|
|
83
|
+
if err := encoder.Encode(c); err != nil {
|
|
84
|
+
return fmt.Errorf("failed to encode cache: %w", err)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return nil
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// CalculateMD5 computes the MD5 hash of a file
|
|
91
|
+
func CalculateMD5(filepath string) (string, error) {
|
|
92
|
+
file, err := os.Open(filepath)
|
|
93
|
+
if err != nil {
|
|
94
|
+
return "", fmt.Errorf("failed to open file: %w", err)
|
|
95
|
+
}
|
|
96
|
+
defer file.Close()
|
|
97
|
+
|
|
98
|
+
hash := md5.New()
|
|
99
|
+
if _, err := io.Copy(hash, file); err != nil {
|
|
100
|
+
return "", fmt.Errorf("failed to hash file: %w", err)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return fmt.Sprintf("%x", hash.Sum(nil)), nil
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// HasChanged checks if a file's MD5 has changed since last sync
|
|
107
|
+
func (c *Cache) HasChanged(filepath string) (bool, error) {
|
|
108
|
+
currentMD5, err := CalculateMD5(filepath)
|
|
109
|
+
if err != nil {
|
|
110
|
+
return false, err
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
cachedMD5, exists := c.FileMD5s[filepath]
|
|
114
|
+
if !exists {
|
|
115
|
+
return true, nil // File not in cache, consider it changed
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return currentMD5 != cachedMD5, nil
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// UpdateFile updates the cache with new policies and MD5 for a file
|
|
122
|
+
func (c *Cache) UpdateFile(filepath string, policies []parser.Policy) error {
|
|
123
|
+
md5Hash, err := CalculateMD5(filepath)
|
|
124
|
+
if err != nil {
|
|
125
|
+
return err
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Convert policies to serializable format
|
|
129
|
+
serializablePolicies := make([]SerializablePolicy, len(policies))
|
|
130
|
+
for i, p := range policies {
|
|
131
|
+
serializablePolicies[i] = SerializablePolicy{
|
|
132
|
+
Name: p.Name,
|
|
133
|
+
Message: p.Message,
|
|
134
|
+
OriginalText: p.OriginalText,
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
c.FileMD5s[filepath] = md5Hash
|
|
139
|
+
c.Policies[filepath] = serializablePolicies
|
|
140
|
+
|
|
141
|
+
return nil
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// GetPolicies retrieves cached policies for a file and converts them back to parser.Policy
|
|
145
|
+
func (c *Cache) GetPolicies(filepath string) ([]parser.Policy, error) {
|
|
146
|
+
serializablePolicies, exists := c.Policies[filepath]
|
|
147
|
+
if !exists {
|
|
148
|
+
return nil, fmt.Errorf("no cached policies for file: %s", filepath)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Convert back to parser.Policy
|
|
152
|
+
policies := make([]parser.Policy, len(serializablePolicies))
|
|
153
|
+
for i, sp := range serializablePolicies {
|
|
154
|
+
policies[i] = parser.Policy{
|
|
155
|
+
Name: sp.Name,
|
|
156
|
+
Message: sp.Message,
|
|
157
|
+
OriginalText: sp.OriginalText,
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return policies, nil
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// GetAllPolicies returns all cached policies from all files combined
|
|
165
|
+
func (c *Cache) GetAllPolicies() ([]parser.Policy, error) {
|
|
166
|
+
var allPolicies []parser.Policy
|
|
167
|
+
|
|
168
|
+
for filepath := range c.Policies {
|
|
169
|
+
policies, err := c.GetPolicies(filepath)
|
|
170
|
+
if err != nil {
|
|
171
|
+
return nil, err
|
|
172
|
+
}
|
|
173
|
+
allPolicies = append(allPolicies, policies...)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return allPolicies, nil
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// FindGitRoot walks up from the current directory to find the git root
|
|
180
|
+
func FindGitRoot() (string, error) {
|
|
181
|
+
dir, err := os.Getwd()
|
|
182
|
+
if err != nil {
|
|
183
|
+
return "", err
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
for {
|
|
187
|
+
gitPath := filepath.Join(dir, ".git")
|
|
188
|
+
if _, err := os.Stat(gitPath); err == nil {
|
|
189
|
+
return dir, nil
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
parent := filepath.Dir(dir)
|
|
193
|
+
if parent == dir {
|
|
194
|
+
return "", fmt.Errorf("not in a git repository")
|
|
195
|
+
}
|
|
196
|
+
dir = parent
|
|
197
|
+
}
|
|
198
|
+
}
|