@tanagram/cli 0.3.1 → 0.4.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/api/client.go ADDED
@@ -0,0 +1,106 @@
1
+ package api
2
+
3
+ import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "io"
7
+ "net/http"
8
+ "os"
9
+ "time"
10
+
11
+ "github.com/tanagram/cli/auth"
12
+ )
13
+
14
+ // TanagramAPIClient handles communication with the Tanagram API
15
+ type TanagramAPIClient struct {
16
+ baseURL string
17
+ jwt string
18
+ client *http.Client
19
+ }
20
+
21
+ // Repository represents a git repository
22
+ type Repository struct {
23
+ ID string `json:"id"`
24
+ Name string `json:"name"`
25
+ Owner string `json:"owner"`
26
+ IsPrivate bool `json:"is_private"`
27
+ }
28
+
29
+ // Policy represents a Tanagram policy
30
+ type Policy struct {
31
+ ID string `json:"id"`
32
+ Name string `json:"name"`
33
+ OrganizationID string `json:"organization_id"`
34
+ Substrate string `json:"substrate"` // "llm" or "tql"
35
+ DescriptionFromUser string `json:"description_from_user"`
36
+ DescriptionRewrittenByLLM string `json:"description_rewritten_by_llm"`
37
+ CustomMessage string `json:"custom_message"`
38
+ EnabledStatus string `json:"enabled_status"`
39
+ CreatedAt time.Time `json:"created_at"`
40
+ UpdatedAt time.Time `json:"updated_at"`
41
+ PolicyRepositories []Repository `json:"policy_repositories"`
42
+ ViolationsCount int `json:"violations_count"`
43
+ }
44
+
45
+ // PolicyListResponse is the response from GET /api/policies/
46
+ type PolicyListResponse struct {
47
+ Policies []Policy `json:"policies"`
48
+ Total int `json:"total"`
49
+ }
50
+
51
+ // getAPIBaseURL returns the API base URL
52
+ func getAPIBaseURL() string {
53
+ if url := os.Getenv("TANAGRAM_API_URL"); url != "" {
54
+ return url
55
+ }
56
+ return "https://api.tanagram.ai"
57
+ }
58
+
59
+ // NewAPIClient creates a new Tanagram API client
60
+ func NewAPIClient() (*TanagramAPIClient, error) {
61
+ jwt, err := auth.GetAccessToken()
62
+ if err != nil {
63
+ return nil, err
64
+ }
65
+
66
+ return &TanagramAPIClient{
67
+ baseURL: getAPIBaseURL(),
68
+ jwt: jwt,
69
+ client: &http.Client{
70
+ Timeout: 30 * time.Second,
71
+ },
72
+ }, nil
73
+ }
74
+
75
+ // GetPolicies fetches all policies from the API
76
+ func (c *TanagramAPIClient) GetPolicies() (*PolicyListResponse, error) {
77
+ req, err := http.NewRequest("GET", c.baseURL+"/api/policies/", nil)
78
+ if err != nil {
79
+ return nil, fmt.Errorf("failed to create request: %w", err)
80
+ }
81
+
82
+ req.Header.Set("Authorization", "Bearer "+c.jwt)
83
+ req.Header.Set("Content-Type", "application/json")
84
+
85
+ resp, err := c.client.Do(req)
86
+ if err != nil {
87
+ return nil, fmt.Errorf("failed to fetch policies: %w", err)
88
+ }
89
+ defer resp.Body.Close()
90
+
91
+ if resp.StatusCode == 401 {
92
+ return nil, fmt.Errorf("authentication failed - your session may have expired. Please run 'tanagram login' again")
93
+ }
94
+
95
+ if resp.StatusCode != 200 {
96
+ body, _ := io.ReadAll(resp.Body)
97
+ return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
98
+ }
99
+
100
+ var result PolicyListResponse
101
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
102
+ return nil, fmt.Errorf("failed to parse response: %w", err)
103
+ }
104
+
105
+ return &result, nil
106
+ }
package/auth/auth.go ADDED
@@ -0,0 +1,26 @@
1
+ package auth
2
+
3
+ import (
4
+ "fmt"
5
+
6
+ "github.com/zalando/go-keyring"
7
+ )
8
+
9
+ const (
10
+ serviceName = "tanagram-cli"
11
+ accessTokenKey = "access-token"
12
+ )
13
+
14
+ // GetAccessToken retrieves the stored access token from the keyring
15
+ func GetAccessToken() (string, error) {
16
+ token, err := keyring.Get(serviceName, accessTokenKey)
17
+ if err != nil {
18
+ return "", fmt.Errorf("not logged in - run 'tanagram login' first: %w", err)
19
+ }
20
+ return token, nil
21
+ }
22
+
23
+ // SetAccessToken stores the access token in the keyring
24
+ func SetAccessToken(token string) error {
25
+ return keyring.Set(serviceName, accessTokenKey, token)
26
+ }
package/commands/list.go CHANGED
@@ -3,10 +3,11 @@ package commands
3
3
  import (
4
4
  "fmt"
5
5
 
6
+ gitpkg "github.com/tanagram/cli/git"
6
7
  "github.com/tanagram/cli/storage"
7
8
  )
8
9
 
9
- // List displays all cached policies
10
+ // List displays all cached policies (both local and cloud)
10
11
  func List() error {
11
12
  // Find git root
12
13
  gitRoot, err := storage.FindGitRoot()
@@ -14,42 +15,97 @@ func List() error {
14
15
  return err
15
16
  }
16
17
 
17
- // Load cache
18
+ // Load local cache
18
19
  cache, err := storage.LoadCache(gitRoot)
19
20
  if err != nil {
20
21
  return fmt.Errorf("failed to load cache: %w", err)
21
22
  }
22
23
 
23
- if len(cache.Policies) == 0 {
24
- fmt.Println("No cached policies found. Run 'tanagram sync' first.")
24
+ // Load local policies from cache
25
+ localPolicies, err := cache.GetAllPolicies()
26
+ if err != nil {
27
+ return fmt.Errorf("failed to load local policies: %w", err)
28
+ }
29
+
30
+ // Try to load cloud policies for current repo
31
+ cloudPolicies := []storage.SimplifiedPolicy{}
32
+ var repoInfo *gitpkg.RepoInfo
33
+
34
+ cloudStorage := storage.NewCloudPolicyStorage(gitRoot)
35
+ repoInfo, err = gitpkg.GetCurrentRepo()
36
+ if err == nil {
37
+ // Successfully detected repo, try to load cloud policies
38
+ repoFile, err := cloudStorage.LoadPoliciesForRepo(repoInfo.Owner, repoInfo.Name)
39
+ if err == nil && repoFile != nil {
40
+ cloudPolicies = repoFile.Policies
41
+ }
42
+ }
43
+
44
+ // Check if we have any policies to display
45
+ if len(localPolicies) == 0 && len(cloudPolicies) == 0 {
46
+ fmt.Println("No policies found.")
47
+ fmt.Println("\nTo get started:")
48
+ fmt.Println(" • Local policies: Run 'tanagram sync' to extract from AGENTS.md, POLICIES.md, etc.")
49
+ fmt.Println(" • Cloud policies: Run 'tanagram sync-policies' to fetch from Tanagram")
25
50
  return nil
26
51
  }
27
52
 
28
- totalPolicies := 0
53
+ // Display local policies
54
+ if len(localPolicies) > 0 {
55
+ fmt.Println("═══════════════════════════════════════════════════════════════")
56
+ fmt.Printf("LOCAL POLICIES (%d policies)\n", len(localPolicies))
57
+ fmt.Println("═══════════════════════════════════════════════════════════════")
58
+ fmt.Println("Source: Local instruction files (AGENTS.md, POLICIES.md, etc.)")
59
+ fmt.Println()
29
60
 
30
- fmt.Println("Cached Policies (All enforced via LLM):")
61
+ // Group by file
62
+ fileGroups := make(map[string][]storage.SerializablePolicy)
63
+ for filepath, serializablePolicies := range cache.Policies {
64
+ if len(serializablePolicies) > 0 {
65
+ fileGroups[filepath] = serializablePolicies
66
+ }
67
+ }
31
68
 
32
- // Display policies grouped by file
33
- for filepath, serializablePolicies := range cache.Policies {
34
- if len(serializablePolicies) == 0 {
35
- continue
69
+ for filepath, serializablePolicies := range fileGroups {
70
+ fmt.Printf("📄 %s (%d policies)\n", filepath, len(serializablePolicies))
71
+ fmt.Println("───────────────────────────────────────────────────────────────")
72
+ for _, sp := range serializablePolicies {
73
+ fmt.Printf(" • %s: %s\n", sp.Name, sp.Message)
74
+ }
75
+ fmt.Println()
36
76
  }
77
+ }
37
78
 
38
- fmt.Printf("📄 %s (%d policies)\n", filepath, len(serializablePolicies))
39
- fmt.Println("─────────────────────────────────────────────────────────────")
79
+ // Display cloud policies
80
+ if len(cloudPolicies) > 0 {
81
+ fmt.Println("═══════════════════════════════════════════════════════════════")
82
+ fmt.Printf("CLOUD POLICIES (%d policies)\n", len(cloudPolicies))
83
+ fmt.Println("═══════════════════════════════════════════════════════════════")
84
+ fmt.Printf("Source: Tanagram (repository: %s/%s)\n", repoInfo.Owner, repoInfo.Name)
85
+ fmt.Println()
40
86
 
41
- for _, sp := range serializablePolicies {
42
- totalPolicies++
43
- fmt.Printf(" • %s: %s\n", sp.Name, sp.Message)
87
+ for _, cp := range cloudPolicies {
88
+ status := "✓ enabled"
89
+ if !cp.Enabled {
90
+ status = "✗ disabled"
91
+ }
92
+ fmt.Printf(" • %s [%s]\n", cp.Name, status)
93
+ if cp.Message != "" {
94
+ fmt.Printf(" Message: %s\n", cp.Message)
95
+ }
44
96
  }
45
97
  fmt.Println()
46
98
  }
47
99
 
48
100
  // 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")
101
+ fmt.Println("═══════════════════════════════════════════════════════════════")
102
+ fmt.Printf("SUMMARY\n")
103
+ fmt.Println("═══════════════════════════════════════════════════════════════")
104
+ fmt.Printf("Local policies: %d\n", len(localPolicies))
105
+ fmt.Printf("Cloud policies: %d\n", len(cloudPolicies))
106
+ fmt.Printf("Total enforced: %d\n", len(localPolicies)+len(cloudPolicies))
107
+ fmt.Println("\nNote: Cloud policies take precedence over local policies with the same name")
108
+ fmt.Println("All policies are enforced using LLM-based semantic analysis")
53
109
 
54
110
  return nil
55
111
  }
@@ -0,0 +1,159 @@
1
+ package commands
2
+
3
+ import (
4
+ "context"
5
+ "crypto/rand"
6
+ "crypto/sha256"
7
+ "encoding/base64"
8
+ "fmt"
9
+ "net"
10
+ "net/http"
11
+ "net/url"
12
+ "os"
13
+ "time"
14
+
15
+ "github.com/pkg/browser"
16
+ "github.com/tanagram/cli/auth"
17
+ )
18
+
19
+ // getWebAppURL returns the web app URL based on environment
20
+ func getWebAppURL() string {
21
+ // Check environment variable first
22
+ if url := os.Getenv("TANAGRAM_WEB_URL"); url != "" {
23
+ return url
24
+ }
25
+
26
+ // Default to production
27
+ return "https://web.tanagram.ai"
28
+ }
29
+
30
+ // generateCodeVerifier generates a random code verifier for PKCE
31
+ func generateCodeVerifier() (string, error) {
32
+ // Generate 32 random bytes (256 bits)
33
+ b := make([]byte, 32)
34
+ if _, err := rand.Read(b); err != nil {
35
+ return "", err
36
+ }
37
+ // Base64 URL encode without padding
38
+ return base64.RawURLEncoding.EncodeToString(b), nil
39
+ }
40
+
41
+ // generateCodeChallenge generates the code challenge from the verifier
42
+ func generateCodeChallenge(verifier string) string {
43
+ hash := sha256.Sum256([]byte(verifier))
44
+ return base64.RawURLEncoding.EncodeToString(hash[:])
45
+ }
46
+
47
+ // Login implements the OAuth PKCE flow for CLI authentication
48
+ func Login() error {
49
+ // Generate PKCE parameters
50
+ codeVerifier, err := generateCodeVerifier()
51
+ if err != nil {
52
+ return fmt.Errorf("failed to generate code verifier: %w", err)
53
+ }
54
+ codeChallenge := generateCodeChallenge(codeVerifier)
55
+
56
+ // Create a channel to receive the authorization code
57
+ codeChan := make(chan string, 1)
58
+ errChan := make(chan error, 1)
59
+
60
+ // Start local HTTP server for callback on a dynamic port
61
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
62
+ if err != nil {
63
+ return fmt.Errorf("failed to start callback server: %w", err)
64
+ }
65
+
66
+ // Get the actual port assigned
67
+ port := listener.Addr().(*net.TCPAddr).Port
68
+ redirectURI := fmt.Sprintf("http://127.0.0.1:%d/callback", port)
69
+
70
+ // Setup callback handler
71
+ mux := http.NewServeMux()
72
+ mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
73
+ code := r.URL.Query().Get("code")
74
+ if code == "" {
75
+ errChan <- fmt.Errorf("no authorization code received")
76
+ http.Error(w, "Authorization failed", http.StatusBadRequest)
77
+ return
78
+ }
79
+
80
+ // Send success message to browser
81
+ w.Header().Set("Content-Type", "text/html")
82
+ fmt.Fprintf(w, `
83
+ <!DOCTYPE html>
84
+ <html>
85
+ <head><title>Tanagram Login</title></head>
86
+ <body style="font-family: system-ui; text-align: center; padding: 50px;">
87
+ <h1>✓ Login Successful</h1>
88
+ <p>You can close this window and return to your terminal.</p>
89
+ </body>
90
+ </html>
91
+ `)
92
+
93
+ codeChan <- code
94
+ })
95
+
96
+ // Start the HTTP server
97
+ server := &http.Server{Handler: mux}
98
+ go func() {
99
+ if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
100
+ errChan <- fmt.Errorf("callback server error: %w", err)
101
+ }
102
+ }()
103
+
104
+ // Construct authorization URL pointing to web app's CLI authorization page
105
+ authURL := fmt.Sprintf("%s/cli-authorize?%s", getWebAppURL(), url.Values{
106
+ "redirect_uri": {redirectURI},
107
+ "code_challenge": {codeChallenge},
108
+ "code_challenge_method": {"S256"},
109
+ }.Encode())
110
+
111
+ fmt.Println("Opening browser for authentication...")
112
+ fmt.Printf("If the browser doesn't open automatically, visit:\n%s\n\n", authURL)
113
+
114
+ // Open browser
115
+ if err := browser.OpenURL(authURL); err != nil {
116
+ fmt.Printf("Warning: Could not open browser automatically: %v\n", err)
117
+ }
118
+
119
+ fmt.Println("Waiting for authentication...")
120
+
121
+ // Wait for callback or timeout
122
+ var authCode string
123
+ select {
124
+ case authCode = <-codeChan:
125
+ // Success
126
+ case err := <-errChan:
127
+ return err
128
+ case <-time.After(5 * time.Minute):
129
+ return fmt.Errorf("authentication timeout - please try again")
130
+ }
131
+
132
+ // Shutdown the callback server
133
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
134
+ defer cancel()
135
+ server.Shutdown(ctx)
136
+
137
+ // The authorization code is actually the session JWT from the web app
138
+ // No need to exchange - we can use it directly
139
+ sessionJWT := authCode
140
+
141
+ // Verify token is valid
142
+ if sessionJWT == "" {
143
+ return fmt.Errorf("received empty session token")
144
+ }
145
+
146
+ // Store session JWT in OS keyring
147
+ fmt.Println("Storing session token securely...")
148
+
149
+ if err := auth.SetAccessToken(sessionJWT); err != nil {
150
+ return fmt.Errorf("failed to store session token: %w", err)
151
+ }
152
+
153
+ fmt.Println("\n✓ Successfully logged in to Tanagram")
154
+ fmt.Printf("Session token stored securely in OS keyring\n")
155
+
156
+ return nil
157
+ }
158
+
159
+ // Removed exchangeCodeForTokens - we receive the session JWT directly from the web app
package/commands/run.go CHANGED
@@ -11,7 +11,7 @@ import (
11
11
  "github.com/tanagram/cli/checker"
12
12
  "github.com/tanagram/cli/config"
13
13
  "github.com/tanagram/cli/extractor"
14
- "github.com/tanagram/cli/git"
14
+ gitpkg "github.com/tanagram/cli/git"
15
15
  "github.com/tanagram/cli/metrics"
16
16
  "github.com/tanagram/cli/parser"
17
17
  "github.com/tanagram/cli/snapshot"
@@ -186,21 +186,50 @@ func Run() error {
186
186
  })
187
187
  }
188
188
 
189
- // Load all policies from cache
190
- policies, err := cache.GetAllPolicies()
189
+ // Load local policies from cache
190
+ localPolicies, err := cache.GetAllPolicies()
191
191
  if err != nil {
192
192
  return fmt.Errorf("failed to load policies from cache: %w", err)
193
193
  }
194
194
 
195
+ // Try to load cloud policies for current repo
196
+ cloudPolicies := []parser.Policy{}
197
+ cloudStorage := storage.NewCloudPolicyStorage(gitRoot)
198
+
199
+ repoInfo, err := gitpkg.GetCurrentRepo()
200
+ if err == nil {
201
+ // Successfully detected repo, try to load cloud policies
202
+ cloudPolicies, err = cloudStorage.LoadCloudPoliciesAsParserFormat(repoInfo.Owner, repoInfo.Name)
203
+ if err != nil {
204
+ // Cloud policies exist but failed to load - warn but continue
205
+ fmt.Printf("Warning: Failed to load cloud policies: %v\n", err)
206
+ cloudPolicies = []parser.Policy{}
207
+ } else if len(cloudPolicies) > 0 {
208
+ fmt.Printf("Loaded %d cloud policies for %s/%s\n", len(cloudPolicies), repoInfo.Owner, repoInfo.Name)
209
+ }
210
+ }
211
+ // If repo detection failed, silently continue with local-only policies
212
+
213
+ // Merge local and cloud policies (cloud takes precedence)
214
+ policies := storage.MergePolicies(localPolicies, cloudPolicies)
215
+
195
216
  if len(policies) == 0 {
196
217
  fmt.Println("No enforceable policies found")
197
218
  return nil
198
219
  }
199
220
 
200
- fmt.Printf("Loaded %d policies\n", len(policies))
221
+ totalLocal := len(localPolicies)
222
+ totalCloud := len(cloudPolicies)
223
+ totalMerged := len(policies)
224
+
225
+ if totalCloud > 0 {
226
+ fmt.Printf("Total policies: %d (%d local, %d cloud, %d after merge)\n", totalMerged, totalLocal, totalCloud, totalMerged)
227
+ } else {
228
+ fmt.Printf("Loaded %d local policies\n", totalLocal)
229
+ }
201
230
 
202
231
  // Check if a snapshot exists (from PreToolUse hook)
203
- var changesToCheck []git.ChangedLine
232
+ var changesToCheck []gitpkg.ChangedLine
204
233
  useSnapshot := false
205
234
 
206
235
  if snapshot.Exists(gitRoot) {
@@ -226,7 +255,7 @@ func Run() error {
226
255
 
227
256
  // Convert snapshot.ChangedLine to git.ChangedLine
228
257
  for _, sc := range snapshotChanges {
229
- changesToCheck = append(changesToCheck, git.ChangedLine{
258
+ changesToCheck = append(changesToCheck, gitpkg.ChangedLine{
230
259
  File: sc.File,
231
260
  LineNumber: sc.LineNumber,
232
261
  Content: sc.Content,
@@ -243,7 +272,7 @@ func Run() error {
243
272
  } else {
244
273
  // No snapshot - fall back to checking all git changes
245
274
  fmt.Println("Checking all changes (unstaged + staged)...")
246
- diffResult, err := git.GetAllChanges()
275
+ diffResult, err := gitpkg.GetAllChanges()
247
276
  if err != nil {
248
277
  return fmt.Errorf("error getting git diff: %w", err)
249
278
  }
@@ -0,0 +1,88 @@
1
+ package commands
2
+
3
+ import (
4
+ "fmt"
5
+
6
+ "github.com/tanagram/cli/api"
7
+ "github.com/tanagram/cli/storage"
8
+ )
9
+
10
+ // SyncPolicies fetches policies from Tanagram API and saves them locally
11
+ func SyncPolicies() error {
12
+ // Find git root
13
+ gitRoot, err := storage.FindGitRoot()
14
+ if err != nil {
15
+ return fmt.Errorf("not in a git repository: %w", err)
16
+ }
17
+
18
+ fmt.Println("Syncing policies from Tanagram...")
19
+
20
+ // Create API client
21
+ client, err := api.NewAPIClient()
22
+ if err != nil {
23
+ return err
24
+ }
25
+
26
+ // Fetch policies from API
27
+ fmt.Println("Fetching policies from API...")
28
+ response, err := client.GetPolicies()
29
+ if err != nil {
30
+ return err
31
+ }
32
+
33
+ if len(response.Policies) == 0 {
34
+ fmt.Println("No policies found for your organization")
35
+ return nil
36
+ }
37
+
38
+ fmt.Printf("Found %d policies\n", response.Total)
39
+
40
+ // Group policies by repository
41
+ repoMap := make(map[string][]api.Policy)
42
+ for _, policy := range response.Policies {
43
+ for _, repo := range policy.PolicyRepositories {
44
+ key := repo.Owner + "/" + repo.Name
45
+ repoMap[key] = append(repoMap[key], policy)
46
+ }
47
+ }
48
+
49
+ fmt.Printf("Policies apply to %d repositories\n", len(repoMap))
50
+
51
+ // Save policies for each repository
52
+ cloudStorage := storage.NewCloudPolicyStorage(gitRoot)
53
+ savedCount := 0
54
+
55
+ for repoKey, policies := range repoMap {
56
+ // Extract owner and repo name
57
+ owner := policies[0].PolicyRepositories[0].Owner
58
+ repo := policies[0].PolicyRepositories[0].Name
59
+
60
+ err := cloudStorage.SavePoliciesForRepo(owner, repo, policies)
61
+ if err != nil {
62
+ fmt.Printf("Warning: Failed to save policies for %s: %v\n", repoKey, err)
63
+ continue
64
+ }
65
+
66
+ savedCount++
67
+ fmt.Printf(" ✓ Saved %d policies for %s\n", len(policies), repoKey)
68
+ }
69
+
70
+ // Save metadata
71
+ orgID := ""
72
+ orgName := ""
73
+ if len(response.Policies) > 0 {
74
+ orgID = response.Policies[0].OrganizationID
75
+ // Organization name not in API response, use ID for now
76
+ orgName = orgID
77
+ }
78
+
79
+ err = cloudStorage.SaveMetadata(orgID, orgName, response.Total, len(repoMap))
80
+ if err != nil {
81
+ return fmt.Errorf("failed to save metadata: %w", err)
82
+ }
83
+
84
+ fmt.Printf("\n✓ Successfully synced %d policies across %d repositories\n", response.Total, savedCount)
85
+ fmt.Printf("Policies saved to: %s\n", cloudStorage.GetPoliciesDir())
86
+
87
+ return nil
88
+ }
package/git/repo.go ADDED
@@ -0,0 +1,54 @@
1
+ package git
2
+
3
+ import (
4
+ "fmt"
5
+ "os/exec"
6
+ "regexp"
7
+ "strings"
8
+ )
9
+
10
+ // RepoInfo contains repository identification
11
+ type RepoInfo struct {
12
+ Owner string
13
+ Name string
14
+ }
15
+
16
+ // GetCurrentRepo detects the current repository from git remote
17
+ func GetCurrentRepo() (*RepoInfo, error) {
18
+ // Get git remote URL
19
+ cmd := exec.Command("git", "remote", "get-url", "origin")
20
+ output, err := cmd.Output()
21
+ if err != nil {
22
+ return nil, fmt.Errorf("failed to get git remote: %w (not in a git repository?)", err)
23
+ }
24
+
25
+ remoteURL := strings.TrimSpace(string(output))
26
+
27
+ // Parse owner and repo name from various URL formats
28
+ // Examples:
29
+ // - git@github.com:owner/repo.git
30
+ // - https://github.com/owner/repo.git
31
+ // - https://github.com/owner/repo
32
+ // - https://username@github.com/owner/repo.git
33
+ // - https://username:password@github.com/owner/repo.git
34
+
35
+ // Try SSH format first
36
+ sshRegex := regexp.MustCompile(`git@[\w.-]+:([\w-]+)/([\w.-]+?)(\.git)?$`)
37
+ if matches := sshRegex.FindStringSubmatch(remoteURL); matches != nil {
38
+ return &RepoInfo{
39
+ Owner: matches[1],
40
+ Name: strings.TrimSuffix(matches[2], ".git"),
41
+ }, nil
42
+ }
43
+
44
+ // Try HTTPS format (with optional username:password@ prefix)
45
+ httpsRegex := regexp.MustCompile(`https?://(?:[\w.-]+@)?[\w.-]+/([\w-]+)/([\w.-]+?)(\.git)?$`)
46
+ if matches := httpsRegex.FindStringSubmatch(remoteURL); matches != nil {
47
+ return &RepoInfo{
48
+ Owner: matches[1],
49
+ Name: strings.TrimSuffix(matches[2], ".git"),
50
+ }, nil
51
+ }
52
+
53
+ return nil, fmt.Errorf("could not parse git remote URL: %s", remoteURL)
54
+ }
package/go.mod CHANGED
@@ -11,13 +11,16 @@ require (
11
11
  )
12
12
 
13
13
  require (
14
+ al.essio.dev/pkg/shellescape v1.5.1 // indirect
14
15
  github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
15
16
  github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
16
17
  github.com/charmbracelet/x/ansi v0.10.1 // indirect
17
18
  github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
18
19
  github.com/charmbracelet/x/term v0.2.1 // indirect
20
+ github.com/danieljoos/wincred v1.2.2 // indirect
19
21
  github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
20
22
  github.com/go-ole/go-ole v1.2.6 // indirect
23
+ github.com/godbus/dbus/v5 v5.1.0 // indirect
21
24
  github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
22
25
  github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
23
26
  github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
@@ -27,6 +30,7 @@ require (
27
30
  github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
28
31
  github.com/muesli/cancelreader v0.2.2 // indirect
29
32
  github.com/muesli/termenv v0.16.0 // indirect
33
+ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
30
34
  github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
31
35
  github.com/rivo/uniseg v0.4.7 // indirect
32
36
  github.com/shoenig/go-m1cpu v0.1.6 // indirect
@@ -38,6 +42,7 @@ require (
38
42
  github.com/tklauser/numcpus v0.6.1 // indirect
39
43
  github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
40
44
  github.com/yusufpapurcu/wmi v1.2.4 // indirect
45
+ github.com/zalando/go-keyring v0.2.6 // indirect
41
46
  golang.org/x/sys v0.36.0 // indirect
42
47
  golang.org/x/text v0.27.0 // indirect
43
48
  )
package/go.sum CHANGED
@@ -1,3 +1,5 @@
1
+ al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho=
2
+ al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890=
1
3
  github.com/anthropics/anthropic-sdk-go v1.17.0 h1:BwK8ApcmaAUkvZTiQE0yi3R9XneEFskDIjLTmOAFZxQ=
2
4
  github.com/anthropics/anthropic-sdk-go v1.17.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
3
5
  github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
@@ -14,12 +16,16 @@ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0G
14
16
  github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
15
17
  github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
16
18
  github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
19
+ github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0=
20
+ github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8=
17
21
  github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
18
22
  github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
19
23
  github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
20
24
  github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
21
25
  github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
22
26
  github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
27
+ github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
28
+ github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
23
29
  github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
24
30
  github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
25
31
  github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
@@ -41,6 +47,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
41
47
  github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
42
48
  github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
43
49
  github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
50
+ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
51
+ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
44
52
  github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
45
53
  github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
46
54
  github.com/posthog/posthog-go v1.6.12 h1:rsOBL/YdMfLJtOVjKJLgdzYmvaL3aIW6IVbAteSe+aI=
@@ -76,11 +84,14 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
76
84
  github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
77
85
  github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
78
86
  github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
87
+ github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s=
88
+ github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI=
79
89
  golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
80
90
  golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
81
91
  golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
82
92
  golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
83
93
  golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
94
+ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
84
95
  golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
85
96
  golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
86
97
  golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
package/main.go CHANGED
@@ -125,6 +125,16 @@ func main() {
125
125
  fmt.Println("\nNo selection made")
126
126
  }
127
127
  return
128
+ case "login":
129
+ metrics.Track("cli.command.execute", map[string]interface{}{
130
+ "command": "login",
131
+ })
132
+ err = commands.Login()
133
+ case "sync-policies":
134
+ metrics.Track("cli.command.execute", map[string]interface{}{
135
+ "command": "sync-policies",
136
+ })
137
+ err = commands.SyncPolicies()
128
138
  case "puzzle":
129
139
  metrics.Track("cli.command.execute", map[string]interface{}{
130
140
  "command": "puzzle",
@@ -168,6 +178,11 @@ USAGE:
168
178
 
169
179
  COMMANDS:
170
180
  run Check git changes against policies (default)
181
+ login Authenticate with Tanagram using Stytch B2B
182
+ <<<<<<< HEAD
183
+ sync-policies Sync cloud policies from Tanagram
184
+ =======
185
+ >>>>>>> origin/main
171
186
  snapshot Create a snapshot of current file state (used by PreToolUse hook)
172
187
  sync Manually sync instruction files to cache
173
188
  list Show all cached policies
@@ -179,8 +194,13 @@ COMMANDS:
179
194
  EXAMPLES:
180
195
  tanagram # Check changes (auto-syncs if files changed)
181
196
  tanagram run # Same as above
197
+ tanagram login # Authenticate with Tanagram
198
+ <<<<<<< HEAD
199
+ tanagram sync-policies # Sync cloud policies from Tanagram
200
+ =======
201
+ >>>>>>> origin/main
182
202
  tanagram snapshot # Create snapshot before making changes
183
- tanagram sync # Manually sync policies
203
+ tanagram sync # Manually sync local instruction files
184
204
  tanagram list # View all cached policies
185
205
  tanagram config claude # Setup Claude Code hooks in ~/.claude/settings.json
186
206
  tanagram config list # Show where hooks are installed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanagram/cli",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "Tanagram - Catch sloppy code before it ships",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -33,6 +33,8 @@
33
33
  },
34
34
  "files": [
35
35
  "bin/tanagram.js",
36
+ "api/",
37
+ "auth/",
36
38
  "checker/",
37
39
  "commands/",
38
40
  "config/",
@@ -45,6 +47,7 @@
45
47
  "snapshot/",
46
48
  "storage/",
47
49
  "tui/",
50
+ "utils/",
48
51
  "main.go",
49
52
  "go.mod",
50
53
  "go.sum",
@@ -0,0 +1,258 @@
1
+ package storage
2
+
3
+ import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "os"
7
+ "path/filepath"
8
+ "time"
9
+
10
+ "github.com/tanagram/cli/api"
11
+ "github.com/tanagram/cli/parser"
12
+ )
13
+
14
+ // RepoPolicyFile represents policies for a specific repository
15
+ type RepoPolicyFile struct {
16
+ Repository struct {
17
+ ID string `json:"id"`
18
+ Name string `json:"name"`
19
+ Owner string `json:"owner"`
20
+ IsPrivate bool `json:"is_private"`
21
+ } `json:"repository"`
22
+ Policies []SimplifiedPolicy `json:"policies"`
23
+ LastSync time.Time `json:"last_sync"`
24
+ }
25
+
26
+ // SimplifiedPolicy is a policy optimized for CLI storage
27
+ type SimplifiedPolicy struct {
28
+ ID string `json:"id"`
29
+ Name string `json:"name"`
30
+ Substrate string `json:"substrate"`
31
+ Description string `json:"description"`
32
+ OriginalDescription string `json:"original_description"`
33
+ Enabled bool `json:"enabled"`
34
+ Message string `json:"message"`
35
+ SyncedAt time.Time `json:"synced_at"`
36
+ }
37
+
38
+ // PolicyMetadata tracks overall sync state
39
+ type PolicyMetadata struct {
40
+ OrganizationID string `json:"organization_id"`
41
+ OrganizationName string `json:"organization_name"`
42
+ LastSync time.Time `json:"last_sync"`
43
+ TotalPolicies int `json:"total_policies"`
44
+ TotalRepos int `json:"total_repos"`
45
+ SyncMode string `json:"sync_mode"` // "auto" or "manual"
46
+ }
47
+
48
+ // CloudPolicyStorage handles cloud policy storage
49
+ type CloudPolicyStorage struct {
50
+ gitRoot string // Not used for path, kept for compatibility
51
+ }
52
+
53
+ // NewCloudPolicyStorage creates a new cloud policy storage instance
54
+ func NewCloudPolicyStorage(gitRoot string) *CloudPolicyStorage {
55
+ return &CloudPolicyStorage{gitRoot: gitRoot}
56
+ }
57
+
58
+ // GetPoliciesDir returns the cloud policies directory path (global, in user's home directory)
59
+ func (s *CloudPolicyStorage) GetPoliciesDir() string {
60
+ homeDir, err := os.UserHomeDir()
61
+ if err != nil {
62
+ // Fallback to current directory if home dir can't be determined
63
+ return filepath.Join(".tanagram", "policies")
64
+ }
65
+ return filepath.Join(homeDir, ".tanagram", "policies")
66
+ }
67
+
68
+ // SavePoliciesForRepo saves policies for a specific repository
69
+ func (s *CloudPolicyStorage) SavePoliciesForRepo(owner, repo string, policies []api.Policy) error {
70
+ // Create directory structure
71
+ repoDir := filepath.Join(s.GetPoliciesDir(), owner)
72
+ if err := os.MkdirAll(repoDir, 0755); err != nil {
73
+ return fmt.Errorf("failed to create directory: %w", err)
74
+ }
75
+
76
+ // Filter policies for this repo and convert to simplified format
77
+ var repoPolicies []SimplifiedPolicy
78
+ var repoInfo api.Repository
79
+
80
+ for _, policy := range policies {
81
+ // Check if this policy applies to this repo
82
+ for _, policyRepo := range policy.PolicyRepositories {
83
+ if policyRepo.Owner == owner && policyRepo.Name == repo {
84
+ repoInfo = policyRepo
85
+ repoPolicies = append(repoPolicies, SimplifiedPolicy{
86
+ ID: policy.ID,
87
+ Name: policy.Name,
88
+ Substrate: policy.Substrate,
89
+ Description: policy.DescriptionRewrittenByLLM,
90
+ OriginalDescription: policy.DescriptionFromUser,
91
+ Enabled: policy.EnabledStatus == "enabled",
92
+ Message: policy.CustomMessage,
93
+ SyncedAt: time.Now(),
94
+ })
95
+ break
96
+ }
97
+ }
98
+ }
99
+
100
+ // Create repo policy file
101
+ repoFile := RepoPolicyFile{
102
+ Policies: repoPolicies,
103
+ LastSync: time.Now(),
104
+ }
105
+ repoFile.Repository.ID = repoInfo.ID
106
+ repoFile.Repository.Name = repo
107
+ repoFile.Repository.Owner = owner
108
+ repoFile.Repository.IsPrivate = repoInfo.IsPrivate
109
+
110
+ // Save to file
111
+ filePath := filepath.Join(repoDir, repo+".json")
112
+ data, err := json.MarshalIndent(repoFile, "", " ")
113
+ if err != nil {
114
+ return fmt.Errorf("failed to marshal policies: %w", err)
115
+ }
116
+
117
+ if err := os.WriteFile(filePath, data, 0644); err != nil {
118
+ return fmt.Errorf("failed to write policy file: %w", err)
119
+ }
120
+
121
+ return nil
122
+ }
123
+
124
+ // SaveMetadata saves sync metadata
125
+ func (s *CloudPolicyStorage) SaveMetadata(orgID, orgName string, totalPolicies, totalRepos int) error {
126
+ metadata := PolicyMetadata{
127
+ OrganizationID: orgID,
128
+ OrganizationName: orgName,
129
+ LastSync: time.Now(),
130
+ TotalPolicies: totalPolicies,
131
+ TotalRepos: totalRepos,
132
+ SyncMode: "manual",
133
+ }
134
+
135
+ metadataPath := filepath.Join(s.GetPoliciesDir(), "metadata.json")
136
+ data, err := json.MarshalIndent(metadata, "", " ")
137
+ if err != nil {
138
+ return fmt.Errorf("failed to marshal metadata: %w", err)
139
+ }
140
+
141
+ // Ensure directory exists
142
+ if err := os.MkdirAll(s.GetPoliciesDir(), 0755); err != nil {
143
+ return fmt.Errorf("failed to create policies directory: %w", err)
144
+ }
145
+
146
+ if err := os.WriteFile(metadataPath, data, 0644); err != nil {
147
+ return fmt.Errorf("failed to write metadata: %w", err)
148
+ }
149
+
150
+ return nil
151
+ }
152
+
153
+ // LoadPoliciesForRepo loads policies for a specific repository
154
+ func (s *CloudPolicyStorage) LoadPoliciesForRepo(owner, repo string) (*RepoPolicyFile, error) {
155
+ filePath := filepath.Join(s.GetPoliciesDir(), owner, repo+".json")
156
+
157
+ data, err := os.ReadFile(filePath)
158
+ if err != nil {
159
+ if os.IsNotExist(err) {
160
+ return nil, nil // No cloud policies for this repo
161
+ }
162
+ return nil, fmt.Errorf("failed to read policy file: %w", err)
163
+ }
164
+
165
+ var repoFile RepoPolicyFile
166
+ if err := json.Unmarshal(data, &repoFile); err != nil {
167
+ return nil, fmt.Errorf("failed to parse policy file: %w", err)
168
+ }
169
+
170
+ return &repoFile, nil
171
+ }
172
+
173
+ // LoadMetadata loads sync metadata
174
+ func (s *CloudPolicyStorage) LoadMetadata() (*PolicyMetadata, error) {
175
+ metadataPath := filepath.Join(s.GetPoliciesDir(), "metadata.json")
176
+
177
+ data, err := os.ReadFile(metadataPath)
178
+ if err != nil {
179
+ if os.IsNotExist(err) {
180
+ return nil, nil
181
+ }
182
+ return nil, fmt.Errorf("failed to read metadata: %w", err)
183
+ }
184
+
185
+ var metadata PolicyMetadata
186
+ if err := json.Unmarshal(data, &metadata); err != nil {
187
+ return nil, fmt.Errorf("failed to parse metadata: %w", err)
188
+ }
189
+
190
+ return &metadata, nil
191
+ }
192
+
193
+ // GetCacheAge returns how old the cache is
194
+ func (s *CloudPolicyStorage) GetCacheAge() (time.Duration, error) {
195
+ metadata, err := s.LoadMetadata()
196
+ if err != nil || metadata == nil {
197
+ return 0, fmt.Errorf("no cache found")
198
+ }
199
+
200
+ return time.Since(metadata.LastSync), nil
201
+ }
202
+
203
+ // LoadCloudPoliciesAsParserFormat loads cloud policies for a repo in parser.Policy format
204
+ func (s *CloudPolicyStorage) LoadCloudPoliciesAsParserFormat(owner, repo string) ([]parser.Policy, error) {
205
+ repoFile, err := s.LoadPoliciesForRepo(owner, repo)
206
+ if err != nil {
207
+ return nil, err
208
+ }
209
+
210
+ if repoFile == nil {
211
+ // No cloud policies for this repo
212
+ return []parser.Policy{}, nil
213
+ }
214
+
215
+ // Convert SimplifiedPolicy to parser.Policy
216
+ var policies []parser.Policy
217
+ for _, cloudPolicy := range repoFile.Policies {
218
+ if !cloudPolicy.Enabled {
219
+ continue // Skip disabled policies
220
+ }
221
+
222
+ // Use the rewritten description as OriginalText (it's more detailed)
223
+ originalText := cloudPolicy.Description
224
+ if originalText == "" {
225
+ originalText = cloudPolicy.OriginalDescription
226
+ }
227
+
228
+ policies = append(policies, parser.Policy{
229
+ Name: cloudPolicy.Name,
230
+ Message: cloudPolicy.Message,
231
+ OriginalText: originalText,
232
+ })
233
+ }
234
+
235
+ return policies, nil
236
+ }
237
+
238
+ // MergePolicies merges local and cloud policies, with cloud taking precedence
239
+ func MergePolicies(local, cloud []parser.Policy) []parser.Policy {
240
+ // Create map of cloud policy names for quick lookup
241
+ cloudNames := make(map[string]bool)
242
+ for _, p := range cloud {
243
+ cloudNames[p.Name] = true
244
+ }
245
+
246
+ // Start with all cloud policies
247
+ merged := make([]parser.Policy, len(cloud))
248
+ copy(merged, cloud)
249
+
250
+ // Add local policies that don't conflict with cloud
251
+ for _, localPolicy := range local {
252
+ if !cloudNames[localPolicy.Name] {
253
+ merged = append(merged, localPolicy)
254
+ }
255
+ }
256
+
257
+ return merged
258
+ }
@@ -0,0 +1,36 @@
1
+ package utils
2
+
3
+ import (
4
+ "os"
5
+ "sync"
6
+
7
+ "github.com/shirou/gopsutil/v3/process"
8
+ )
9
+
10
+ var (
11
+ parentProcessName string
12
+ parentProcessOnce sync.Once
13
+ )
14
+
15
+ // GetParentProcess returns the name of the parent process.
16
+ // The result is memoized after the first call to avoid repeated system calls.
17
+ func GetParentProcess() string {
18
+ parentProcessOnce.Do(func() {
19
+ parentProcessName = "unknown"
20
+
21
+ parentPID := int32(os.Getppid())
22
+ parent, err := process.NewProcess(parentPID)
23
+ if err != nil {
24
+ return
25
+ }
26
+
27
+ name, err := parent.Name()
28
+ if err != nil {
29
+ return
30
+ }
31
+
32
+ parentProcessName = name
33
+ })
34
+
35
+ return parentProcessName
36
+ }