diff --git a/.gitignore b/.gitignore index 7b04681..326ff9b 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ node_modules/ .sym/ *.coverprofile coverage.txt + +# Symphony API key configuration +.sym/.env diff --git a/go.mod b/go.mod index fd1833f..c815c07 100644 --- a/go.mod +++ b/go.mod @@ -10,10 +10,12 @@ require ( require ( github.com/modelcontextprotocol/go-sdk v1.1.0 + github.com/manifoldco/promptui v0.9.0 github.com/stretchr/testify v1.11.1 ) require ( + github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/google/jsonschema-go v0.3.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -21,6 +23,6 @@ require ( github.com/spf13/pflag v1.0.10 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect golang.org/x/oauth2 v0.30.0 // indirect - golang.org/x/sys v0.15.0 // indirect + golang.org/x/sys v0.38.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 29d48b1..d534903 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,11 @@ github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -11,6 +17,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/modelcontextprotocol/go-sdk v1.1.0 h1:Qjayg53dnKC4UZ+792W21e4BpwEZBzwgRW6LrjLWSwA= github.com/modelcontextprotocol/go-sdk v1.1.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10= +github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= +github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -28,10 +36,12 @@ github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT0 golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/cmd/api_key.go b/internal/cmd/api_key.go new file mode 100644 index 0000000..6fda694 --- /dev/null +++ b/internal/cmd/api_key.go @@ -0,0 +1,281 @@ +package cmd + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/manifoldco/promptui" +) + +// promptAPIKeySetup prompts user to setup API key (without checking if it exists) +func promptAPIKeySetup() { + promptAPIKeyConfiguration(false) +} + +// promptAPIKeyIfNeeded checks if OpenAI API key is configured and prompts if not +func promptAPIKeyIfNeeded() { + promptAPIKeyConfiguration(true) +} + +// promptAPIKeyConfiguration handles API key configuration with optional existence check +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") + return + } + + // 2. Check .sym/.env file + if hasAPIKeyInEnvFile(envPath) { + fmt.Println("\n✓ OpenAI API key found in .sym/.env") + return + } + + // Neither found - show warning + fmt.Println("\n⚠ OpenAI API key not found") + fmt.Println(" (Required for convert, validate commands and MCP auto-conversion)") + fmt.Println() + } + + // Create selection prompt + items := []string{ + "Enter API key", + "Skip (set manually later)", + } + + templates := &promptui.SelectTemplates{ + Label: "{{ . }}?", + Active: "▸ {{ . | cyan }}", + Inactive: " {{ . }}", + Selected: "✓ {{ . | green }}", + } + + selectPrompt := promptui.Select{ + Label: "Would you like to configure it now", + Items: items, + Templates: templates, + Size: 2, + } + + index, _, err := selectPrompt.Run() + if err != nil { + fmt.Println("\nSkipped API key configuration") + return + } + + switch index { + case 0: // Enter API key + apiKey, err := promptForAPIKey() + if err != nil { + fmt.Printf("\n❌ Failed to read API key: %v\n", err) + return + } + + // Validate API key format + if err := validateAPIKey(apiKey); err != nil { + fmt.Printf("\n⚠ Warning: %v\n", err) + fmt.Println(" API key was saved anyway. Make sure it's correct.") + } + + // Save to .sym/.env + if err := saveToEnvFile(envPath, "OPENAI_API_KEY", apiKey); err != nil { + fmt.Printf("\n❌ Failed to save API key: %v\n", err) + return + } + + fmt.Println("\n✓ API key saved to .sym/.env") + + // Add to .gitignore + if err := ensureGitignore(".sym/.env"); err != nil { + fmt.Printf("⚠ Warning: Failed to update .gitignore: %v\n", err) + fmt.Println(" Please manually add '.sym/.env' to .gitignore") + } else { + fmt.Println("✓ Added .sym/.env to .gitignore") + } + + case 1: // Skip + fmt.Println("\nSkipped API key configuration") + fmt.Println("\n💡 Tip: You can set OPENAI_API_KEY in:") + fmt.Println(" - .sym/.env file") + fmt.Println(" - System environment variable") + } +} + +// promptForAPIKey prompts user to enter API key +func promptForAPIKey() (string, error) { + fmt.Print("Enter your OpenAI API key: ") + + // Use bufio reader for better paste support + reader := bufio.NewReader(os.Stdin) + input, err := reader.ReadString('\n') + if err != nil { + return "", fmt.Errorf("failed to read API key: %w", err) + } + + // Clean the input: remove all whitespace, control characters, and non-printable characters + apiKey := cleanAPIKey(input) + + if len(apiKey) == 0 { + return "", fmt.Errorf("API key cannot be empty") + } + + return apiKey, nil +} + +// cleanAPIKey removes whitespace, control characters, and non-printable characters from API key +func cleanAPIKey(input string) string { + var result strings.Builder + for _, r := range input { + // Only keep printable ASCII characters (excluding space) + if r >= 33 && r <= 126 { + result.WriteRune(r) + } + } + return result.String() +} + +// validateAPIKey performs basic validation on API key format +func validateAPIKey(key string) error { + if !strings.HasPrefix(key, "sk-") { + return fmt.Errorf("API key should start with 'sk-'") + } + if len(key) < 20 { + return fmt.Errorf("API key seems too short") + } + return nil +} + +// hasAPIKeyInEnvFile checks if OPENAI_API_KEY exists in .env file +func hasAPIKeyInEnvFile(envPath string) bool { + file, err := os.Open(envPath) + if err != nil { + return false + } + defer func() { _ = file.Close() }() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "OPENAI_API_KEY=") { + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 && strings.TrimSpace(parts[1]) != "" { + return true + } + } + } + + 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" + + // Read existing .gitignore + var lines []string + existingFile, err := os.Open(gitignorePath) + if err == nil { + scanner := bufio.NewScanner(existingFile) + for scanner.Scan() { + line := scanner.Text() + lines = append(lines, line) + // Check if already exists + if strings.TrimSpace(line) == path { + _ = existingFile.Close() + return nil // Already in .gitignore + } + } + _ = existingFile.Close() + } + + // Add to .gitignore + lines = append(lines, "", "# Symphony API key configuration", path) + content := strings.Join(lines, "\n") + "\n" + + if err := os.WriteFile(gitignorePath, []byte(content), 0644); err != nil { + return fmt.Errorf("failed to update .gitignore: %w", err) + } + + return nil +} + +// getAPIKey retrieves OpenAI API key from environment or .env file +// Priority: 1) System environment variable 2) .sym/.env file +func getAPIKey() (string, error) { + // 1. Check system environment variable first + if key := os.Getenv("OPENAI_API_KEY"); key != "" { + return key, nil + } + + // 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) +} diff --git a/internal/cmd/convert.go b/internal/cmd/convert.go index 192b91b..e13234c 100644 --- a/internal/cmd/convert.go +++ b/internal/cmd/convert.go @@ -133,9 +133,10 @@ func runMultiTargetConvert(userPolicy *schema.UserPolicy) error { } // Setup OpenAI client - apiKey := os.Getenv("OPENAI_API_KEY") - if apiKey == "" { - fmt.Println("Warning: OPENAI_API_KEY not set, using fallback inference") + apiKey, err := getAPIKey() + if err != nil { + fmt.Printf("Warning: %v, using fallback inference\n", err) + apiKey = "" } timeout := time.Duration(convertTimeout) * time.Second diff --git a/internal/cmd/init.go b/internal/cmd/init.go index 6d0d9da..f27b85c 100644 --- a/internal/cmd/init.go +++ b/internal/cmd/init.go @@ -29,13 +29,37 @@ This command: Run: runInit, } -var initForce bool +var ( + initForce bool + skipMCPRegister bool + registerMCPOnly bool + skipAPIKey bool + setupAPIKeyOnly bool +) func init() { initCmd.Flags().BoolVarP(&initForce, "force", "f", false, "Overwrite existing roles.json") + initCmd.Flags().BoolVar(&skipMCPRegister, "skip-mcp", false, "Skip MCP server registration prompt") + initCmd.Flags().BoolVar(®isterMCPOnly, "register-mcp", false, "Register MCP server only (skip roles/policy init)") + initCmd.Flags().BoolVar(&skipAPIKey, "skip-api-key", false, "Skip OpenAI API key configuration prompt") + initCmd.Flags().BoolVar(&setupAPIKeyOnly, "setup-api-key", false, "Setup OpenAI API key only (skip roles/policy init)") } func runInit(cmd *cobra.Command, args []string) { + // MCP registration only mode + if registerMCPOnly { + fmt.Println("🔧 Registering Symphony MCP server...") + promptMCPRegistration() + return + } + + // API key setup only mode + if setupAPIKeyOnly { + fmt.Println("🔑 Setting up OpenAI API key...") + promptAPIKeySetup() + return + } + // Check if logged in if !config.IsLoggedIn() { fmt.Println("❌ Not logged in") @@ -123,6 +147,16 @@ func runInit(cmd *cobra.Command, args []string) { fmt.Println(" 2. Commit: git add .sym/ && git commit -m 'Initialize Symphony roles and policy'") fmt.Println(" 3. Push: git push") fmt.Println("\nAfter pushing, team members can clone and use 'sym my-role' to check their access.") + + // MCP registration prompt + if !skipMCPRegister { + promptMCPRegistration() + } + + // API key configuration prompt + if !skipAPIKey { + promptAPIKeyIfNeeded() + } } // createDefaultPolicy creates a default policy file with RBAC roles diff --git a/internal/cmd/mcp.go b/internal/cmd/mcp.go index e38c4db..b2a0e16 100644 --- a/internal/cmd/mcp.go +++ b/internal/cmd/mcp.go @@ -108,3 +108,69 @@ func launchDashboard() error { return nil } + +// autoConvertPolicy converts user-policy.json to code-policy.json +func autoConvertPolicy(userPolicyPath, codePolicyPath string) error { + // Load user policy + data, err := os.ReadFile(userPolicyPath) + if err != nil { + return fmt.Errorf("failed to read user policy: %w", err) + } + + var userPolicy schema.UserPolicy + if err := json.Unmarshal(data, &userPolicy); err != nil { + return fmt.Errorf("failed to parse user policy: %w", err) + } + + // Setup LLM client + apiKey, err := getAPIKey() + if err != nil { + return fmt.Errorf("OpenAI API key not configured: %w\nTip: Run 'sym init' or set OPENAI_API_KEY in .sym/.env", err) + } + + llmClient := llm.NewClient(apiKey, + llm.WithModel("gpt-4o-mini"), + llm.WithTimeout(30*time.Second), + ) + + // Create converter + conv := converter.NewConverter(converter.WithLLMClient(llmClient)) + + // Setup context with timeout + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(len(userPolicy.Rules)*30)*time.Second) + defer cancel() + + fmt.Printf("Converting %d rules...\n", len(userPolicy.Rules)) + + // Convert to all targets + result, err := conv.ConvertMultiTarget(ctx, &userPolicy, converter.MultiTargetConvertOptions{ + Targets: []string{"all"}, + OutputDir: filepath.Dir(codePolicyPath), + ConfidenceThreshold: 0.7, + }) + if err != nil { + return fmt.Errorf("conversion failed: %w", err) + } + + // Write code policy + codePolicyJSON, err := json.MarshalIndent(result.CodePolicy, "", " ") + if err != nil { + return fmt.Errorf("failed to serialize code policy: %w", err) + } + + if err := os.WriteFile(codePolicyPath, codePolicyJSON, 0644); err != nil { + return fmt.Errorf("failed to write code policy: %w", err) + } + + // Write linter configs + for linterName, config := range result.LinterConfigs { + outputPath := filepath.Join(filepath.Dir(codePolicyPath), config.Filename) + if err := os.WriteFile(outputPath, config.Content, 0644); err != nil { + fmt.Printf("Warning: failed to write %s config: %v\n", linterName, err) + } else { + fmt.Printf(" ✓ Generated %s: %s\n", linterName, outputPath) + } + } + + return nil +} diff --git a/internal/cmd/mcp_register.go b/internal/cmd/mcp_register.go new file mode 100644 index 0000000..ad73006 --- /dev/null +++ b/internal/cmd/mcp_register.go @@ -0,0 +1,331 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "github.com/manifoldco/promptui" +) + +// MCPRegistrationConfig represents the MCP configuration structure +// Used for Claude Code, Cursor +type MCPRegistrationConfig struct { + MCPServers map[string]MCPServerConfig `json:"mcpServers"` +} + +// VSCodeMCPConfig represents the VS Code MCP configuration structure +type VSCodeMCPConfig struct { + Servers map[string]VSCodeServerConfig `json:"servers"` + Inputs []interface{} `json:"inputs,omitempty"` +} + +// MCPServerConfig represents a single MCP server configuration +// Used for Claude Code, Cursor +type MCPServerConfig struct { + Type string `json:"type,omitempty"` // Optional for Claude Code, recommended for Cursor + Command string `json:"command"` + Args []string `json:"args"` + Env map[string]string `json:"env,omitempty"` +} + +// VSCodeServerConfig represents VS Code MCP server configuration +type VSCodeServerConfig struct { + Type string `json:"type"` + Command string `json:"command"` + Args []string `json:"args"` + Env map[string]string `json:"env,omitempty"` +} + +// promptMCPRegistration prompts user to register Symphony as MCP server +func promptMCPRegistration() { + // Check if npx is available + if !checkNpxAvailable() { + fmt.Println("\n⚠ Warning: 'npx' not found. MCP features require Node.js.") + fmt.Println(" Download: https://nodejs.org/") + + confirmPrompt := promptui.Prompt{ + Label: "Continue anyway", + IsConfirm: true, + } + + result, err := confirmPrompt.Run() + if err != nil || strings.ToLower(result) != "y" { + fmt.Println("Skipped MCP registration") + return + } + } + + fmt.Println("\n📡 Would you like to register Symphony as an MCP server?") + fmt.Println(" (Symphony MCP provides code convention tools for AI assistants)") + fmt.Println() + + // Create selection prompt + items := []string{ + "Claude Desktop (global)", + "Claude Code (project)", + "Cursor (project)", + "VS Code/Cline (project)", + "All", + "Skip", + } + + templates := &promptui.SelectTemplates{ + Label: "{{ . }}?", + Active: "▸ {{ . | cyan }}", + Inactive: " {{ . }}", + Selected: "✓ {{ . | green }}", + } + + prompt := promptui.Select{ + Label: "Select option", + Items: items, + Templates: templates, + Size: 6, + } + + index, _, err := prompt.Run() + if err != nil { + fmt.Println("\nSkipped MCP registration") + return + } + + switch index { + case 0: // Claude Desktop (global) + if err := registerMCP("claude-desktop"); err != nil { + fmt.Printf("❌ Failed to register Claude Desktop: %v\n", err) + } else { + fmt.Println("\n✅ MCP registration complete! Restart Claude Desktop to use Symphony.") + } + case 1: // Claude Code (project) + if err := registerMCP("claude-code"); err != nil { + fmt.Printf("❌ Failed to register Claude Code: %v\n", err) + } else { + fmt.Println("\n✅ MCP registration complete! Reload Claude Code to use Symphony.") + } + case 2: // Cursor (project) + if err := registerMCP("cursor"); err != nil { + fmt.Printf("❌ Failed to register Cursor: %v\n", err) + } else { + fmt.Println("\n✅ MCP registration complete! Reload Cursor to use Symphony.") + } + case 3: // VS Code/Cline (project) + if err := registerMCP("vscode"); err != nil { + fmt.Printf("❌ Failed to register VS Code: %v\n", err) + } else { + fmt.Println("\n✅ MCP registration complete! Reload VS Code to use Symphony.") + } + case 4: // All + apps := []string{"claude-desktop", "claude-code", "cursor", "vscode"} + successCount := 0 + for _, app := range apps { + if registerMCP(app) == nil { + successCount++ + } + } + if successCount > 0 { + fmt.Printf("\n✅ MCP registration complete! Registered to %d app(s).\n", successCount) + fmt.Println(" Restart/reload the apps to use Symphony.") + } + case 5: // Skip + fmt.Println("Skipped MCP registration") + fmt.Println("\n💡 Tip: Run 'sym init --register-mcp' to register MCP later") + } +} + +// registerMCP registers Symphony as an MCP server for the specified app +func registerMCP(app string) error { + configPath := getMCPConfigPath(app) + + if configPath == "" { + fmt.Printf("\n⚠ %s config path could not be determined\n", getAppDisplayName(app)) + return fmt.Errorf("config path not determined") + } + + // Check if this is a project-specific config + isProjectConfig := app != "claude-desktop" + + if isProjectConfig { + fmt.Printf("\n✓ Configuring %s (project-specific)\n", getAppDisplayName(app)) + } else { + fmt.Printf("\n✓ Configuring %s (global)\n", getAppDisplayName(app)) + } + fmt.Printf(" Location: %s\n", configPath) + + // Create config directory if it doesn't exist + configDir := filepath.Dir(configPath) + if err := os.MkdirAll(configDir, 0755); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + + // Read existing config and handle different formats + existingData, err := os.ReadFile(configPath) + fileExists := err == nil + + var data []byte + + if app == "vscode" { + // VS Code uses different format + var vscodeConfig VSCodeMCPConfig + + if fileExists { + if err := json.Unmarshal(existingData, &vscodeConfig); err != nil { + // Invalid JSON, create backup + backupPath := configPath + ".bak" + if err := os.WriteFile(backupPath, existingData, 0644); err != nil { + fmt.Printf(" ⚠ Failed to create backup: %v\n", err) + } else { + fmt.Printf(" ⚠ Invalid JSON, backup created: %s\n", filepath.Base(backupPath)) + } + vscodeConfig = VSCodeMCPConfig{} + } else { + // Valid JSON, create backup + backupPath := configPath + ".bak" + if err := os.WriteFile(backupPath, existingData, 0644); err != nil { + fmt.Printf(" ⚠ Failed to create backup: %v\n", err) + } else { + fmt.Printf(" Backup: %s\n", filepath.Base(backupPath)) + } + } + } else { + fmt.Printf(" Creating new configuration file\n") + } + + // Initialize Servers if nil + if vscodeConfig.Servers == nil { + vscodeConfig.Servers = make(map[string]VSCodeServerConfig) + } + + // Add/update Symphony server + vscodeConfig.Servers["symphony"] = VSCodeServerConfig{ + Type: "stdio", + Command: "npx", + Args: []string{"-y", "@dev-symphony/sym@latest", "mcp"}, + } + + // Marshal + data, err = json.MarshalIndent(vscodeConfig, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + } else { + // Claude Code, Cursor use standard format + var config MCPRegistrationConfig + + if fileExists { + if err := json.Unmarshal(existingData, &config); err != nil { + // Invalid JSON, create backup + backupPath := configPath + ".bak" + if err := os.WriteFile(backupPath, existingData, 0644); err != nil { + fmt.Printf(" ⚠ Failed to create backup: %v\n", err) + } else { + fmt.Printf(" ⚠ Invalid JSON, backup created: %s\n", filepath.Base(backupPath)) + } + config = MCPRegistrationConfig{} + } else { + // Valid JSON, create backup + backupPath := configPath + ".bak" + if err := os.WriteFile(backupPath, existingData, 0644); err != nil { + fmt.Printf(" ⚠ Failed to create backup: %v\n", err) + } else { + fmt.Printf(" Backup: %s\n", filepath.Base(backupPath)) + } + } + } else { + fmt.Printf(" Creating new configuration file\n") + } + + // Initialize MCPServers if nil + if config.MCPServers == nil { + config.MCPServers = make(map[string]MCPServerConfig) + } + + // Add/update Symphony server + serverConfig := MCPServerConfig{ + Command: "npx", + Args: []string{"-y", "@dev-symphony/sym@latest", "mcp"}, + } + + // For Cursor, add type field + if app == "cursor" { + serverConfig.Type = "stdio" + } + + config.MCPServers["symphony"] = serverConfig + + // Marshal + data, err = json.MarshalIndent(config, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + } + + // Write config + if err := os.WriteFile(configPath, data, 0644); err != nil { + return fmt.Errorf("failed to write config: %w", err) + } + + fmt.Printf(" ✓ Symphony MCP server registered\n") + + return nil +} + +// getMCPConfigPath returns the MCP config file path for the specified app +func getMCPConfigPath(app string) string { + homeDir, _ := os.UserHomeDir() + + // For project-specific configs, get current working directory (project root) + cwd, _ := os.Getwd() + + var path string + + switch app { + case "claude-desktop": + // Global configuration + switch runtime.GOOS { + case "windows": + path = filepath.Join(os.Getenv("APPDATA"), "Claude", "claude_desktop_config.json") + case "darwin": + path = filepath.Join(homeDir, "Library", "Application Support", "Claude", "claude_desktop_config.json") + case "linux": + path = filepath.Join(homeDir, ".config", "Claude", "claude_desktop_config.json") + } + case "claude-code": + // Project-specific configuration + path = filepath.Join(cwd, ".mcp.json") + case "cursor": + // Project-specific configuration + path = filepath.Join(cwd, ".cursor", "mcp.json") + case "vscode": + // Project-specific configuration + path = filepath.Join(cwd, ".vscode", "mcp.json") + } + + return path +} + +// getAppDisplayName returns the display name for the app +func getAppDisplayName(app string) string { + switch app { + case "claude-desktop": + return "Claude Desktop" + case "claude-code": + return "Claude Code" + case "cursor": + return "Cursor" + case "vscode": + return "VS Code/Cline" + default: + return app + } +} + +// checkNpxAvailable checks if npx is available in PATH +func checkNpxAvailable() bool { + _, err := exec.LookPath("npx") + return err == nil +} diff --git a/internal/cmd/validate.go b/internal/cmd/validate.go index c9f8dee..547f185 100644 --- a/internal/cmd/validate.go +++ b/internal/cmd/validate.go @@ -71,9 +71,9 @@ func runValidate(cmd *cobra.Command, args []string) error { } // Get OpenAI API key - apiKey := os.Getenv("OPENAI_API_KEY") - if apiKey == "" { - return fmt.Errorf("OPENAI_API_KEY environment variable not set") + apiKey, err := getAPIKey() + if err != nil { + return fmt.Errorf("OpenAI API key not configured: %w\nTip: Run 'sym init' or set OPENAI_API_KEY in .sym/.env", err) } // Create LLM client