From 9a7ba96868a36f90bbff7203834f5336fea2cac6 Mon Sep 17 00:00:00 2001 From: baeyc0510 Date: Wed, 12 Nov 2025 15:47:12 +0900 Subject: [PATCH 1/4] feat: add MCP server registration to init command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add interactive MCP server registration during project initialization with support for multiple platforms and configuration types. Features: - Interactive prompt with arrow key navigation (promptui) - Support for 4 platforms: * Claude Desktop (global config) * Claude Code (project .mcp.json) * Cursor (project .cursor/mcp.json) * VS Code/Cline (project .vscode/mcp.json) - Platform-specific JSON formats (VS Code uses different structure) - New flags: --register-mcp (registration only), --skip-mcp (skip prompt) - Automatic backup creation before modification - Project-specific configs enable team collaboration via version control Changes: - Add promptui dependency for interactive selection - Create internal/cmd/mcp_register.go with registration logic - Update internal/cmd/init.go with MCP registration flow - Support both global and project-specific MCP configurations šŸ¤– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- go.mod | 4 +- go.sum | 11 +- internal/cmd/init.go | 20 ++- internal/cmd/mcp_register.go | 319 +++++++++++++++++++++++++++++++++++ 4 files changed, 350 insertions(+), 4 deletions(-) create mode 100644 internal/cmd/mcp_register.go diff --git a/go.mod b/go.mod index eab5296..03f4c48 100644 --- a/go.mod +++ b/go.mod @@ -9,11 +9,13 @@ require ( ) require ( + github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/manifoldco/promptui v0.9.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/pflag v1.0.10 // indirect - golang.org/x/sys v0.15.0 // indirect github.com/stretchr/testify v1.11.1 // indirect + golang.org/x/sys v0.15.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 91857cd..3986c54 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,16 @@ 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/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/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= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +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= @@ -15,11 +21,12 @@ github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4 github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +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.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cmd/init.go b/internal/cmd/init.go index a41b634..7b83950 100644 --- a/internal/cmd/init.go +++ b/internal/cmd/init.go @@ -28,13 +28,26 @@ This command: Run: runInit, } -var initForce bool +var ( + initForce bool + skipMCPRegister bool + registerMCPOnly 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)") } func runInit(cmd *cobra.Command, args []string) { + // MCP registration only mode + if registerMCPOnly { + fmt.Println("šŸ”§ Registering Symphony MCP server...\n") + promptMCPRegistration() + return + } + // Check if logged in if !config.IsLoggedIn() { fmt.Println("āŒ Not logged in") @@ -115,6 +128,11 @@ 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() + } } // createDefaultPolicy creates a default policy file with RBAC roles diff --git a/internal/cmd/mcp_register.go b/internal/cmd/mcp_register.go new file mode 100644 index 0000000..bec47d2 --- /dev/null +++ b/internal/cmd/mcp_register.go @@ -0,0 +1,319 @@ +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" + os.WriteFile(backupPath, existingData, 0644) + fmt.Printf(" ⚠ Invalid JSON, backup created: %s\n", filepath.Base(backupPath)) + vscodeConfig = VSCodeMCPConfig{} + } else { + // Valid JSON, create backup + backupPath := configPath + ".bak" + os.WriteFile(backupPath, existingData, 0644) + 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" + os.WriteFile(backupPath, existingData, 0644) + fmt.Printf(" ⚠ Invalid JSON, backup created: %s\n", filepath.Base(backupPath)) + config = MCPRegistrationConfig{} + } else { + // Valid JSON, create backup + backupPath := configPath + ".bak" + os.WriteFile(backupPath, existingData, 0644) + 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 +} From b5492132ef94aec53545057ff337be6ceda4c701 Mon Sep 17 00:00:00 2001 From: baeyc0510 Date: Wed, 12 Nov 2025 16:05:52 +0900 Subject: [PATCH 2/4] feat: add OpenAI API key configuration to init Add interactive API key configuration during project initialization with support for both environment variables and .sym/.env file. Features: - Interactive prompt only when API key not found - Priority: system env var > .sym/.env file - Masked input for API key entry - Basic validation (sk- prefix, length check) - Automatic .gitignore update for .sym/.env - File permissions set to 0600 for security New flags: - --setup-api-key: Setup API key only (skip roles/policy init) - --skip-api-key: Skip API key configuration prompt Changes: - Create internal/cmd/api_key.go with key management logic - Add promptAPIKeyIfNeeded() to init.go workflow - Update convert, validate, mcp commands to use getAPIKey() - Support loading API key from .sym/.env file Benefits: - No need to set environment variables manually - Project-specific API keys (team collaboration) - Secure file storage with restrictive permissions - Backward compatible with existing env var setup - Can be configured later with 'sym init --setup-api-key' --- internal/cmd/api_key.go | 269 +++++++++++++++++++++++++++++++++++++++ internal/cmd/convert.go | 7 +- internal/cmd/init.go | 16 +++ internal/cmd/mcp.go | 6 +- internal/cmd/validate.go | 6 +- 5 files changed, 295 insertions(+), 9 deletions(-) create mode 100644 internal/cmd/api_key.go diff --git a/internal/cmd/api_key.go b/internal/cmd/api_key.go new file mode 100644 index 0000000..42950ef --- /dev/null +++ b/internal/cmd/api_key.go @@ -0,0 +1,269 @@ +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 with masking +func promptForAPIKey() (string, error) { + prompt := promptui.Prompt{ + Label: "Enter your OpenAI API key", + Mask: '*', + Validate: func(input string) error { + if len(input) == 0 { + return fmt.Errorf("API key cannot be empty") + } + return nil + }, + } + + result, err := prompt.Run() + if err != nil { + return "", err + } + + return strings.TrimSpace(result), nil +} + +// 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 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 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 7b83950..e8a806b 100644 --- a/internal/cmd/init.go +++ b/internal/cmd/init.go @@ -32,12 +32,16 @@ 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) { @@ -48,6 +52,13 @@ func runInit(cmd *cobra.Command, args []string) { return } + // API key setup only mode + if setupAPIKeyOnly { + fmt.Println("šŸ”‘ Setting up OpenAI API key...\n") + promptAPIKeySetup() + return + } + // Check if logged in if !config.IsLoggedIn() { fmt.Println("āŒ Not logged in") @@ -133,6 +144,11 @@ func runInit(cmd *cobra.Command, args []string) { 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 680f818..36ace5a 100644 --- a/internal/cmd/mcp.go +++ b/internal/cmd/mcp.go @@ -138,9 +138,9 @@ func autoConvertPolicy(userPolicyPath, codePolicyPath string) error { } // Setup LLM client - 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) } llmClient := llm.NewClient(apiKey, 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 From 4fe52dad1d03643134ecaf68b03c4a761f24d398 Mon Sep 17 00:00:00 2001 From: baeyc0510 Date: Wed, 12 Nov 2025 16:16:11 +0900 Subject: [PATCH 3/4] fix: remove redundant newlines in fmt.Println statements Remove '\n' from fmt.Println calls to fix go vet errors. fmt.Println automatically adds a newline, so explicit '\n' is redundant. --- internal/cmd/init.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/cmd/init.go b/internal/cmd/init.go index e8a806b..6f1045d 100644 --- a/internal/cmd/init.go +++ b/internal/cmd/init.go @@ -47,14 +47,14 @@ func init() { func runInit(cmd *cobra.Command, args []string) { // MCP registration only mode if registerMCPOnly { - fmt.Println("šŸ”§ Registering Symphony MCP server...\n") + fmt.Println("šŸ”§ Registering Symphony MCP server...") promptMCPRegistration() return } // API key setup only mode if setupAPIKeyOnly { - fmt.Println("šŸ”‘ Setting up OpenAI API key...\n") + fmt.Println("šŸ”‘ Setting up OpenAI API key...") promptAPIKeySetup() return } From 50bb52f8c9ab2e5c10a33d31d7607612f63eeffb Mon Sep 17 00:00:00 2001 From: baeyc0510 Date: Wed, 12 Nov 2025 16:36:50 +0900 Subject: [PATCH 4/4] fix: handle error returns in file operations for golangci-lint compliance - Add error checking for file.Close() calls in api_key.go - Add error checking for os.WriteFile() calls in mcp_register.go - Display warning messages when backup file creation fails - Fixes all errcheck linter violations in CI --- internal/cmd/api_key.go | 10 +++++----- internal/cmd/mcp_register.go | 28 ++++++++++++++++++++-------- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/internal/cmd/api_key.go b/internal/cmd/api_key.go index 42950ef..3ed284e 100644 --- a/internal/cmd/api_key.go +++ b/internal/cmd/api_key.go @@ -145,7 +145,7 @@ func hasAPIKeyInEnvFile(envPath string) bool { if err != nil { return false } - defer file.Close() + defer func() { _ = file.Close() }() scanner := bufio.NewScanner(file) for scanner.Scan() { @@ -181,7 +181,7 @@ func saveToEnvFile(envPath, key, value string) error { lines = append(lines, line) } } - existingFile.Close() + _ = existingFile.Close() } // Add new key @@ -210,11 +210,11 @@ func ensureGitignore(path string) error { lines = append(lines, line) // Check if already exists if strings.TrimSpace(line) == path { - existingFile.Close() + _ = existingFile.Close() return nil // Already in .gitignore } } - existingFile.Close() + _ = existingFile.Close() } // Add to .gitignore @@ -252,7 +252,7 @@ func loadFromEnvFile(envPath, key string) (string, error) { if err != nil { return "", err } - defer file.Close() + defer func() { _ = file.Close() }() scanner := bufio.NewScanner(file) for scanner.Scan() { diff --git a/internal/cmd/mcp_register.go b/internal/cmd/mcp_register.go index bec47d2..ad73006 100644 --- a/internal/cmd/mcp_register.go +++ b/internal/cmd/mcp_register.go @@ -176,14 +176,20 @@ func registerMCP(app string) error { if err := json.Unmarshal(existingData, &vscodeConfig); err != nil { // Invalid JSON, create backup backupPath := configPath + ".bak" - os.WriteFile(backupPath, existingData, 0644) - fmt.Printf(" ⚠ Invalid JSON, backup created: %s\n", filepath.Base(backupPath)) + 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" - os.WriteFile(backupPath, existingData, 0644) - fmt.Printf(" Backup: %s\n", filepath.Base(backupPath)) + 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") @@ -214,14 +220,20 @@ func registerMCP(app string) error { if err := json.Unmarshal(existingData, &config); err != nil { // Invalid JSON, create backup backupPath := configPath + ".bak" - os.WriteFile(backupPath, existingData, 0644) - fmt.Printf(" ⚠ Invalid JSON, backup created: %s\n", filepath.Base(backupPath)) + 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" - os.WriteFile(backupPath, existingData, 0644) - fmt.Printf(" Backup: %s\n", filepath.Base(backupPath)) + 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")