Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,7 @@ coverage.txt

# Symphony API key configuration
.sym/.env
.vscode
.cursor
sym-cli.exe
.mcp.json
84 changes: 10 additions & 74 deletions internal/cmd/api_key.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"path/filepath"
"strings"

"github.com/DevSymphony/sym-cli/internal/envutil"
"github.com/manifoldco/promptui"
)

Expand All @@ -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
}

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
61 changes: 52 additions & 9 deletions internal/cmd/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"time"

"github.com/DevSymphony/sym-cli/internal/converter"
Expand Down Expand Up @@ -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: <git-root>/.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 {
Expand All @@ -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()
Expand All @@ -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)
}
Expand All @@ -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
Expand Down
31 changes: 31 additions & 0 deletions internal/cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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{
Expand Down
49 changes: 1 addition & 48 deletions internal/cmd/policy.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package cmd

import (
"encoding/json"
"fmt"
"os"
"github.com/DevSymphony/sym-cli/internal/config"
Expand All @@ -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{
Expand All @@ -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) {
Expand Down Expand Up @@ -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"))
}
}
7 changes: 4 additions & 3 deletions internal/engine/llm/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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)
Expand Down
Loading