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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,6 @@ node_modules/
.sym/
*.coverprofile
coverage.txt

# Symphony API key configuration
.sym/.env
4 changes: 3 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,19 @@ 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
github.com/pmezard/go-difflib v1.0.0 // indirect
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
)
14 changes: 12 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand All @@ -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=
Expand All @@ -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=
Expand Down
281 changes: 281 additions & 0 deletions internal/cmd/api_key.go
Original file line number Diff line number Diff line change
@@ -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)
}
7 changes: 4 additions & 3 deletions internal/cmd/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading