diff --git a/.gitignore b/.gitignore index 326ff9b..37bc077 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,7 @@ coverage.txt # Symphony API key configuration .sym/.env +.vscode +.cursor +sym-cli.exe +.mcp.json diff --git a/internal/cmd/api_key.go b/internal/cmd/api_key.go index 6fda694..43fb924 100644 --- a/internal/cmd/api_key.go +++ b/internal/cmd/api_key.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strings" + "github.com/DevSymphony/sym-cli/internal/envutil" "github.com/manifoldco/promptui" ) @@ -25,9 +26,9 @@ func promptAPIKeyConfiguration(checkExisting bool) { envPath := filepath.Join(".sym", ".env") if checkExisting { - // 1. Check environment variable - if os.Getenv("OPENAI_API_KEY") != "" { - fmt.Println("\n✓ OpenAI API key detected from environment") + // 1. Check environment variable or .env file + if envutil.GetAPIKey("OPENAI_API_KEY") != "" { + fmt.Println("\n✓ OpenAI API key detected from environment or .sym/.env") return } @@ -84,7 +85,7 @@ func promptAPIKeyConfiguration(checkExisting bool) { } // Save to .sym/.env - if err := saveToEnvFile(envPath, "OPENAI_API_KEY", apiKey); err != nil { + if err := envutil.SaveKeyToEnvFile(envPath, "OPENAI_API_KEY", apiKey); err != nil { fmt.Printf("\n❌ Failed to save API key: %v\n", err) return } @@ -173,41 +174,6 @@ func hasAPIKeyInEnvFile(envPath string) bool { return false } -// saveToEnvFile saves a key-value pair to .env file -func saveToEnvFile(envPath, key, value string) error { - // Create .sym directory if it doesn't exist - symDir := filepath.Dir(envPath) - if err := os.MkdirAll(symDir, 0755); err != nil { - return fmt.Errorf("failed to create .sym directory: %w", err) - } - - // Read existing content - var lines []string - existingFile, err := os.Open(envPath) - if err == nil { - scanner := bufio.NewScanner(existingFile) - for scanner.Scan() { - line := scanner.Text() - // Skip existing OPENAI_API_KEY lines - if !strings.HasPrefix(strings.TrimSpace(line), key+"=") { - lines = append(lines, line) - } - } - _ = existingFile.Close() - } - - // Add new key - lines = append(lines, fmt.Sprintf("%s=%s", key, value)) - - // Write to file with restrictive permissions (owner read/write only) - content := strings.Join(lines, "\n") + "\n" - if err := os.WriteFile(envPath, []byte(content), 0600); err != nil { - return fmt.Errorf("failed to write .env file: %w", err) - } - - return nil -} - // ensureGitignore ensures that the given path is in .gitignore func ensureGitignore(path string) error { gitignorePath := ".gitignore" @@ -241,41 +207,11 @@ func ensureGitignore(path string) error { } // getAPIKey retrieves OpenAI API key from environment or .env file -// Priority: 1) System environment variable 2) .sym/.env file +// Returns error if not found func getAPIKey() (string, error) { - // 1. Check system environment variable first - if key := os.Getenv("OPENAI_API_KEY"); key != "" { - return key, nil + key := envutil.GetAPIKey("OPENAI_API_KEY") + if key == "" { + return "", fmt.Errorf("OPENAI_API_KEY not found in environment or .sym/.env") } - - // 2. Check .sym/.env file - envPath := filepath.Join(".sym", ".env") - key, err := loadFromEnvFile(envPath, "OPENAI_API_KEY") - if err == nil && key != "" { - return key, nil - } - - return "", fmt.Errorf("OPENAI_API_KEY not found in environment or .sym/.env") -} - -// loadFromEnvFile loads a specific key from .env file -func loadFromEnvFile(envPath, key string) (string, error) { - file, err := os.Open(envPath) - if err != nil { - return "", err - } - defer func() { _ = file.Close() }() - - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if strings.HasPrefix(line, key+"=") { - parts := strings.SplitN(line, "=", 2) - if len(parts) == 2 { - return strings.TrimSpace(parts[1]), nil - } - } - } - - return "", fmt.Errorf("key %s not found in %s", key, envPath) + return key, nil } diff --git a/internal/cmd/convert.go b/internal/cmd/convert.go index e13234c..63e8aed 100644 --- a/internal/cmd/convert.go +++ b/internal/cmd/convert.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "time" "github.com/DevSymphony/sym-cli/internal/converter" @@ -51,16 +52,27 @@ map them to appropriate linter rules.`, } func init() { - convertCmd.Flags().StringVarP(&convertInputFile, "input", "i", "user-policy.json", "input user policy file") + convertCmd.Flags().StringVarP(&convertInputFile, "input", "i", "", "input user policy file (default: from .sym/.env POLICY_PATH)") convertCmd.Flags().StringVarP(&convertOutputFile, "output", "o", "", "output code policy file (legacy mode)") convertCmd.Flags().StringSliceVar(&convertTargets, "targets", []string{}, "target linters (eslint,checkstyle,pmd or 'all')") - convertCmd.Flags().StringVar(&convertOutputDir, "output-dir", "", "output directory for linter configs (default: /.sym)") + convertCmd.Flags().StringVar(&convertOutputDir, "output-dir", "", "output directory for linter configs (default: same as input file directory)") convertCmd.Flags().StringVar(&convertOpenAIModel, "openai-model", "gpt-4o-mini", "OpenAI model to use for inference") convertCmd.Flags().Float64Var(&convertConfidenceThreshold, "confidence-threshold", 0.7, "minimum confidence for LLM inference (0.0-1.0)") convertCmd.Flags().IntVar(&convertTimeout, "timeout", 30, "timeout for API calls in seconds") } func runConvert(cmd *cobra.Command, args []string) error { + // Determine input file path + if convertInputFile == "" { + // Try to load from .env + policyPath := loadPolicyPathFromEnv() + if policyPath == "" { + policyPath = ".sym/user-policy.json" // fallback default + } + convertInputFile = policyPath + fmt.Printf("Using policy path from .env: %s\n", convertInputFile) + } + // Read input file data, err := os.ReadFile(convertInputFile) if err != nil { @@ -83,10 +95,38 @@ func runConvert(cmd *cobra.Command, args []string) error { return runLegacyConvert(&userPolicy) } +// loadPolicyPathFromEnv reads POLICY_PATH from .sym/.env +func loadPolicyPathFromEnv() string { + envPath := filepath.Join(".sym", ".env") + data, err := os.ReadFile(envPath) + if err != nil { + return "" + } + + lines := strings.Split(string(data), "\n") + prefix := "POLICY_PATH=" + + for _, line := range lines { + line = strings.TrimSpace(line) + // Skip comments and empty lines + if len(line) == 0 || line[0] == '#' { + continue + } + // Check if line starts with POLICY_PATH= + if strings.HasPrefix(line, prefix) { + return strings.TrimSpace(line[len(prefix):]) + } + } + + return "" +} + func runLegacyConvert(userPolicy *schema.UserPolicy) error { outputFile := convertOutputFile if outputFile == "" { - outputFile = "code-policy.json" + // Use same directory as input file + inputDir := filepath.Dir(convertInputFile) + outputFile = filepath.Join(inputDir, "code-policy.json") } conv := converter.NewConverter() @@ -103,6 +143,12 @@ func runLegacyConvert(userPolicy *schema.UserPolicy) error { return fmt.Errorf("failed to serialize code policy: %w", err) } + // Create directory if needed + outputDir := filepath.Dir(outputFile) + if err := os.MkdirAll(outputDir, 0755); err != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } + if err := os.WriteFile(outputFile, output, 0644); err != nil { return fmt.Errorf("failed to write output file: %w", err) } @@ -119,12 +165,9 @@ func runLegacyConvert(userPolicy *schema.UserPolicy) error { func runMultiTargetConvert(userPolicy *schema.UserPolicy) error { // Determine output directory if convertOutputDir == "" { - // Use .sym directory in git root by default - symDir, err := getSymDir() - if err != nil { - return fmt.Errorf("failed to determine output directory: %w (hint: run from within a git repository or use --output-dir)", err) - } - convertOutputDir = symDir + // Use same directory as input file + convertOutputDir = filepath.Dir(convertInputFile) + fmt.Printf("Using output directory: %s (same as input file)\n", convertOutputDir) } // Create output directory if it doesn't exist diff --git a/internal/cmd/init.go b/internal/cmd/init.go index f27b85c..30d6d77 100644 --- a/internal/cmd/init.go +++ b/internal/cmd/init.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "github.com/DevSymphony/sym-cli/internal/config" + "github.com/DevSymphony/sym-cli/internal/envutil" "github.com/DevSymphony/sym-cli/internal/git" "github.com/DevSymphony/sym-cli/internal/github" "github.com/DevSymphony/sym-cli/internal/policy" @@ -140,6 +141,14 @@ func runInit(cmd *cobra.Command, args []string) { fmt.Println("✓ user-policy.json created with default RBAC roles") } + // Create .sym/.env with default POLICY_PATH + fmt.Println("\nSetting up environment configuration...") + if err := initializeEnvFile(); err != nil { + fmt.Printf("⚠ Warning: Failed to create .sym/.env: %v\n", err) + } else { + fmt.Println("✓ .sym/.env created with default policy path") + } + fmt.Println("\nNext steps:") fmt.Println(" 1. Review the files:") fmt.Println(" cat .sym/roles.json") @@ -210,6 +219,28 @@ func createDefaultPolicy(cfg *config.Config) error { // removeExistingCodePolicy removes all files generated by convert command // including linter configurations and code-policy.json +// initializeEnvFile creates .sym/.env with default POLICY_PATH if not exists +func initializeEnvFile() error { + envPath := filepath.Join(".sym", ".env") + defaultPolicyPath := ".sym/user-policy.json" + + // Check if .env already exists + if _, err := os.Stat(envPath); err == nil { + // File exists, check if POLICY_PATH is already set + existingPath := envutil.LoadKeyFromEnvFile(envPath, "POLICY_PATH") + if existingPath != "" { + // POLICY_PATH already set, nothing to do + return nil + } + // POLICY_PATH not set, add it + return envutil.SaveKeyToEnvFile(envPath, "POLICY_PATH", defaultPolicyPath) + } + + // .env doesn't exist, create it with default POLICY_PATH + content := fmt.Sprintf("# Policy configuration\nPOLICY_PATH=%s\n", defaultPolicyPath) + return os.WriteFile(envPath, []byte(content), 0644) +} + func removeExistingCodePolicy() error { // Files generated by convert command convertGeneratedFiles := []string{ diff --git a/internal/cmd/policy.go b/internal/cmd/policy.go index a8e4ac2..e497f94 100644 --- a/internal/cmd/policy.go +++ b/internal/cmd/policy.go @@ -1,7 +1,6 @@ package cmd import ( - "encoding/json" "fmt" "os" "github.com/DevSymphony/sym-cli/internal/config" @@ -18,8 +17,7 @@ var policyCmd = &cobra.Command{ Commands: sym policy path # Show current policy file path sym policy path --set PATH # Set policy file path - sym policy validate # Validate policy file - sym policy history # Show policy change history`, + sym policy validate # Validate policy file`, } var policyPathCmd = &cobra.Command{ @@ -36,27 +34,15 @@ var policyValidateCmd = &cobra.Command{ Run: runPolicyValidate, } -var policyHistoryCmd = &cobra.Command{ - Use: "history", - Short: "Show policy change history", - Long: `Display the git commit history for the policy file.`, - Run: runPolicyHistory, -} - var ( policyPathSet string - historyLimit int - jsonOutput bool ) func init() { policyPathCmd.Flags().StringVar(&policyPathSet, "set", "", "Set new policy file path") - policyHistoryCmd.Flags().IntVarP(&historyLimit, "limit", "n", 10, "Number of commits to show") - policyHistoryCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format") policyCmd.AddCommand(policyPathCmd) policyCmd.AddCommand(policyValidateCmd) - policyCmd.AddCommand(policyHistoryCmd) } func runPolicyPath(cmd *cobra.Command, args []string) { @@ -136,36 +122,3 @@ func runPolicyValidate(cmd *cobra.Command, args []string) { } } } - -func runPolicyHistory(cmd *cobra.Command, args []string) { - cfg, err := config.LoadConfig() - if err != nil { - fmt.Printf("❌ Failed to load config: %v\n", err) - os.Exit(1) - } - - history, err := policy.GetPolicyHistory(cfg.PolicyPath, historyLimit) - if err != nil { - fmt.Printf("❌ Failed to get history: %v\n", err) - os.Exit(1) - } - - if len(history) == 0 { - fmt.Println("No policy changes found") - return - } - - if jsonOutput { - data, _ := json.MarshalIndent(history, "", " ") - fmt.Println(string(data)) - return - } - - fmt.Printf("Policy change history (last %d commits):\n\n", len(history)) - - for i, commit := range history { - fmt.Printf("%d. %s\n", i+1, commit.Message) - fmt.Printf(" %s - %s <%s>\n", commit.Hash[:7], commit.Author, commit.Email) - fmt.Printf(" %s\n\n", commit.Date.Format("2006-01-02 15:04:05")) - } -} diff --git a/internal/engine/llm/engine.go b/internal/engine/llm/engine.go index f127c48..dffd1ad 100644 --- a/internal/engine/llm/engine.go +++ b/internal/engine/llm/engine.go @@ -8,6 +8,7 @@ import ( "time" "github.com/DevSymphony/sym-cli/internal/engine/core" + "github.com/DevSymphony/sym-cli/internal/envutil" "github.com/DevSymphony/sym-cli/internal/llm" ) @@ -29,13 +30,13 @@ func (e *Engine) Init(ctx context.Context, config core.EngineConfig) error { e.config = config // Initialize LLM client - apiKey := os.Getenv("ANTHROPIC_API_KEY") + apiKey := envutil.GetAPIKey("ANTHROPIC_API_KEY") if apiKey == "" { - apiKey = os.Getenv("OPENAI_API_KEY") + apiKey = envutil.GetAPIKey("OPENAI_API_KEY") } if apiKey == "" { - return fmt.Errorf("LLM API key not found (ANTHROPIC_API_KEY or OPENAI_API_KEY)") + return fmt.Errorf("LLM API key not found (ANTHROPIC_API_KEY or OPENAI_API_KEY in environment or .sym/.env)") } e.client = llm.NewClient(apiKey) diff --git a/internal/envutil/env.go b/internal/envutil/env.go new file mode 100644 index 0000000..feb8240 --- /dev/null +++ b/internal/envutil/env.go @@ -0,0 +1,101 @@ +package envutil + +import ( + "bufio" + "os" + "path/filepath" + "strings" +) + +// GetAPIKey retrieves an API key from environment or .sym/.env +// It checks system environment variable first, then .sym/.env file +func GetAPIKey(keyName string) string { + // 1. Check system environment variable first + if key := os.Getenv(keyName); key != "" { + return key + } + + // 2. Check .sym/.env file + return LoadKeyFromEnvFile(filepath.Join(".sym", ".env"), keyName) +} + +// GetPolicyPath retrieves policy path from .sym/.env +func GetPolicyPath() string { + return LoadKeyFromEnvFile(filepath.Join(".sym", ".env"), "POLICY_PATH") +} + +// LoadKeyFromEnvFile reads a specific key from .env file +func LoadKeyFromEnvFile(envPath, key string) string { + file, err := os.Open(envPath) + if err != nil { + return "" + } + defer func() { + _ = file.Close() + }() + + scanner := bufio.NewScanner(file) + prefix := key + "=" + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + // Skip comments and empty lines + if len(line) == 0 || line[0] == '#' { + continue + } + // Check if line starts with our key + if strings.HasPrefix(line, prefix) { + return strings.TrimSpace(line[len(prefix):]) + } + } + + return "" +} + +// SaveKeyToEnvFile saves a key-value pair to .env file +// It preserves existing lines, comments, and blank lines +func SaveKeyToEnvFile(envPath, key, value string) error { + // Create .sym directory if it doesn't exist + symDir := filepath.Dir(envPath) + if err := os.MkdirAll(symDir, 0755); err != nil { + return err + } + + // Read existing content + var lines []string + keyFound := false + existingFile, err := os.Open(envPath) + if err == nil { + scanner := bufio.NewScanner(existingFile) + for scanner.Scan() { + line := scanner.Text() + trimmed := strings.TrimSpace(line) + + // Check if this line defines the key we're updating + if trimmed != "" && !strings.HasPrefix(trimmed, "#") && strings.HasPrefix(trimmed, key+"=") { + // Replace existing key with new value + lines = append(lines, key+"="+value) + keyFound = true + } else { + // Keep all other lines (including comments and blank lines) + lines = append(lines, line) + } + } + _ = existingFile.Close() + } else if !os.IsNotExist(err) { + return err + } + + // If key not found, add it at the end + if !keyFound { + if len(lines) > 0 && lines[len(lines)-1] != "" { + lines = append(lines, "") // Add blank line before new section + } + lines = append(lines, "# Policy configuration") + lines = append(lines, key+"="+value) + } + + // Write to file + content := strings.Join(lines, "\n") + "\n" + return os.WriteFile(envPath, []byte(content), 0600) +} diff --git a/internal/llm/client.go b/internal/llm/client.go index 6c215f8..11e4036 100644 --- a/internal/llm/client.go +++ b/internal/llm/client.go @@ -7,8 +7,9 @@ import ( "fmt" "io" "net/http" - "os" "time" + + "github.com/DevSymphony/sym-cli/internal/envutil" ) const ( @@ -70,7 +71,7 @@ func WithVerbose(verbose bool) ClientOption { // NewClient creates a new OpenAI API client func NewClient(apiKey string, opts ...ClientOption) *Client { if apiKey == "" { - apiKey = os.Getenv("OPENAI_API_KEY") + apiKey = envutil.GetAPIKey("OPENAI_API_KEY") } client := &Client{ diff --git a/internal/mcp/server.go b/internal/mcp/server.go index f26593c..80f9e45 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -11,6 +11,7 @@ import ( "time" "github.com/DevSymphony/sym-cli/internal/converter" + "github.com/DevSymphony/sym-cli/internal/envutil" "github.com/DevSymphony/sym-cli/internal/git" "github.com/DevSymphony/sym-cli/internal/llm" "github.com/DevSymphony/sym-cli/internal/policy" @@ -34,9 +35,9 @@ func ConvertPolicyWithLLM(userPolicyPath, codePolicyPath string) error { } // Setup LLM client - apiKey := os.Getenv("OPENAI_API_KEY") + apiKey := envutil.GetAPIKey("OPENAI_API_KEY") if apiKey == "" { - return fmt.Errorf("OPENAI_API_KEY environment variable not set") + return fmt.Errorf("OPENAI_API_KEY not found in environment or .sym/.env") } llmClient := llm.NewClient(apiKey, @@ -142,14 +143,24 @@ func (s *Server) Start() error { // Only try to load policies if we have a directory if dir != "" { // Try to load user-policy.json for natural language descriptions - userPolicyPath := filepath.Join(dir, "user-policy.json") + // First check .env for POLICY_PATH, otherwise use default + userPolicyPath := envutil.GetPolicyPath() + if userPolicyPath == "" { + userPolicyPath = filepath.Join(dir, "user-policy.json") + } else if !filepath.IsAbs(userPolicyPath) { + // Make relative path absolute based on repo root + if repoRoot, err := git.GetRepoRoot(); err == nil { + userPolicyPath = filepath.Join(repoRoot, userPolicyPath) + } + } + if userPolicy, err := s.loader.LoadUserPolicy(userPolicyPath); err == nil { s.userPolicy = userPolicy fmt.Fprintf(os.Stderr, "✓ User policy loaded: %s (%d rules)\n", userPolicyPath, len(userPolicy.Rules)) } - // Try to load code-policy.json for validation - codePolicyPath := filepath.Join(dir, "code-policy.json") + // Try to load code-policy.json for validation (in same directory as user policy) + codePolicyPath := filepath.Join(filepath.Dir(userPolicyPath), "code-policy.json") if codePolicy, err := s.loader.LoadCodePolicy(codePolicyPath); err == nil { s.codePolicy = codePolicy fmt.Fprintf(os.Stderr, "✓ Code policy loaded: %s (%d rules)\n", codePolicyPath, len(codePolicy.Rules)) @@ -612,14 +623,14 @@ func (s *Server) handleValidateCode(params map[string]interface{}) (interface{}, } // Setup LLM client for validation - apiKey := os.Getenv("ANTHROPIC_API_KEY") + apiKey := envutil.GetAPIKey("ANTHROPIC_API_KEY") if apiKey == "" { - apiKey = os.Getenv("OPENAI_API_KEY") + apiKey = envutil.GetAPIKey("OPENAI_API_KEY") } if apiKey == "" { return nil, &RPCError{ Code: -32000, - Message: "LLM API key not found (ANTHROPIC_API_KEY or OPENAI_API_KEY required for validation)", + Message: "LLM API key not found (ANTHROPIC_API_KEY or OPENAI_API_KEY required for validation in environment or .sym/.env)", } } diff --git a/internal/policy/history.go b/internal/policy/history.go deleted file mode 100644 index 6e22084..0000000 --- a/internal/policy/history.go +++ /dev/null @@ -1,105 +0,0 @@ -package policy - -import ( - "fmt" - "os/exec" - "strings" - "time" -) - -// PolicyCommit represents a single commit in policy history -type PolicyCommit struct { - Hash string `json:"hash"` - Author string `json:"author"` - Email string `json:"email"` - Date time.Time `json:"date"` - Message string `json:"message"` - FilesChanged int `json:"filesChanged"` -} - -// GetPolicyHistory returns the git commit history for the policy file -func GetPolicyHistory(customPath string, limit int) ([]PolicyCommit, error) { - policyPath, err := GetPolicyPath(customPath) - if err != nil { - return nil, err - } - - if limit <= 0 { - limit = 10 - } - - // Git log command with custom format - // Format: hash|author|email|timestamp|subject - cmd := exec.Command("git", "log", - fmt.Sprintf("--max-count=%d", limit), - "--pretty=format:%H|%an|%ae|%at|%s", - "--", policyPath) - - output, err := cmd.Output() - if err != nil { - // If file has no history yet, return empty list - return []PolicyCommit{}, nil - } - - lines := strings.Split(strings.TrimSpace(string(output)), "\n") - commits := make([]PolicyCommit, 0, len(lines)) - - for _, line := range lines { - if line == "" { - continue - } - - parts := strings.SplitN(line, "|", 5) - if len(parts) != 5 { - continue - } - - var timestamp int64 - if _, err := fmt.Sscanf(parts[3], "%d", ×tamp); err != nil { - continue - } - - commit := PolicyCommit{ - Hash: parts[0], - Author: parts[1], - Email: parts[2], - Date: time.Unix(timestamp, 0), - Message: parts[4], - } - - commits = append(commits, commit) - } - - return commits, nil -} - -// GetPolicyDiff returns the diff for a specific commit -func GetPolicyDiff(commitHash string, customPath string) (string, error) { - policyPath, err := GetPolicyPath(customPath) - if err != nil { - return "", err - } - - // Get diff for specific commit - cmd := exec.Command("git", "show", fmt.Sprintf("%s:%s", commitHash, policyPath)) - output, err := cmd.Output() - if err != nil { - return "", fmt.Errorf("failed to get diff: %w", err) - } - - return string(output), nil -} - -// GetLatestCommit returns the most recent commit for the policy file -func GetLatestCommit(customPath string) (*PolicyCommit, error) { - commits, err := GetPolicyHistory(customPath, 1) - if err != nil { - return nil, err - } - - if len(commits) == 0 { - return nil, fmt.Errorf("no commits found for policy file") - } - - return &commits[0], nil -} diff --git a/internal/server/server.go b/internal/server/server.go index aa05372..f22b7a9 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1,16 +1,23 @@ package server import ( + "context" "embed" "encoding/json" "fmt" "io" "io/fs" "net/http" + "os" + "path/filepath" "strings" + "time" "github.com/DevSymphony/sym-cli/internal/config" + "github.com/DevSymphony/sym-cli/internal/converter" + "github.com/DevSymphony/sym-cli/internal/envutil" "github.com/DevSymphony/sym-cli/internal/git" "github.com/DevSymphony/sym-cli/internal/github" + "github.com/DevSymphony/sym-cli/internal/llm" "github.com/DevSymphony/sym-cli/internal/policy" "github.com/DevSymphony/sym-cli/internal/roles" "github.com/DevSymphony/sym-cli/pkg/schema" // symphonyclient integration @@ -62,9 +69,9 @@ func (s *Server) Start() error { // Policy API endpoints mux.HandleFunc("/api/policy", s.handlePolicy) mux.HandleFunc("/api/policy/path", s.handlePolicyPath) - mux.HandleFunc("/api/policy/history", s.handlePolicyHistory) mux.HandleFunc("/api/policy/templates", s.handlePolicyTemplates) mux.HandleFunc("/api/policy/templates/", s.handlePolicyTemplateDetail) + mux.HandleFunc("/api/policy/convert", s.handleConvert) mux.HandleFunc("/api/users", s.handleUsers) // Static files @@ -338,7 +345,14 @@ func (s *Server) handlePolicy(w http.ResponseWriter, r *http.Request) { // handleGetPolicy returns the current policy func (s *Server) handleGetPolicy(w http.ResponseWriter, r *http.Request) { - policyData, err := policy.LoadPolicy(s.cfg.PolicyPath) + // Get policy path from .sym/.env (or use default) + policyPath := envutil.GetPolicyPath() + if policyPath == "" { + policyPath = ".sym/user-policy.json" + } + fmt.Printf("Loading policy from: %s\n", policyPath) + + policyData, err := policy.LoadPolicy(policyPath) if err != nil { http.Error(w, fmt.Sprintf("Failed to load policy: %v", err), http.StatusInternalServerError) return @@ -383,8 +397,22 @@ func (s *Server) handleSavePolicy(w http.ResponseWriter, r *http.Request) { return } + // Get policy path from .sym/.env (or use default) + policyPath := envutil.GetPolicyPath() + if policyPath == "" { + policyPath = ".sym/user-policy.json" + } + fmt.Printf("Saving policy to: %s\n", policyPath) + + // Ensure directory exists + policyDir := filepath.Dir(policyPath) + if err := os.MkdirAll(policyDir, 0755); err != nil { + http.Error(w, fmt.Sprintf("Failed to create directory %s: %v", policyDir, err), http.StatusInternalServerError) + return + } + // Save policy - if err := policy.SavePolicy(&newPolicy, s.cfg.PolicyPath); err != nil { + if err := policy.SavePolicy(&newPolicy, policyPath); err != nil { http.Error(w, fmt.Sprintf("Failed to save policy: %v", err), http.StatusInternalServerError) return } @@ -400,9 +428,19 @@ func (s *Server) handleSavePolicy(w http.ResponseWriter, r *http.Request) { func (s *Server) handlePolicyPath(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: + // Load policy path from .sym/.env + policyPath := envutil.GetPolicyPath() + if policyPath == "" { + // Default to .sym/user-policy.json if not set + policyPath = ".sym/user-policy.json" + fmt.Printf("No POLICY_PATH in .sym/.env, using default: %s\n", policyPath) + } else { + fmt.Printf("Loaded POLICY_PATH from .sym/.env: %s\n", policyPath) + } + w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]string{ - "policyPath": s.cfg.PolicyPath, + "policyPath": policyPath, }) case http.MethodPost: s.handleSetPolicyPath(w, r) @@ -437,39 +475,70 @@ func (s *Server) handleSetPolicyPath(w http.ResponseWriter, r *http.Request) { PolicyPath string `json:"policyPath"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + fmt.Printf("Failed to decode request body: %v\n", err) http.Error(w, "Invalid JSON", http.StatusBadRequest) return } - // Update config - s.cfg.PolicyPath = req.PolicyPath - if err := config.SaveConfig(s.cfg); err != nil { - http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError) - return + fmt.Printf("Received policy path from client: '%s' (length: %d)\n", req.PolicyPath, len(req.PolicyPath)) + + // Get current policy path from .env + oldPolicyPath := envutil.GetPolicyPath() + if oldPolicyPath == "" { + oldPolicyPath = ".sym/user-policy.json" // default } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]string{ - "status": "success", - "message": "Policy path updated successfully", - }) -} + // If path is changing and old file exists, move it to new location + if oldPolicyPath != req.PolicyPath { + fmt.Printf("Policy path changing from '%s' to '%s'\n", oldPolicyPath, req.PolicyPath) -// handlePolicyHistory returns the policy commit history -func (s *Server) handlePolicyHistory(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return + if _, err := os.Stat(oldPolicyPath); err == nil { + fmt.Printf("Moving existing policy file from '%s' to '%s'\n", oldPolicyPath, req.PolicyPath) + + // Create directory for new path + newDir := filepath.Dir(req.PolicyPath) + if err := os.MkdirAll(newDir, 0755); err != nil { + fmt.Printf("Warning: Failed to create directory '%s': %v\n", newDir, err) + } + + // Read old file + oldData, err := os.ReadFile(oldPolicyPath) + if err != nil { + fmt.Printf("Warning: Failed to read old policy file: %v\n", err) + } else { + // Write to new location + if err := os.WriteFile(req.PolicyPath, oldData, 0644); err != nil { + fmt.Printf("Warning: Failed to write to new location: %v\n", err) + } else { + fmt.Printf("Successfully copied policy to new location\n") + + // Remove old file + if err := os.Remove(oldPolicyPath); err != nil { + fmt.Printf("Warning: Failed to remove old policy file: %v\n", err) + } else { + fmt.Printf("Successfully removed old policy file\n") + } + } + } + } else { + fmt.Printf("Old policy file not found at '%s', skipping move\n", oldPolicyPath) + } } - history, err := policy.GetPolicyHistory(s.cfg.PolicyPath, 20) - if err != nil { - http.Error(w, fmt.Sprintf("Failed to get policy history: %v", err), http.StatusInternalServerError) + // Save to .sym/.env file + fmt.Printf("Saving policy path to .sym/.env: %s\n", req.PolicyPath) + if err := envutil.SaveKeyToEnvFile(filepath.Join(".sym", ".env"), "POLICY_PATH", req.PolicyPath); err != nil { + fmt.Printf("Failed to save policy path: %v\n", err) + http.Error(w, fmt.Sprintf("Failed to save policy path: %v", err), http.StatusInternalServerError) return } + fmt.Printf("Policy path saved successfully to .sym/.env\n") w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(history) + _ = json.NewEncoder(w).Encode(map[string]string{ + "status": "success", + "message": "Policy path updated successfully", + }) } // handlePolicyTemplates returns the list of available templates @@ -555,3 +624,140 @@ func (s *Server) handleUsers(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(users) } + +// handleConvert runs the convert command to generate linter configs +func (s *Server) handleConvert(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Get current user + user, err := s.githubClient.GetCurrentUser() + if err != nil { + http.Error(w, fmt.Sprintf("Failed to get user: %v", err), http.StatusInternalServerError) + return + } + + // Check if user has permission to edit policy + canEdit, err := s.hasPermission(user.Login, "editPolicy") + if err != nil { + http.Error(w, fmt.Sprintf("Failed to check permissions: %v", err), http.StatusInternalServerError) + return + } + + if !canEdit { + http.Error(w, "Forbidden: You don't have permission to convert policy", http.StatusForbidden) + return + } + + fmt.Println("Starting policy conversion...") + + // Get policy path from .env + policyPath := envutil.GetPolicyPath() + if policyPath == "" { + policyPath = ".sym/user-policy.json" + } + + fmt.Printf("Converting policy from: %s\n", policyPath) + + // Load user policy + userPolicy, err := policy.LoadPolicy(policyPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load policy: %v", err), http.StatusInternalServerError) + return + } + + // Determine output directory (same as input file) + outputDir := filepath.Dir(policyPath) + + // Get API key + apiKey, err := s.getAPIKey() + if err != nil { + fmt.Printf("Warning: %v, conversion may be limited\n", err) + apiKey = "" + } + + // Setup LLM client + timeout := 30 * time.Second + llmClient := llm.NewClient( + apiKey, + llm.WithModel("gpt-4o-mini"), + llm.WithTimeout(timeout), + ) + + // Create converter with LLM client + conv := converter.NewConverter(converter.WithLLMClient(llmClient)) + + // Setup context with timeout + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(30*len(userPolicy.Rules))*time.Second) + defer cancel() + + // Convert for all targets + targets := []string{"all"} + convResult, err := conv.ConvertMultiTarget(ctx, userPolicy, converter.MultiTargetConvertOptions{ + Targets: targets, + OutputDir: outputDir, + ConfidenceThreshold: 0.7, + }) + if err != nil { + http.Error(w, fmt.Sprintf("Conversion failed: %v", err), http.StatusInternalServerError) + return + } + + // Write linter configuration files + filesWritten := []string{} + for linterName, config := range convResult.LinterConfigs { + outputPath := filepath.Join(outputDir, config.Filename) + + if err := os.WriteFile(outputPath, config.Content, 0644); err != nil { + http.Error(w, fmt.Sprintf("Failed to write %s config: %v", linterName, err), http.StatusInternalServerError) + return + } + + fmt.Printf("✓ Generated %s configuration: %s\n", linterName, outputPath) + filesWritten = append(filesWritten, config.Filename) + } + + // Write internal code policy + codePolicyPath := filepath.Join(outputDir, "code-policy.json") + codePolicyJSON, err := json.MarshalIndent(convResult.CodePolicy, "", " ") + if err != nil { + http.Error(w, fmt.Sprintf("Failed to serialize code policy: %v", err), http.StatusInternalServerError) + return + } + + if err := os.WriteFile(codePolicyPath, codePolicyJSON, 0644); err != nil { + http.Error(w, fmt.Sprintf("Failed to write code policy: %v", err), http.StatusInternalServerError) + return + } + + fmt.Printf("✓ Generated internal policy: %s\n", codePolicyPath) + filesWritten = append(filesWritten, "code-policy.json") + + result := map[string]interface{}{ + "status": "success", + "policyPath": policyPath, + "outputDir": outputDir, + "filesWritten": filesWritten, + "message": fmt.Sprintf("Conversion completed: %d files written", len(filesWritten)), + } + + if len(convResult.Warnings) > 0 { + result["warnings"] = convResult.Warnings + } + + fmt.Printf("Conversion completed: %d files written\n", len(filesWritten)) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(result) +} + +// getAPIKey retrieves the OpenAI API key from environment or .sym/.env +func (s *Server) getAPIKey() (string, error) { + key := envutil.GetAPIKey("OPENAI_API_KEY") + if key == "" { + return "", fmt.Errorf("OPENAI_API_KEY not found in environment or .sym/.env") + } + return key, nil +} diff --git a/internal/server/static/index.html b/internal/server/static/index.html index 7c8450a..58dd26b 100644 --- a/internal/server/static/index.html +++ b/internal/server/static/index.html @@ -49,10 +49,6 @@

템플릿 - @@ -104,12 +100,6 @@

Info -
- -
@@ -121,10 +111,6 @@

정책 파일의 상대 경로를 지정합니다.

-
- - -
@@ -191,22 +177,6 @@

- - -