Skip to content

Commit 28ae1f4

Browse files
authored
Feat/mcp registration (#17)
* feat: add MCP server registration to init command 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 * 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' * 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. * 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 * fix: improve API key input handling to support paste operations - Replace promptui masked input with bufio.Reader for better paste support - Add cleanAPIKey function to remove control characters and whitespace - Fix duplicate label output when pasting API keys - Keep only printable ASCII characters (33-126) in API key input - Update .gitignore to include .sym/.env * fix: remove unused autoConvertPolicy function from mcp.go - Remove duplicate autoConvertPolicy function (moved to internal/mcp/server.go) - Clean up unused imports (context, encoding/json, converter, llm, schema) - Fix golangci-lint unused function warning ---------
1 parent eed1bdc commit 28ae1f4

File tree

8 files changed

+672
-10
lines changed

8 files changed

+672
-10
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,6 @@ node_modules/
1111
.sym/
1212
*.coverprofile
1313
coverage.txt
14+
15+
# Symphony API key configuration
16+
.sym/.env

go.mod

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,19 @@ require (
1010

1111
require (
1212
github.com/modelcontextprotocol/go-sdk v1.1.0
13+
github.com/manifoldco/promptui v0.9.0
1314
github.com/stretchr/testify v1.11.1
1415
)
1516

1617
require (
18+
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect
1719
github.com/davecgh/go-spew v1.1.1 // indirect
1820
github.com/google/jsonschema-go v0.3.0 // indirect
1921
github.com/inconshreveable/mousetrap v1.1.0 // indirect
2022
github.com/pmezard/go-difflib v1.0.0 // indirect
2123
github.com/spf13/pflag v1.0.10 // indirect
2224
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
2325
golang.org/x/oauth2 v0.30.0 // indirect
24-
golang.org/x/sys v0.15.0 // indirect
26+
golang.org/x/sys v0.38.0 // indirect
2527
gopkg.in/yaml.v3 v3.0.1 // indirect
2628
)

go.sum

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
22
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
3+
github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE=
4+
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
5+
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8=
6+
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
7+
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8=
8+
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
39
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
410
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
511
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
1117
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
1218
github.com/modelcontextprotocol/go-sdk v1.1.0 h1:Qjayg53dnKC4UZ+792W21e4BpwEZBzwgRW6LrjLWSwA=
1319
github.com/modelcontextprotocol/go-sdk v1.1.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10=
20+
github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA=
21+
github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg=
1422
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU=
1523
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
1624
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
2836
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
2937
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
3038
golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
31-
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
32-
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
3339
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
3440
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
41+
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
42+
golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
43+
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
44+
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
3545
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
3646
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
3747
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

internal/cmd/api_key.go

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
package cmd
2+
3+
import (
4+
"bufio"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
10+
"github.com/manifoldco/promptui"
11+
)
12+
13+
// promptAPIKeySetup prompts user to setup API key (without checking if it exists)
14+
func promptAPIKeySetup() {
15+
promptAPIKeyConfiguration(false)
16+
}
17+
18+
// promptAPIKeyIfNeeded checks if OpenAI API key is configured and prompts if not
19+
func promptAPIKeyIfNeeded() {
20+
promptAPIKeyConfiguration(true)
21+
}
22+
23+
// promptAPIKeyConfiguration handles API key configuration with optional existence check
24+
func promptAPIKeyConfiguration(checkExisting bool) {
25+
envPath := filepath.Join(".sym", ".env")
26+
27+
if checkExisting {
28+
// 1. Check environment variable
29+
if os.Getenv("OPENAI_API_KEY") != "" {
30+
fmt.Println("\n✓ OpenAI API key detected from environment")
31+
return
32+
}
33+
34+
// 2. Check .sym/.env file
35+
if hasAPIKeyInEnvFile(envPath) {
36+
fmt.Println("\n✓ OpenAI API key found in .sym/.env")
37+
return
38+
}
39+
40+
// Neither found - show warning
41+
fmt.Println("\n⚠ OpenAI API key not found")
42+
fmt.Println(" (Required for convert, validate commands and MCP auto-conversion)")
43+
fmt.Println()
44+
}
45+
46+
// Create selection prompt
47+
items := []string{
48+
"Enter API key",
49+
"Skip (set manually later)",
50+
}
51+
52+
templates := &promptui.SelectTemplates{
53+
Label: "{{ . }}?",
54+
Active: "▸ {{ . | cyan }}",
55+
Inactive: " {{ . }}",
56+
Selected: "✓ {{ . | green }}",
57+
}
58+
59+
selectPrompt := promptui.Select{
60+
Label: "Would you like to configure it now",
61+
Items: items,
62+
Templates: templates,
63+
Size: 2,
64+
}
65+
66+
index, _, err := selectPrompt.Run()
67+
if err != nil {
68+
fmt.Println("\nSkipped API key configuration")
69+
return
70+
}
71+
72+
switch index {
73+
case 0: // Enter API key
74+
apiKey, err := promptForAPIKey()
75+
if err != nil {
76+
fmt.Printf("\n❌ Failed to read API key: %v\n", err)
77+
return
78+
}
79+
80+
// Validate API key format
81+
if err := validateAPIKey(apiKey); err != nil {
82+
fmt.Printf("\n⚠ Warning: %v\n", err)
83+
fmt.Println(" API key was saved anyway. Make sure it's correct.")
84+
}
85+
86+
// Save to .sym/.env
87+
if err := saveToEnvFile(envPath, "OPENAI_API_KEY", apiKey); err != nil {
88+
fmt.Printf("\n❌ Failed to save API key: %v\n", err)
89+
return
90+
}
91+
92+
fmt.Println("\n✓ API key saved to .sym/.env")
93+
94+
// Add to .gitignore
95+
if err := ensureGitignore(".sym/.env"); err != nil {
96+
fmt.Printf("⚠ Warning: Failed to update .gitignore: %v\n", err)
97+
fmt.Println(" Please manually add '.sym/.env' to .gitignore")
98+
} else {
99+
fmt.Println("✓ Added .sym/.env to .gitignore")
100+
}
101+
102+
case 1: // Skip
103+
fmt.Println("\nSkipped API key configuration")
104+
fmt.Println("\n💡 Tip: You can set OPENAI_API_KEY in:")
105+
fmt.Println(" - .sym/.env file")
106+
fmt.Println(" - System environment variable")
107+
}
108+
}
109+
110+
// promptForAPIKey prompts user to enter API key
111+
func promptForAPIKey() (string, error) {
112+
fmt.Print("Enter your OpenAI API key: ")
113+
114+
// Use bufio reader for better paste support
115+
reader := bufio.NewReader(os.Stdin)
116+
input, err := reader.ReadString('\n')
117+
if err != nil {
118+
return "", fmt.Errorf("failed to read API key: %w", err)
119+
}
120+
121+
// Clean the input: remove all whitespace, control characters, and non-printable characters
122+
apiKey := cleanAPIKey(input)
123+
124+
if len(apiKey) == 0 {
125+
return "", fmt.Errorf("API key cannot be empty")
126+
}
127+
128+
return apiKey, nil
129+
}
130+
131+
// cleanAPIKey removes whitespace, control characters, and non-printable characters from API key
132+
func cleanAPIKey(input string) string {
133+
var result strings.Builder
134+
for _, r := range input {
135+
// Only keep printable ASCII characters (excluding space)
136+
if r >= 33 && r <= 126 {
137+
result.WriteRune(r)
138+
}
139+
}
140+
return result.String()
141+
}
142+
143+
// validateAPIKey performs basic validation on API key format
144+
func validateAPIKey(key string) error {
145+
if !strings.HasPrefix(key, "sk-") {
146+
return fmt.Errorf("API key should start with 'sk-'")
147+
}
148+
if len(key) < 20 {
149+
return fmt.Errorf("API key seems too short")
150+
}
151+
return nil
152+
}
153+
154+
// hasAPIKeyInEnvFile checks if OPENAI_API_KEY exists in .env file
155+
func hasAPIKeyInEnvFile(envPath string) bool {
156+
file, err := os.Open(envPath)
157+
if err != nil {
158+
return false
159+
}
160+
defer func() { _ = file.Close() }()
161+
162+
scanner := bufio.NewScanner(file)
163+
for scanner.Scan() {
164+
line := strings.TrimSpace(scanner.Text())
165+
if strings.HasPrefix(line, "OPENAI_API_KEY=") {
166+
parts := strings.SplitN(line, "=", 2)
167+
if len(parts) == 2 && strings.TrimSpace(parts[1]) != "" {
168+
return true
169+
}
170+
}
171+
}
172+
173+
return false
174+
}
175+
176+
// saveToEnvFile saves a key-value pair to .env file
177+
func saveToEnvFile(envPath, key, value string) error {
178+
// Create .sym directory if it doesn't exist
179+
symDir := filepath.Dir(envPath)
180+
if err := os.MkdirAll(symDir, 0755); err != nil {
181+
return fmt.Errorf("failed to create .sym directory: %w", err)
182+
}
183+
184+
// Read existing content
185+
var lines []string
186+
existingFile, err := os.Open(envPath)
187+
if err == nil {
188+
scanner := bufio.NewScanner(existingFile)
189+
for scanner.Scan() {
190+
line := scanner.Text()
191+
// Skip existing OPENAI_API_KEY lines
192+
if !strings.HasPrefix(strings.TrimSpace(line), key+"=") {
193+
lines = append(lines, line)
194+
}
195+
}
196+
_ = existingFile.Close()
197+
}
198+
199+
// Add new key
200+
lines = append(lines, fmt.Sprintf("%s=%s", key, value))
201+
202+
// Write to file with restrictive permissions (owner read/write only)
203+
content := strings.Join(lines, "\n") + "\n"
204+
if err := os.WriteFile(envPath, []byte(content), 0600); err != nil {
205+
return fmt.Errorf("failed to write .env file: %w", err)
206+
}
207+
208+
return nil
209+
}
210+
211+
// ensureGitignore ensures that the given path is in .gitignore
212+
func ensureGitignore(path string) error {
213+
gitignorePath := ".gitignore"
214+
215+
// Read existing .gitignore
216+
var lines []string
217+
existingFile, err := os.Open(gitignorePath)
218+
if err == nil {
219+
scanner := bufio.NewScanner(existingFile)
220+
for scanner.Scan() {
221+
line := scanner.Text()
222+
lines = append(lines, line)
223+
// Check if already exists
224+
if strings.TrimSpace(line) == path {
225+
_ = existingFile.Close()
226+
return nil // Already in .gitignore
227+
}
228+
}
229+
_ = existingFile.Close()
230+
}
231+
232+
// Add to .gitignore
233+
lines = append(lines, "", "# Symphony API key configuration", path)
234+
content := strings.Join(lines, "\n") + "\n"
235+
236+
if err := os.WriteFile(gitignorePath, []byte(content), 0644); err != nil {
237+
return fmt.Errorf("failed to update .gitignore: %w", err)
238+
}
239+
240+
return nil
241+
}
242+
243+
// getAPIKey retrieves OpenAI API key from environment or .env file
244+
// Priority: 1) System environment variable 2) .sym/.env file
245+
func getAPIKey() (string, error) {
246+
// 1. Check system environment variable first
247+
if key := os.Getenv("OPENAI_API_KEY"); key != "" {
248+
return key, nil
249+
}
250+
251+
// 2. Check .sym/.env file
252+
envPath := filepath.Join(".sym", ".env")
253+
key, err := loadFromEnvFile(envPath, "OPENAI_API_KEY")
254+
if err == nil && key != "" {
255+
return key, nil
256+
}
257+
258+
return "", fmt.Errorf("OPENAI_API_KEY not found in environment or .sym/.env")
259+
}
260+
261+
// loadFromEnvFile loads a specific key from .env file
262+
func loadFromEnvFile(envPath, key string) (string, error) {
263+
file, err := os.Open(envPath)
264+
if err != nil {
265+
return "", err
266+
}
267+
defer func() { _ = file.Close() }()
268+
269+
scanner := bufio.NewScanner(file)
270+
for scanner.Scan() {
271+
line := strings.TrimSpace(scanner.Text())
272+
if strings.HasPrefix(line, key+"=") {
273+
parts := strings.SplitN(line, "=", 2)
274+
if len(parts) == 2 {
275+
return strings.TrimSpace(parts[1]), nil
276+
}
277+
}
278+
}
279+
280+
return "", fmt.Errorf("key %s not found in %s", key, envPath)
281+
}

internal/cmd/convert.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,9 +133,10 @@ func runMultiTargetConvert(userPolicy *schema.UserPolicy) error {
133133
}
134134

135135
// Setup OpenAI client
136-
apiKey := os.Getenv("OPENAI_API_KEY")
137-
if apiKey == "" {
138-
fmt.Println("Warning: OPENAI_API_KEY not set, using fallback inference")
136+
apiKey, err := getAPIKey()
137+
if err != nil {
138+
fmt.Printf("Warning: %v, using fallback inference\n", err)
139+
apiKey = ""
139140
}
140141

141142
timeout := time.Duration(convertTimeout) * time.Second

0 commit comments

Comments
 (0)