From e0afd545a39c7865d0e97d40d59d262c0361a03b Mon Sep 17 00:00:00 2001 From: ikjeong Date: Tue, 9 Dec 2025 08:44:16 +0000 Subject: [PATCH 01/12] refactor: simplify convert command interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - --targets 플래그 제거, 언어 기반 라우팅으로 단순화 --- internal/cmd/convert.go | 37 +++++++++---------------------------- 1 file changed, 9 insertions(+), 28 deletions(-) diff --git a/internal/cmd/convert.go b/internal/cmd/convert.go index 270b46c..173c5f5 100644 --- a/internal/cmd/convert.go +++ b/internal/cmd/convert.go @@ -5,11 +5,9 @@ import ( "encoding/json" "fmt" "os" - "strings" "time" "github.com/DevSymphony/sym-cli/internal/config" - "github.com/DevSymphony/sym-cli/internal/linter" "github.com/DevSymphony/sym-cli/internal/converter" "github.com/DevSymphony/sym-cli/internal/llm" "github.com/DevSymphony/sym-cli/internal/ui" @@ -19,7 +17,6 @@ import ( var ( convertInputFile string - convertTargets []string convertOutputDir string ) @@ -29,36 +26,20 @@ var convertCmd = &cobra.Command{ Long: `Convert natural language policies (Schema A) written by users into linter-specific configurations and internal validation schema (Schema B). -Supported linters are dynamically determined from registered adapters. -Uses OpenAI API to intelligently analyze natural language rules and -map them to appropriate linter rules.`, - Example: ` # Convert to all supported linters (outputs to /.sym) - sym convert -i user-policy.json --targets all +The conversion uses language-based routing with LLM inference to determine +which linters apply to each rule. Supported linters include ESLint, Prettier, +Pylint, TSC, Checkstyle, and PMD.`, + Example: ` # Convert policy (outputs to .sym directory) + sym convert -i user-policy.json - # Convert for specific linter - sym convert -i user-policy.json --targets eslint - - # Convert for Java with specific model - sym convert -i user-policy.json --targets checkstyle,pmd - - # Use custom output directory - sym convert -i user-policy.json --targets all --output-dir ./custom-dir`, + # Convert with custom output directory + sym convert -i user-policy.json -o ./custom-dir`, RunE: runConvert, } func init() { - convertCmd.Flags().StringVarP(&convertInputFile, "input", "i", "", "input user policy file (default: from .sym/.env POLICY_PATH)") - convertCmd.Flags().StringSliceVar(&convertTargets, "targets", []string{}, buildTargetsDescription()) - convertCmd.Flags().StringVar(&convertOutputDir, "output-dir", "", "output directory for linter configs (default: same as input file directory)") -} - -// buildTargetsDescription dynamically builds the --targets flag description -func buildTargetsDescription() string { - tools := linter.Global().GetAllToolNames() - if len(tools) == 0 { - return "target linters (or 'all')" - } - return fmt.Sprintf("target linters (%s or 'all')", strings.Join(tools, ",")) + convertCmd.Flags().StringVarP(&convertInputFile, "input", "i", "", "input user policy file (default: from .sym/config.json)") + convertCmd.Flags().StringVarP(&convertOutputDir, "output-dir", "o", "", "output directory for linter configs (default: .sym)") } func runConvert(cmd *cobra.Command, args []string) error { From 201ae0ab6fd7d584df0e766aa69f2085abb1edfc Mon Sep 17 00:00:00 2001 From: ikjeong Date: Tue, 9 Dec 2025 08:44:26 +0000 Subject: [PATCH 02/12] refactor: restructure init command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - --register-mcp, --setup-llm 플래그 제거 - .sym/config.json 생성 추가 --- internal/cmd/init.go | 64 ++++++++++++++++++++------------------------ 1 file changed, 29 insertions(+), 35 deletions(-) diff --git a/internal/cmd/init.go b/internal/cmd/init.go index 2e10610..22eccf8 100644 --- a/internal/cmd/init.go +++ b/internal/cmd/init.go @@ -18,65 +18,52 @@ import ( var initCmd = &cobra.Command{ Use: "init", Short: "Initialize Symphony for the current directory", - Long: `Create a .sym directory with roles.json and user-policy.json files. + Long: `Initialize Symphony for the current project. This command: 1. Creates .sym/roles.json with default roles (admin, developer, viewer) 2. Creates .sym/user-policy.json with default RBAC configuration - 3. Sets your role to admin (can be changed later via dashboard) - 4. Optionally registers MCP server for AI tools`, + 3. Creates .sym/config.json with default settings + 4. Sets your role to admin (can be changed later via dashboard) + 5. Optionally registers MCP server for AI tools + 6. Optionally configures LLM backend + +Use --force to reinitialize an existing Symphony project.`, Run: runInit, } var ( initForce bool skipMCPRegister bool - registerMCPOnly bool skipLLMSetup bool - setupLLMOnly bool ) func init() { - initCmd.Flags().BoolVarP(&initForce, "force", "f", false, "Overwrite existing roles.json") + initCmd.Flags().BoolVarP(&initForce, "force", "f", false, "Overwrite existing Symphony configuration") 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(&skipLLMSetup, "skip-llm", false, "Skip LLM backend configuration prompt") - initCmd.Flags().BoolVar(&setupLLMOnly, "setup-llm", false, "Setup LLM backend only (skip roles/policy init)") } func runInit(cmd *cobra.Command, args []string) { - // MCP registration only mode - if registerMCPOnly { - ui.PrintTitle("MCP", "Registering Symphony MCP server") - promptMCPRegistration() - return - } - - // LLM setup only mode - if setupLLMOnly { - ui.PrintTitle("LLM", "Setting up LLM backend") - promptLLMBackendSetup() - return - } - - // Check if roles.json already exists - exists, err := roles.RolesExists() + // Check if .sym directory already exists + symDir, err := getSymDir() if err != nil { - ui.PrintError(fmt.Sprintf("Failed to check roles.json: %v", err)) + ui.PrintError(fmt.Sprintf("Failed to determine .sym directory: %v", err)) os.Exit(1) } - if exists && !initForce { - ui.PrintWarn("roles.json already exists") - fmt.Println("Use --force flag to overwrite") + symDirExists := false + if _, err := os.Stat(symDir); err == nil { + symDirExists = true + } else if !os.IsNotExist(err) { + ui.PrintError(fmt.Sprintf("Failed to check .sym directory: %v", err)) os.Exit(1) } - // If force flag is set, remove existing code-policy.json - if initForce { - if err := removeExistingCodePolicy(); err != nil { - ui.PrintWarn(fmt.Sprintf("Failed to remove existing code-policy.json: %v", err)) - } + if symDirExists && !initForce { + ui.PrintWarn(".sym directory already exists") + fmt.Println("Use --force flag to reinitialize") + os.Exit(1) } // Create default roles (empty user lists - users select their own role) @@ -127,6 +114,13 @@ func runInit(cmd *cobra.Command, args []string) { promptLLMBackendSetup() } + // Clean up generated files at the end (only when --force is set) + if initForce { + if err := removeExistingCodePolicy(); err != nil { + ui.PrintWarn(fmt.Sprintf("Failed to remove generated files: %v", err)) + } + } + // Show completion message fmt.Println() ui.PrintDone("Initialization complete") @@ -189,8 +183,8 @@ func createDefaultPolicy() error { // initializeConfigFile creates .sym/config.json with default settings func initializeConfigFile() error { - // Check if config.json already exists - if config.ProjectConfigExists() { + // Check if config.json already exists (skip unless force is set) + if config.ProjectConfigExists() && !initForce { return nil } From fd56e98726d4eeb4db44d44a368b30b871cd504f Mon Sep 17 00:00:00 2001 From: ikjeong Date: Tue, 9 Dec 2025 08:44:36 +0000 Subject: [PATCH 03/12] refactor: add Symphony section markers to mcp register MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Symphony 섹션 마커 추가 (idempotent 업데이트) - claude.md → CLAUDE.md 네이밍 규칙 적용 - 도움말 텍스트 업데이트 --- internal/cmd/mcp_register.go | 98 +++++++++++++++++++++--------------- 1 file changed, 57 insertions(+), 41 deletions(-) diff --git a/internal/cmd/mcp_register.go b/internal/cmd/mcp_register.go index 1baa5a4..f86b03b 100644 --- a/internal/cmd/mcp_register.go +++ b/internal/cmd/mcp_register.go @@ -13,6 +13,12 @@ import ( "github.com/DevSymphony/sym-cli/internal/ui" ) +// Section markers for Symphony instructions in append-mode files (CLAUDE.md, .clinerules) +const ( + symphonySectionStart = "" + symphonySectionEnd = "" +) + // MCPRegistrationConfig represents the MCP configuration structure // Used for Claude Desktop, Claude Code, Cursor, Cline (mcpServers format) type MCPRegistrationConfig struct { @@ -99,7 +105,7 @@ func promptMCPRegistration() { // If no tools selected, skip if len(selectedTools) == 0 { fmt.Println("Skipped MCP registration") - fmt.Println(ui.Indent("Tip: Run 'sym init --register-mcp' to register MCP later")) + fmt.Println(ui.Indent("Tip: Run 'sym mcp register' to register MCP later")) return } @@ -158,7 +164,7 @@ func registerMCP(app string) error { if fileExists { if err := json.Unmarshal(existingData, &vscodeConfig); err != nil { - // Invalid JSON, create backup + // Invalid JSON, create backup and start fresh backupPath := configPath + ".bak" if err := os.WriteFile(backupPath, existingData, 0644); err != nil { fmt.Println(ui.Indent(fmt.Sprintf("Failed to create backup: %v", err))) @@ -166,15 +172,8 @@ func registerMCP(app string) error { fmt.Println(ui.Indent(fmt.Sprintf("Invalid JSON, backup created: %s", filepath.Base(backupPath)))) } vscodeConfig = VSCodeMCPConfig{} - } else { - // Valid JSON, create backup - backupPath := configPath + ".bak" - if err := os.WriteFile(backupPath, existingData, 0644); err != nil { - fmt.Println(ui.Indent(fmt.Sprintf("Failed to create backup: %v", err))) - } else { - fmt.Println(ui.Indent(fmt.Sprintf("Backup: %s", filepath.Base(backupPath)))) - } } + // Valid JSON: no backup needed, just update symphony entry } else { fmt.Println(ui.Indent("Creating new configuration file")) } @@ -202,7 +201,7 @@ func registerMCP(app string) error { if fileExists { if err := json.Unmarshal(existingData, &config); err != nil { - // Invalid JSON, create backup + // Invalid JSON, create backup and start fresh backupPath := configPath + ".bak" if err := os.WriteFile(backupPath, existingData, 0644); err != nil { fmt.Println(ui.Indent(fmt.Sprintf("Failed to create backup: %v", err))) @@ -210,15 +209,8 @@ func registerMCP(app string) error { fmt.Println(ui.Indent(fmt.Sprintf("Invalid JSON, backup created: %s", filepath.Base(backupPath)))) } config = MCPRegistrationConfig{} - } else { - // Valid JSON, create backup - backupPath := configPath + ".bak" - if err := os.WriteFile(backupPath, existingData, 0644); err != nil { - fmt.Println(ui.Indent(fmt.Sprintf("Failed to create backup: %v", err))) - } else { - fmt.Println(ui.Indent(fmt.Sprintf("Backup: %s", filepath.Base(backupPath)))) - } } + // Valid JSON: no backup needed, just update symphony entry } else { fmt.Println(ui.Indent("Creating new configuration file")) } @@ -331,6 +323,33 @@ func checkNpxAvailable() bool { return err == nil } +// updateSymphonySection updates or appends the Symphony section in content +func updateSymphonySection(existingContent, symphonyContent string) string { + startIdx := strings.Index(existingContent, symphonySectionStart) + endIdx := strings.Index(existingContent, symphonySectionEnd) + + // Case 1: Symphony section exists → replace + if startIdx != -1 && endIdx != -1 && endIdx > startIdx { + endIdx += len(symphonySectionEnd) + return existingContent[:startIdx] + symphonyContent + existingContent[endIdx:] + } + + // Case 2: No Symphony section → append at end + if existingContent == "" { + return symphonyContent + } + + // Ensure proper spacing + separator := "\n\n" + if strings.HasSuffix(existingContent, "\n\n") { + separator = "" + } else if strings.HasSuffix(existingContent, "\n") { + separator = "\n" + } + + return existingContent + separator + symphonyContent +} + // createInstructionsFile creates or updates the instructions file for the specified app func createInstructionsFile(app string) error { var instructionsPath string @@ -339,7 +358,7 @@ func createInstructionsFile(app string) error { switch app { case "claude-code": - instructionsPath = "claude.md" + instructionsPath = "CLAUDE.md" content = getClaudeCodeInstructions() appendMode = true case "cursor": @@ -367,14 +386,16 @@ func createInstructionsFile(app string) error { if fileExists { if appendMode { - // Check if Symphony instructions already exist - if strings.Contains(string(existingContent), "# Symphony Code Conventions") { - fmt.Println(ui.Indent(fmt.Sprintf("Instructions already exist in %s", instructionsPath))) - return nil + // Update Symphony section (replace if exists, append if not) + existingStr := string(existingContent) + content = updateSymphonySection(existingStr, content) + + // Determine message based on whether section was replaced or appended + if strings.Contains(existingStr, symphonySectionStart) { + fmt.Println(ui.Indent(fmt.Sprintf("Updated Symphony section in %s", instructionsPath))) + } else { + fmt.Println(ui.Indent(fmt.Sprintf("Appended Symphony section to %s", instructionsPath))) } - // Append to existing file - content = string(existingContent) + "\n\n" + content - fmt.Println(ui.Indent(fmt.Sprintf("Appended Symphony instructions to %s", instructionsPath))) } else { // Create backup backupPath := instructionsPath + ".bak" @@ -416,9 +437,10 @@ func createInstructionsFile(app string) error { return nil } -// getClaudeCodeInstructions returns instructions for Claude Code (claude.md) +// getClaudeCodeInstructions returns instructions for Claude Code (CLAUDE.md) func getClaudeCodeInstructions() string { - return `# Symphony Code Conventions + return symphonySectionStart + ` +# Symphony Code Conventions **This project uses Symphony MCP for automated code convention management.** @@ -428,13 +450,13 @@ func getClaudeCodeInstructions() string { **Check MCP Status**: Verify Symphony MCP server is active. If unavailable, warn the user and do not proceed. -**Query Conventions**: Use ` + "`symphony/query_conventions`" + ` to retrieve relevant rules. +**Query Conventions**: Use ` + "`mcp__symphony__query_conventions`" + ` to retrieve relevant rules. - Select appropriate category: security, style, documentation, error_handling, architecture, performance, testing - Filter by languages as needed ### 2. After Writing Code -**Validate Changes**: Always run ` + "`symphony/validate_code`" + ` to check all changes against project conventions. +**Validate Changes**: Always run ` + "`mcp__symphony__validate_code`" + ` to check all changes against project conventions. **Fix Violations**: Address any issues found before committing. @@ -446,11 +468,7 @@ func getClaudeCodeInstructions() string { 4. Validate with Symphony 5. Fix violations 6. Commit - ---- - -*Auto-generated by Symphony* -` +` + symphonySectionEnd + "\n" } // getCursorInstructions returns instructions for Cursor (.cursor/rules/symphony.mdc) @@ -526,7 +544,8 @@ Auto-generated by Symphony // getClineInstructions returns instructions for Cline (.clinerules) func getClineInstructions() string { - return `# Symphony Code Conventions + return symphonySectionStart + ` +# Symphony Code Conventions This project uses Symphony MCP for automated code convention management. @@ -545,8 +564,5 @@ This project uses Symphony MCP for automated code convention management. ## Summary Always: Check MCP → Query Conventions → Write Code → Validate → Fix → Commit - ---- -Auto-generated by Symphony -` +` + symphonySectionEnd + "\n" } From 4c44d84f5b1cb155fc8784efdb31228027ae3704 Mon Sep 17 00:00:00 2001 From: ikjeong Date: Tue, 9 Dec 2025 08:44:46 +0000 Subject: [PATCH 04/12] refactor: improve my-role selection UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bufio.ReadString → survey.Select로 변경 --- internal/cmd/my_role.go | 37 ++++++++++++++----------------------- 1 file changed, 14 insertions(+), 23 deletions(-) diff --git a/internal/cmd/my_role.go b/internal/cmd/my_role.go index 8de2b2e..ef83156 100644 --- a/internal/cmd/my_role.go +++ b/internal/cmd/my_role.go @@ -1,13 +1,11 @@ package cmd import ( - "bufio" "encoding/json" "fmt" "os" - "strconv" - "strings" + "github.com/AlecAivazis/survey/v2" "github.com/DevSymphony/sym-cli/internal/roles" "github.com/DevSymphony/sym-cli/internal/ui" @@ -78,6 +76,10 @@ func runMyRole(cmd *cobra.Command, args []string) { } func selectNewRole() { + // Use custom template to hide "type to filter" and typed characters + restore := useSelectTemplateNoFilter() + defer restore() + availableRoles, err := roles.GetAvailableRoles() if err != nil { ui.PrintError(fmt.Sprintf("Failed to get available roles: %v", err)) @@ -91,34 +93,23 @@ func selectNewRole() { currentRole, _ := roles.GetCurrentRole() - ui.PrintTitle("Role", "Select your role") fmt.Println() - for i, role := range availableRoles { - marker := " " - if role == currentRole { - marker = "→ " - } - fmt.Printf("%s%d. %s\n", marker, i+1, role) - } + ui.PrintTitle("Role", "Select your role") fmt.Println() - reader := bufio.NewReader(os.Stdin) - fmt.Print("Enter number (1-" + strconv.Itoa(len(availableRoles)) + "): ") - input, _ := reader.ReadString('\n') - input = strings.TrimSpace(input) + // Use survey.Select for consistent UI + var selectedRole string + prompt := &survey.Select{ + Message: "Select role:", + Options: availableRoles, + Default: currentRole, + } - if input == "" { + if err := survey.AskOne(prompt, &selectedRole); err != nil { ui.PrintWarn("No selection made") return } - num, err := strconv.Atoi(input) - if err != nil || num < 1 || num > len(availableRoles) { - ui.PrintError("Invalid selection") - os.Exit(1) - } - - selectedRole := availableRoles[num-1] if err := roles.SetCurrentRole(selectedRole); err != nil { ui.PrintError(fmt.Sprintf("Failed to save role: %v", err)) os.Exit(1) From 3f6cc8d7b338a0e42b17aee2f465c7569756b556 Mon Sep 17 00:00:00 2001 From: ikjeong Date: Tue, 9 Dec 2025 08:44:56 +0000 Subject: [PATCH 05/12] fix: update llm command help text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sym init --setup-llm → sym llm setup 안내 수정 --- internal/cmd/llm.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cmd/llm.go b/internal/cmd/llm.go index f8ce60c..8449983 100644 --- a/internal/cmd/llm.go +++ b/internal/cmd/llm.go @@ -223,7 +223,7 @@ func promptLLMBackendSetup() { if selectedDisplayName == "Skip" { fmt.Println("Skipped LLM configuration") - fmt.Println(ui.Indent("Tip: Run 'sym init --setup-llm' to configure later")) + fmt.Println(ui.Indent("Tip: Run 'sym llm setup' to configure later")) return } From 3d35fe9eeaa8159851be316abe6e21ad169543ff Mon Sep 17 00:00:00 2001 From: ikjeong Date: Tue, 9 Dec 2025 08:45:07 +0000 Subject: [PATCH 06/12] docs: consolidate documentation into command reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CONVERT_FEATURE.md, CONVERT_USAGE.md 삭제 - LINTER_VALIDATION.md, LLM_VALIDATOR.md 삭제 - command.md 추가 (통합 CLI 레퍼런스) --- docs/CONVERT_FEATURE.md | 287 --------------- docs/CONVERT_USAGE.md | 392 --------------------- docs/LINTER_VALIDATION.md | 87 ----- docs/LLM_VALIDATOR.md | 161 --------- docs/command.md | 717 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 717 insertions(+), 927 deletions(-) delete mode 100644 docs/CONVERT_FEATURE.md delete mode 100644 docs/CONVERT_USAGE.md delete mode 100644 docs/LINTER_VALIDATION.md delete mode 100644 docs/LLM_VALIDATOR.md create mode 100644 docs/command.md diff --git a/docs/CONVERT_FEATURE.md b/docs/CONVERT_FEATURE.md deleted file mode 100644 index 23ece53..0000000 --- a/docs/CONVERT_FEATURE.md +++ /dev/null @@ -1,287 +0,0 @@ -# Convert 기능 - 다중 타겟 Linter 설정 생성기 - -## 개요 - -`convert` 명령어는 자연어 코딩 컨벤션을 LLM 기반 추론을 통해 linter별 설정 파일로 변환합니다. - -## 기능 - -- **LLM 기반 추론**: OpenAI API를 사용하여 자연어 규칙을 분석 -- **다중 타겟 지원**: 여러 linter 설정을 동시에 생성 - - **ESLint** (JavaScript/TypeScript) - - **Checkstyle** (Java) - - **PMD** (Java) -- **폴백 메커니즘**: LLM 사용 불가 시 패턴 기반 추론 -- **신뢰도 점수**: 추론 신뢰도를 추적하고 낮은 신뢰도에 대해 경고 -- **1:N 규칙 매핑**: 하나의 사용자 규칙이 여러 linter 규칙을 생성 -- **캐싱**: 추론 결과를 캐싱하여 API 호출 최소화 - -## 아키텍처 - -``` -User Policy (user-policy.json) - | - Converter - | - LLM Inference (OpenAI API) <- Fallback (Pattern Matching) - | - Rule Intent Detection - | - Linter Converters - +-- ESLint Converter -> .eslintrc.json - +-- Checkstyle Converter -> checkstyle.xml - +-- PMD Converter -> pmd-ruleset.xml -``` - -### 패키지 구조 - -``` -internal/ -+-- llm/ -| +-- client.go # OpenAI API 클라이언트 -| +-- inference.go # 규칙 추론 엔진 -| +-- types.go # Intent 및 결과 타입 -+-- converter/ -| +-- converter.go # 메인 변환 로직 -| +-- linters/ -| +-- converter.go # Linter 변환기 인터페이스 -| +-- registry.go # 변환기 레지스트리 -| +-- eslint.go # ESLint 변환기 -| +-- checkstyle.go # Checkstyle 변환기 -| +-- pmd.go # PMD 변환기 -+-- cmd/ - +-- convert.go # CLI 명령어 -``` - -## 사용법 - -### 기본 사용 - -```bash -# 모든 지원 linter로 변환 -sym convert -i user-policy.json --targets all --output-dir .linters - -# JavaScript/TypeScript만 -sym convert -i user-policy.json --targets eslint --output-dir .linters - -# Java만 -sym convert -i user-policy.json --targets checkstyle,pmd --output-dir .linters -``` - -### 고급 옵션 - -```bash -# 특정 OpenAI 모델 사용 -sym convert -i user-policy.json \ - --targets all \ - --output-dir .linters \ - --openai-model gpt-4o - -# 신뢰도 임계값 조정 -sym convert -i user-policy.json \ - --targets eslint \ - --output-dir .linters \ - --confidence-threshold 0.8 - -# 상세 출력 활성화 -sym convert -i user-policy.json \ - --targets all \ - --output-dir .linters \ - --verbose -``` - -### 레거시 모드 - -```bash -# 내부 code-policy.json만 생성 (linter 설정 없음) -sym convert -i user-policy.json -o code-policy.json -``` - -## 설정 - -### 환경 변수 - -- `OPENAI_API_KEY`: OpenAI API 키 (LLM 추론에 필요) - - 미설정 시 폴백 패턴 기반 추론 사용 - -### 플래그 - -- `--targets`: 타겟 linter (쉼표로 구분 또는 "all") -- `--output-dir`: 생성 파일 출력 디렉토리 -- `--openai-model`: 사용할 OpenAI 모델 (기본값: gpt-4o) -- `--confidence-threshold`: 추론 최소 신뢰도 (기본값: 0.7) -- `--timeout`: API 호출 타임아웃 초 (기본값: 30) -- `--verbose`: 상세 로깅 활성화 - -## 사용자 정책 스키마 - -### 예시 - -```json -{ - "version": "1.0.0", - "defaults": { - "severity": "error", - "autofix": false - }, - "rules": [ - { - "id": "naming-class-pascalcase", - "say": "클래스 이름은 PascalCase여야 합니다", - "category": "naming", - "languages": ["javascript", "typescript", "java"], - "params": { - "case": "PascalCase" - } - }, - { - "id": "length-max-line", - "say": "한 줄은 최대 100자입니다", - "category": "length", - "params": { - "max": 100 - } - } - ] -} -``` - -### 지원 카테고리 - -- `naming`: 식별자 네이밍 컨벤션 -- `length`: 크기 제약 (라인/파일/함수 길이) -- `style`: 코드 포맷팅 (들여쓰기, 따옴표, 세미콜론) -- `complexity`: 순환/인지 복잡도 -- `security`: 보안 관련 규칙 -- `error_handling`: 예외 처리 패턴 -- `dependency`: import/의존성 제한 - -### 지원 엔진 타입 - -- `pattern`: 네이밍 컨벤션, 금지 패턴, import 제한 -- `length`: 라인/파일/함수 길이, 파라미터 수 -- `style`: 들여쓰기, 따옴표, 세미콜론, 공백 -- `ast`: 순환 복잡도, 중첩 깊이 -- `custom`: 기타 카테고리에 맞지 않는 규칙 - -## 출력 파일 - -### 생성 파일 - -1. **`.eslintrc.json`**: JavaScript/TypeScript용 ESLint 설정 -2. **`checkstyle.xml`**: Java용 Checkstyle 설정 -3. **`pmd-ruleset.xml`**: Java용 PMD 규칙셋 -4. **`code-policy.json`**: 내부 검증 정책 -5. **`conversion-report.json`**: 상세 변환 리포트 - -### 변환 리포트 형식 - -```json -{ - "timestamp": "2025-10-30T19:52:22+09:00", - "input_file": "user-policy.json", - "total_rules": 5, - "targets": ["eslint", "checkstyle", "pmd"], - "openai_model": "gpt-4o", - "confidence_threshold": 0.7, - "linters": { - "eslint": { - "rules_generated": 5, - "warnings": 2, - "errors": 0 - } - }, - "warnings": [ - "eslint: Rule 2: low confidence (0.40 < 0.70): 한 줄은 최대 100자입니다" - ] -} -``` - -## LLM 추론 - -### 동작 방식 - -1. **캐시 확인**: 먼저 규칙이 이전에 추론되었는지 확인 -2. **LLM 분석**: 구조화된 프롬프트와 함께 OpenAI API로 규칙 전송 -3. **Intent 추출**: JSON 응답을 파싱하여 추출: - - 엔진 타입 (pattern/length/style/ast) - - 카테고리 (naming/security 등) - - 타겟 (identifier/content/import) - - 범위 (line/file/function) - - 파라미터 (max, min, case 등) - - 신뢰도 점수 (0.0-1.0) -4. **폴백**: LLM 실패 시 패턴 매칭 사용 -5. **변환**: Intent를 linter별 규칙으로 매핑 - -### 폴백 추론 - -LLM 사용 불가 시 패턴 기반 규칙이 감지: -- **네이밍 규칙**: "PascalCase", "camelCase", "name" 등의 키워드 -- **길이 규칙**: "line", "length", "max", "characters" 등의 키워드 -- **스타일 규칙**: "indent", "spaces", "tabs", "quote" 등의 키워드 -- **보안 규칙**: "secret", "password", "hardcoded" 등의 키워드 -- **import 규칙**: "import", "dependency", "layer" 등의 키워드 - -## 테스트 - -### 단위 테스트 - -```bash -# 모든 테스트 실행 -go test ./... - -# LLM 추론 테스트 -go test ./internal/llm/... - -# Linter 변환기 테스트 -go test ./internal/converter/linters/... -``` - -## 제한사항 - -### ESLint -- 복잡한 AST 패턴 지원 제한 -- 일부 규칙은 커스텀 ESLint 플러그인 필요 -- 스타일 규칙이 Prettier와 충돌 가능 - -### Checkstyle -- 모듈 설정이 복잡할 수 있음 -- 일부 규칙은 추가 체크 필요 -- 커스텀 패턴 지원 제한 - -### PMD -- 규칙 참조는 PMD 버전과 일치해야 함 -- 속성 설정이 규칙마다 다름 -- 일부 카테고리의 커버리지 제한 - -### LLM 추론 -- OpenAI API 키 필요 (비용 발생) -- 복잡한 규칙에 대해 잘못된 해석 가능 -- 신뢰도 점수는 추정치 -- 네트워크 의존성 및 지연 - -## 성능 - -### 벤치마크 (5개 규칙, 캐시 없음) - -- **LLM 사용 (gpt-4o)**: 약 5-10초 -- **폴백만**: 1초 미만 -- **캐시 적용**: 100ms 미만 - -### 비용 추정 - -- **gpt-4o**: 규칙당 약 $0.001 -- **캐싱**: 반복 규칙에 대해 비용 약 90% 절감 - -## 기여 - -새 linter 지원 추가 시: - -1. `internal/converter/linters/`에 `LinterConverter` 인터페이스 구현 -2. `init()` 함수에서 변환기 등록 -3. `*_test.go`에 테스트 추가 -4. 이 문서 업데이트 - -## 라이선스 - -sym-cli 프로젝트 라이선스와 동일 diff --git a/docs/CONVERT_USAGE.md b/docs/CONVERT_USAGE.md deleted file mode 100644 index 6ea61a5..0000000 --- a/docs/CONVERT_USAGE.md +++ /dev/null @@ -1,392 +0,0 @@ -# Convert 명령어 사용 가이드 - -## 빠른 시작 - -자연어 규칙을 linter 설정으로 변환: - -```bash -# 모든 지원 linter로 변환 (출력: /.sym) -sym convert -i user-policy.json --targets all - -# JavaScript/TypeScript만 -sym convert -i user-policy.json --targets eslint - -# Java만 -sym convert -i user-policy.json --targets checkstyle,pmd -``` - -## 기본 출력 디렉토리 - -**중요**: convert 명령어는 Git 저장소 루트에 `.sym` 디렉토리를 자동 생성하고 모든 파일을 저장합니다. - -### 디렉토리 구조 - -``` -your-project/ -+-- .git/ -+-- .sym/ # 자동 생성 -| +-- .eslintrc.json # ESLint 설정 -| +-- checkstyle.xml # Checkstyle 설정 -| +-- pmd-ruleset.xml # PMD 설정 -| +-- code-policy.json # 내부 정책 -| +-- conversion-report.json # 변환 리포트 -+-- src/ -+-- user-policy.json # 입력 파일 -``` - -### 왜 .sym인가? - -- **일관된 위치**: 항상 Git 루트에 있어 찾기 쉬움 -- **버전 관리**: `.gitignore`에 추가하여 생성 파일을 Git에서 제외 가능 -- **CI/CD 친화적**: 스크립트가 항상 `/.sym`에서 설정 찾음 - -### 커스텀 출력 디렉토리 - -다른 위치가 필요한 경우: - -```bash -sym convert -i user-policy.json --targets all --output-dir ./custom-dir -``` - -## 사전 요구사항 - -1. **Git 저장소**: Git 저장소 내에서 명령어 실행 -2. **OpenAI API 키** (선택): 더 나은 추론을 위해 `OPENAI_API_KEY` 설정 - ```bash - export OPENAI_API_KEY=sk-... - ``` - -API 키 없이도 폴백 패턴 매칭 사용 (정확도 낮음) - -## 사용자 정책 파일 - -자연어 규칙으로 `user-policy.json` 작성: - -```json -{ - "version": "1.0.0", - "defaults": { - "severity": "error", - "autofix": false - }, - "rules": [ - { - "say": "클래스 이름은 PascalCase여야 합니다", - "category": "naming", - "languages": ["javascript", "typescript", "java"] - }, - { - "say": "한 줄은 최대 100자입니다", - "category": "length" - }, - { - "say": "들여쓰기는 4칸 공백을 사용합니다", - "category": "style" - } - ] -} -``` - -## 명령어 옵션 - -### 기본 옵션 - -- `-i, --input`: 입력 사용자 정책 파일 (기본값: `user-policy.json`) -- `--targets`: 타겟 linter (쉼표 구분 또는 `all`) - - `eslint` - JavaScript/TypeScript - - `checkstyle` - Java - - `pmd` - Java - - `all` - 모든 지원 linter - -### 고급 옵션 - -- `--output-dir`: 커스텀 출력 디렉토리 (기본값: `/.sym`) -- `--openai-model`: OpenAI 모델 (기본값: `gpt-4o`) -- `--confidence-threshold`: 최소 신뢰도 (기본값: `0.7`) - - 범위: 0.0 ~ 1.0 - - 낮은 값 = 더 많은 규칙 변환, 더 많은 경고 -- `--timeout`: API 타임아웃 초 (기본값: `30`) -- `-v, --verbose`: 상세 로깅 활성화 - -### 레거시 모드 - -내부 `code-policy.json`만 생성: - -```bash -sym convert -i user-policy.json -o code-policy.json -``` - -## 예제 워크플로우 - -### JavaScript/TypeScript 프로젝트 - -```bash -# 1. user-policy.json 작성 -cat > user-policy.json <> .gitignore - -# user-policy.json은 커밋 -git add user-policy.json -git commit -m "Add coding conventions policy" -``` - -### 설정 공유 - -```bash -# 팀과 공유 -git add .sym/*.{json,xml} -git commit -m "Add generated linter configs" - -# 또는 각 머신에서 재생성 -# (각 개발자가 실행: sym convert -i user-policy.json --targets all) -``` - -### 규칙 업데이트 - -```bash -# 1. user-policy.json 편집 -# 2. 설정 재생성 -sym convert -i user-policy.json --targets all - -# 3. 변경사항 검토 -git diff .sym/ - -# 4. 프로젝트에 적용 -npx eslint --config .sym/.eslintrc.json src/ -``` - -## 다음 단계 - -- [전체 기능 문서](CONVERT_FEATURE.md) -- [기여 가이드](../AGENTS.md) diff --git a/docs/LINTER_VALIDATION.md b/docs/LINTER_VALIDATION.md deleted file mode 100644 index 3c085c7..0000000 --- a/docs/LINTER_VALIDATION.md +++ /dev/null @@ -1,87 +0,0 @@ -# Linter 설정 검증 - -## 목적 - -convert 기능은 자연어 코딩 컨벤션에서 linter별 설정을 생성합니다. 이 설정은 Git으로 추적되는 코드 변경사항을 검증하는 데 사용됩니다. - -## 지원 Linter - -### JavaScript/TypeScript -- **ESLint**: JS/TS 코드 스타일, 패턴, 모범 사례 검증 -- 출력: `.sym/.eslintrc.json` - -### Java -- **Checkstyle**: Java 코드 포맷팅 및 스타일 검증 -- 출력: `.sym/checkstyle.xml` -- **PMD**: Java 코드 품질 검증 및 코드 스멜 감지 -- 출력: `.sym/pmd-ruleset.xml` - -### 향후 지원 예정 -- **SonarQube**: 다중 언어 정적 분석 -- **LLM Validator**: 전통적인 linter로 표현할 수 없는 커스텀 규칙 - -## 엔진 할당 - -`code-policy.json`의 각 규칙에는 검증 도구를 지정하는 `engine` 필드가 있습니다: - -- `eslint`: ESLint 설정으로 변환된 규칙 -- `checkstyle`: Checkstyle 모듈로 변환된 규칙 -- `pmd`: PMD 규칙셋으로 변환된 규칙 -- `sonarqube`: 향후 지원 예정 -- `llm-validator`: LLM 분석이 필요한 복잡한 규칙 - -## 예제 워크플로우 - -1. `user-policy.json`에 **컨벤션 정의** -2. linter 설정으로 **변환**: - ```bash - sym convert -i user-policy.json --targets eslint,checkstyle,pmd - ``` -3. Git 변경사항에 **linter 실행**: - ```bash - # JavaScript/TypeScript - eslint --config .sym/.eslintrc.json src/**/*.{js,ts} - - # Java - checkstyle -c .sym/checkstyle.xml src/**/*.java - pmd check -R .sym/pmd-ruleset.xml -d src/ - ``` - -## 코드 정책 스키마 - -생성된 `code-policy.json` 내용: -```json -{ - "version": "1.0.0", - "rules": [ - { - "id": "naming-class-pascalcase", - "engine": "eslint", - "check": {...} - }, - { - "id": "security-no-secrets", - "engine": "llm-validator", - "check": {...} - } - ] -} -``` - -`engine: "llm-validator"` 규칙은 전통적인 linter로 체크할 수 없으며 커스텀 LLM 기반 검증이 필요합니다. - -## 테스트 - -### 통합 테스트 실행 - -```bash -# 모든 통합 테스트 -go test ./tests/integration/... -v - -# 특정 엔진 테스트 -go test ./tests/integration/... -v -run TestPatternEngine -go test ./tests/integration/... -v -run TestLengthEngine -go test ./tests/integration/... -v -run TestStyleEngine -go test ./tests/integration/... -v -run TestASTEngine -go test ./tests/integration/... -v -run TestTypeChecker -``` diff --git a/docs/LLM_VALIDATOR.md b/docs/LLM_VALIDATOR.md deleted file mode 100644 index 2aefbf2..0000000 --- a/docs/LLM_VALIDATOR.md +++ /dev/null @@ -1,161 +0,0 @@ -# LLM Validator - -## Overview - -LLM Validator는 전통적인 linter로 검증할 수 없는 복잡한 코딩 규칙을 LLM을 사용해 검증하는 도구입니다. - -## 사용 목적 - -일부 코딩 컨벤션은 정적 분석 도구로 검사하기 어렵습니다: - -- **보안 규칙**: "하드코딩된 API 키나 비밀번호를 사용하지 마세요" -- **아키텍처 규칙**: "레이어 간 의존성을 준수하세요" -- **복잡도 규칙**: "순환 복잡도를 10 이하로 유지하세요" -- **비즈니스 로직**: "결제 로직에는 항상 로깅을 포함하세요" - -이러한 규칙들은 `code-policy.json`에서 `engine: "llm-validator"`로 표시됩니다. - -## 작동 방식 - -1. **Git 변경사항 읽기**: - - 현재 unstaged 또는 staged 변경사항을 읽습니다 - - 추가된 라인만 추출합니다 - -2. **LLM 검증**: - - `engine: "llm-validator"`인 각 규칙에 대해 - - 변경된 코드를 LLM에 전달 - - 규칙 위반 여부를 확인 - -3. **결과 리포트**: - - 위반사항을 발견하면 상세 정보 출력 - - 수정 제안 포함 - -## 사용 방법 - -### 기본 사용 - -```bash -# Unstaged 변경사항 검증 -sym validate - -# Staged 변경사항 검증 -sym validate --staged - -# 커스텀 policy 파일 사용 -sym validate --policy custom-policy.json -``` - -### 예시 워크플로우 - -1. 코드 변경 -```bash -echo 'const API_KEY = "sk-1234567890"' >> src/config.js -``` - -2. 검증 실행 -```bash -sym validate -``` - -3. 결과 확인 -``` -Validating unstaged changes... -Found 1 changed file(s) - -=== Validation Results === -Checked: 2 -Passed: 1 -Failed: 1 - -Found 1 violation(s): - -1. [error] security-no-hardcoded-secrets - File: src/config.js - Hardcoded API key detected | Suggestion: Use environment variables - -Error: found 1 violation(s) -``` - -## 설정 - -### 환경 변수 - -- `OPENAI_API_KEY`: OpenAI API 키 (필수) - -### 플래그 - -- `--policy, -p`: code-policy.json 경로 (기본: .sym/code-policy.json) -- `--staged`: staged 변경사항 검증 -- `--model`: OpenAI 모델 (기본: gpt-4o) -- `--timeout`: 규칙당 타임아웃 (초, 기본: 30) - -## 통합 - -### Pre-commit Hook - -`.git/hooks/pre-commit`: -```bash -#!/bin/bash -sym validate --staged -``` - -### CI/CD - -```yaml -# GitHub Actions -- name: Validate Code Conventions - run: | - sym validate --staged -``` - -## 제한사항 - -- LLM API 호출 비용 발생 -- 네트워크 연결 필요 -- 응답 시간이 정적 분석보다 느림 - -## 최적화 팁 - -1. **빠른 피드백을 위해 전통적인 linter와 함께 사용**: - - ESLint, Checkstyle, PMD로 검증 가능한 규칙은 해당 도구 사용 - - LLM validator는 복잡한 규칙에만 사용 - -2. **변경사항이 많을 때는 주의**: - - 큰 PR의 경우 API 비용이 증가할 수 있음 - - `--staged`를 사용해 커밋 단위로 검증 권장 - -3. **적절한 규칙 설정**: - - 너무 주관적인 규칙은 피하기 - - 명확하고 구체적인 규칙 작성 - -## 예시 규칙 - -`code-policy.json`: -```json -{ - "rules": [ - { - "id": "security-no-secrets", - "enabled": true, - "category": "security", - "severity": "error", - "desc": "Do not hardcode secrets, API keys, or passwords", - "check": { - "engine": "llm-validator", - "desc": "Do not hardcode secrets, API keys, or passwords" - } - }, - { - "id": "architecture-layer-dependency", - "enabled": true, - "category": "architecture", - "severity": "error", - "desc": "Presentation layer should not directly access data layer", - "check": { - "engine": "llm-validator", - "desc": "Presentation layer should not directly access data layer" - } - } - ] -} -``` diff --git a/docs/command.md b/docs/command.md new file mode 100644 index 0000000..1fae471 --- /dev/null +++ b/docs/command.md @@ -0,0 +1,717 @@ +# Symphony CLI 명령어 레퍼런스 + +Symphony (`sym`)는 코드 컨벤션 관리와 RBAC(역할 기반 접근 제어)를 위한 CLI 도구입니다. LLM 프로바이더와 통합하여 자연어 정책을 린터 설정으로 변환하고, 코드 변경사항을 검증합니다. + +--- + +## 목차 + +- [Symphony CLI 명령어 레퍼런스](#symphony-cli-명령어-레퍼런스) + - [목차](#목차) + - [개요](#개요) + - [빌드 및 설치](#빌드-및-설치) + - [전역 플래그](#전역-플래그) + - [명령어 계층 구조](#명령어-계층-구조) + - [명령어 상세](#명령어-상세) + - [sym init](#sym-init) + - [sym dashboard](#sym-dashboard) + - [sym my-role](#sym-my-role) + - [sym policy](#sym-policy) + - [sym policy path](#sym-policy-path) + - [sym policy validate](#sym-policy-validate) + - [sym convert](#sym-convert) + - [sym validate](#sym-validate) + - [sym mcp](#sym-mcp) + - [sym llm](#sym-llm) + - [sym llm status](#sym-llm-status) + - [sym llm test](#sym-llm-test) + - [sym llm setup](#sym-llm-setup) + - [sym version](#sym-version) + - [sym completion](#sym-completion) + - [sym help](#sym-help) + - [설정 파일](#설정-파일) + - [디렉토리 구조](#디렉토리-구조) + - [config.json](#configjson) + - [.env](#env) + - [roles.json](#rolesjson) + - [MCP 통합](#mcp-통합) + - [지원 도구](#지원-도구) + - [MCP 도구 스키마](#mcp-도구-스키마) + - [query\_conventions](#query_conventions) + - [validate\_code](#validate_code) + - [등록 방법](#등록-방법) + - [LLM 프로바이더](#llm-프로바이더) + - [지원 프로바이더](#지원-프로바이더) + - [설정 방법](#설정-방법) + - [Claude Code](#claude-code) + - [Gemini CLI](#gemini-cli) + - [OpenAI API](#openai-api) + - [상태 확인](#상태-확인) + +--- + +## 개요 + +Symphony CLI는 다음 기능을 제공합니다: + +- **자연어 정책 정의**: 사용자가 자연어로 코딩 컨벤션을 정의 +- **자동 변환**: 자연어 정책을 ESLint, Prettier, Pylint 등 린터 설정으로 변환 +- **RBAC 지원**: 역할 기반 파일 접근 권한 관리 +- **MCP 서버**: Claude Code, Cursor 등 AI 코딩 도구와 통합 +- **LLM 검증**: 정적 린터로 검사할 수 없는 복잡한 규칙을 LLM으로 검증 + +--- + +## 빌드 및 설치 + +```bash +# 저장소 클론 +git clone https://github.com/anthropics/symphony.git +cd symphony + +# 빌드 +go build -o bin/sym ./cmd/sym + +# PATH에 추가 (선택사항) +export PATH=$PATH:$(pwd)/bin + +# 또는 직접 설치 +go install ./cmd/sym +``` + +--- + +## 전역 플래그 + +모든 명령어에서 사용 가능한 플래그입니다. + +| 플래그 | 단축 | 타입 | 기본값 | 설명 | +|--------|------|------|--------|------| +| `--help` | `-h` | bool | `false` | 명령어 도움말 표시 | +| `--verbose` | `-v` | bool | `false` | 상세 출력 활성화 | + +--- + +## 명령어 계층 구조 + +``` +sym +├── init # 프로젝트 초기화 +├── dashboard (dash) # 웹 대시보드 실행 +├── my-role # 역할 확인/변경 +├── policy # 정책 관리 +│ ├── path # 정책 파일 경로 관리 +│ └── validate # 정책 파일 유효성 검사 +├── convert # 정책 → 린터 설정 변환 +├── validate # Git 변경사항 검증 +├── mcp # MCP 서버 실행 +├── llm # LLM 프로바이더 관리 +│ ├── status # 현재 설정 확인 +│ ├── test # 연결 테스트 +│ └── setup # 설정 안내 +├── version # 버전 출력 +├── completion # 쉘 자동완성 스크립트 생성 +└── help # 명령어 도움말 +``` + +--- + +## 명령어 상세 + +### sym init + +**설명**: 현재 디렉토리에 Symphony를 초기화합니다. `.sym` 디렉토리와 기본 설정 파일을 생성합니다. + +**수행 작업**: +1. `.sym/roles.json` 생성 (기본 역할: admin, developer, viewer) +2. `.sym/user-policy.json` 생성 (기본 RBAC 설정) +3. `.sym/config.json` 생성 (기본 설정) +4. 역할을 admin으로 설정 (대시보드에서 변경 가능) +5. MCP 서버 등록 (선택적) +6. LLM 백엔드 설정 (선택적) + +**동작 조건**: +- `--force` 없음: `.sym` 디렉토리가 존재하지 않을 때만 동작 +- `--force` 있음: 기존 설정을 덮어쓰고, 생성된 파일(code-policy.json, 린터 설정)을 정리 + +**문법**: +``` +sym init [flags] +``` + +**플래그**: + +| 플래그 | 단축 | 타입 | 기본값 | 설명 | +|--------|------|------|--------|------| +| `--force` | `-f` | bool | `false` | 기존 Symphony 설정 덮어쓰기 | +| `--skip-mcp` | - | bool | `false` | MCP 서버 등록 프롬프트 건너뛰기 | +| `--skip-llm` | - | bool | `false` | LLM 백엔드 설정 프롬프트 건너뛰기 | + +**예시**: +```bash +# 전체 초기화 +sym init + +# 기존 설정 덮어쓰기 (재초기화) +sym init --force + +# MCP 등록 없이 초기화 +sym init --skip-mcp + +# LLM 설정 없이 초기화 +sym init --skip-llm +``` + +**관련 파일**: `internal/cmd/init.go` + +--- + +### sym dashboard + +**설명**: 역할과 정책을 관리하기 위한 로컬 웹 서버를 시작합니다. + +**별칭**: `dash` + +**기능**: +- 역할 선택 +- 역할 권한 관리 +- 코딩 정책 및 규칙 편집 + +**문법**: +``` +sym dashboard [flags] +sym dash [flags] +``` + +**플래그**: + +| 플래그 | 단축 | 타입 | 기본값 | 설명 | +|--------|------|------|--------|------| +| `--port` | `-p` | int | `8787` | 대시보드 실행 포트 | + +**예시**: +```bash +# 기본 포트(8787)에서 시작 +sym dashboard + +# 사용자 지정 포트에서 시작 +sym dashboard --port 3000 + +# 별칭 사용 +sym dash +``` + +**관련 파일**: `internal/cmd/dashboard.go` + +--- + +### sym my-role + +**설명**: 현재 선택된 역할을 확인하거나 변경합니다. + +**문법**: +``` +sym my-role [flags] +``` + +**플래그**: + +| 플래그 | 단축 | 타입 | 기본값 | 설명 | +|--------|------|------|--------|------| +| `--json` | - | bool | `false` | JSON 형식으로 출력 (스크립팅용) | +| `--select` | - | bool | `false` | 대화형으로 새 역할 선택 | + +**예시**: +```bash +# 현재 역할 확인 +sym my-role + +# JSON으로 출력 +sym my-role --json + +# 역할 변경 (대화형) +sym my-role --select +``` + +**관련 파일**: `internal/cmd/my_role.go` + +--- + +### sym policy + +**설명**: 코딩 컨벤션 및 정책 설정을 관리하는 상위 명령어입니다. + +**문법**: +``` +sym policy [flags] +``` + +#### sym policy path + +**설명**: 정책 파일 경로를 확인하거나 설정합니다. + +**플래그**: + +| 플래그 | 단축 | 타입 | 기본값 | 설명 | +|--------|------|------|--------|------| +| `--set` | - | string | `""` | 새 정책 파일 경로 설정 | + +**예시**: +```bash +# 현재 정책 경로 확인 +sym policy path + +# 새 정책 경로 설정 +sym policy path --set ./custom/my-policy.json +``` + +#### sym policy validate + +**설명**: 정책 파일의 구문 및 구조를 검증합니다. + +**예시**: +```bash +sym policy validate +``` + +**관련 파일**: `internal/cmd/policy.go` + +--- + +### sym convert + +**설명**: 사용자가 작성한 자연어 정책(Schema A)을 린터별 설정 파일과 내부 검증 스키마(Schema B)로 변환합니다. + +LLM을 사용하여 자연어 규칙을 분석하고, 언어 기반 라우팅으로 적절한 린터 규칙에 매핑합니다. + +**지원 린터**: +- ESLint +- Prettier +- Pylint +- TSC (TypeScript Compiler) +- Checkstyle (Java) +- PMD (Java) + +**문법**: +``` +sym convert [flags] +``` + +**플래그**: + +| 플래그 | 단축 | 타입 | 기본값 | 설명 | +|--------|------|------|--------|------| +| `--input` | `-i` | string | `""` | 입력 사용자 정책 파일 (기본값: .sym/config.json의 policy_path) | +| `--output-dir` | `-o` | string | `""` | 린터 설정 출력 디렉토리 (기본값: .sym) | + +**예시**: +```bash +# 정책 변환 (출력: .sym 디렉토리) +sym convert -i user-policy.json + +# 사용자 지정 출력 디렉토리 +sym convert -i user-policy.json -o ./custom-dir +``` + +**출력 파일**: +- `.sym/code-policy.json` - 변환된 정책 (Schema B) +- `.sym/.eslintrc.json` - ESLint 설정 +- `.sym/.prettierrc.json` - Prettier 설정 +- `.sym/.pylintrc` - Pylint 설정 +- 등 + +**관련 파일**: `internal/cmd/convert.go` + +--- + +### sym validate + +**설명**: Git 변경사항을 코딩 컨벤션에 대해 LLM으로 검증합니다. + +code-policy.json에서 `llm-validator`를 엔진으로 사용하는 규칙들을 검사합니다. 이는 일반적으로 정적 린터로 검사할 수 없는 복잡한 규칙(보안, 아키텍처 등)입니다. + +**기본 동작**: 모든 커밋되지 않은 변경사항 검증 +- 스테이지된 변경사항 (git add) +- 스테이지되지 않은 변경사항 (수정됨) +- 추적되지 않은 파일 (새 파일) + +**문법**: +``` +sym validate [flags] +``` + +**플래그**: + +| 플래그 | 단축 | 타입 | 기본값 | 설명 | +|--------|------|------|--------|------| +| `--policy` | `-p` | string | `""` | code-policy.json 경로 (기본값: .sym/code-policy.json) | +| `--staged` | - | bool | `false` | 스테이지된 변경사항만 검증 (기본값: 모든 커밋되지 않은 변경사항) | +| `--timeout` | - | int | `30` | 규칙당 검사 타임아웃 (초) | + +**예시**: +```bash +# 모든 커밋되지 않은 변경사항 검증 (기본) +sym validate + +# 스테이지된 변경사항만 검증 +sym validate --staged + +# 사용자 지정 정책 파일 사용 +sym validate --policy custom-policy.json + +# 타임아웃 설정 +sym validate --timeout 60 +``` + +**관련 파일**: `internal/cmd/validate.go` + +--- + +### sym mcp + +**설명**: MCP(Model Context Protocol) 서버를 시작합니다. LLM 기반 코딩 도구가 stdio를 통해 컨벤션을 쿼리하고 코드를 검증할 수 있습니다. + +**제공되는 MCP 도구**: +- `query_conventions`: 주어진 컨텍스트에 대한 컨벤션 쿼리 +- `validate_code`: 코드의 컨벤션 준수 여부 검증 + +**통신 방식**: stdio (Claude Desktop, Claude Code, Cursor 등 MCP 클라이언트와 통합) + +**문법**: +``` +sym mcp [flags] +``` + +**플래그**: + +| 플래그 | 단축 | 타입 | 기본값 | 설명 | +|--------|------|------|--------|------| +| `--config` | `-c` | string | `""` | 정책 파일 경로 (code-policy.json) | + +**예시**: +```bash +# 자동 감지로 MCP 서버 시작 +sym mcp + +# 특정 설정 파일로 시작 +sym mcp --config code-policy.json +``` + +**관련 파일**: `internal/cmd/mcp.go`, `internal/mcp/server.go` + +--- + +### sym llm + +**설명**: LLM 프로바이더 설정을 관리하는 상위 명령어입니다. + +**지원 프로바이더**: +- `claudecode`: Claude Code CLI ('claude'가 PATH에 필요) +- `geminicli`: Gemini CLI ('gemini'가 PATH에 필요) +- `openaiapi`: OpenAI API (OPENAI_API_KEY 필요) + +**문법**: +``` +sym llm [flags] +``` + +#### sym llm status + +**설명**: 현재 LLM 프로바이더 설정과 가용성을 표시합니다. + +**예시**: +```bash +sym llm status +``` + +#### sym llm test + +**설명**: LLM 프로바이더가 정상 작동하는지 테스트 요청을 보냅니다. + +**예시**: +```bash +sym llm test +``` + +#### sym llm setup + +**설명**: LLM 프로바이더 설정 방법에 대한 안내를 표시합니다. + +**예시**: +```bash +sym llm setup +``` + +**관련 파일**: `internal/cmd/llm.go` + +--- + +### sym version + +**설명**: sym CLI의 버전 번호를 출력합니다. + +**문법**: +``` +sym version +``` + +**예시**: +```bash +sym version +``` + +**관련 파일**: `internal/cmd/version.go` + +--- + +### sym completion + +**설명**: 지정된 쉘에 대한 자동완성 스크립트를 생성합니다. + +**지원 쉘**: +- bash +- zsh +- fish +- powershell + +**문법**: +``` +sym completion +``` + +**예시**: +```bash +# Bash 자동완성 설정 +sym completion bash > /etc/bash_completion.d/sym + +# Zsh 자동완성 설정 +sym completion zsh > "${fpath[1]}/_sym" + +# Fish 자동완성 설정 +sym completion fish > ~/.config/fish/completions/sym.fish + +# PowerShell 자동완성 설정 +sym completion powershell > sym.ps1 +``` + +--- + +### sym help + +**설명**: 명령어에 대한 도움말을 표시합니다. + +**문법**: +``` +sym help [command] +``` + +**예시**: +```bash +# 전체 도움말 +sym help + +# 특정 명령어 도움말 +sym help init +sym help convert +``` + +--- + +## 설정 파일 + +### 디렉토리 구조 + +``` +.sym/ +├── config.json # 프로젝트 설정 (LLM, MCP, 정책 경로) +├── .env # API 키 (gitignored) +├── roles.json # 역할 정의 +├── user-policy.json # 자연어 정책 (Schema A) +├── code-policy.json # 변환된 정책 (Schema B) +└── validation-results.json # 검증 이력 (최근 50개) +``` + +### config.json + +프로젝트별 설정 파일입니다. + +```json +{ + "llm": { + "provider": "claudecode", + "model": "sonnet" + }, + "mcp": { + "tools": ["vscode", "claude-code", "cursor"] + }, + "policy_path": ".sym/user-policy.json" +} +``` + +### .env + +API 키를 저장합니다 (gitignored). + +``` +OPENAI_API_KEY=sk-... +``` + +### roles.json + +역할 정의 파일입니다. + +```json +{ + "roles": { + "admin": { + "permissions": [ + {"path": "**", "read": true, "write": true, "execute": true} + ] + }, + "developer": { + "permissions": [ + {"path": "src/**", "read": true, "write": true, "execute": false}, + {"path": "config/**", "read": true, "write": false, "execute": false} + ] + }, + "viewer": { + "permissions": [ + {"path": "**", "read": true, "write": false, "execute": false} + ] + } + } +} +``` + +--- + +## MCP 통합 + +Symphony는 다음 AI 코딩 도구에 MCP 서버로 등록될 수 있습니다. + +### 지원 도구 + +| 도구 | 설정 위치 | 형식 | +|------|----------|------| +| Claude Code | `.mcp.json` (프로젝트 루트) | mcpServers | +| Cursor | `.cursor/mcp.json` | mcpServers (type: "stdio") | +| VS Code Copilot | `.vscode/mcp.json` | servers | +| Claude Desktop | 전역 설정 (OS별) | mcpServers | +| Cline | 전역 설정 (VS Code 확장 저장소) | mcpServers | + +### MCP 도구 스키마 + +#### query_conventions + +코딩 전 프로젝트 컨벤션을 쿼리합니다. + +**입력 스키마**: + +| 파라미터 | 타입 | 필수 | 설명 | +|----------|------|------|------| +| `category` | string | 아니오 | 카테고리 필터. 'all' 또는 비워두면 모든 카테고리. 옵션: security, style, documentation, error_handling, architecture, performance, testing | +| `languages` | []string | 아니오 | 언어 필터. 비워두면 모든 언어. 예: go, javascript, typescript, python, java | + +#### validate_code + +Git 변경사항을 프로젝트 컨벤션에 대해 검증합니다. + +**입력 스키마**: + +| 파라미터 | 타입 | 필수 | 설명 | +|----------|------|------|------| +| `role` | string | 아니오 | 검증용 RBAC 역할 (선택) | + +### 등록 방법 + +```bash +# 초기화 시 대화형으로 MCP 등록 (권장) +sym init + +# MCP 등록을 건너뛰려면 +sym init --skip-mcp +``` + +--- + +## LLM 프로바이더 + +### 지원 프로바이더 + +| 프로바이더 ID | 표시 이름 | API 키 필요 | CLI 요구사항 | +|--------------|----------|-------------|-------------| +| `claudecode` | Claude Code | 아니오 | 'claude' PATH에 있어야 함 | +| `geminicli` | Gemini CLI | 아니오 | 'gemini' PATH에 있어야 함 | +| `openaiapi` | OpenAI API | 예 (OPENAI_API_KEY) | 없음 | + +### 설정 방법 + +#### Claude Code + +1. Claude Code CLI 설치: + ```bash + npm install -g @anthropic-ai/claude-code + ``` + +2. Claude Code 인증: + ```bash + claude auth login + ``` + +3. Symphony 설정: + ```json + // .sym/config.json + { + "llm": { + "provider": "claudecode", + "model": "sonnet" + } + } + ``` + +#### Gemini CLI + +1. Gemini CLI 설치 (Google Cloud SDK 필요) + +2. Symphony 설정: + ```json + // .sym/config.json + { + "llm": { + "provider": "geminicli", + "model": "gemini-pro" + } + } + ``` + +#### OpenAI API + +1. API 키 설정: + ```bash + # .sym/.env + OPENAI_API_KEY=sk-... + ``` + +2. Symphony 설정: + ```json + // .sym/config.json + { + "llm": { + "provider": "openaiapi", + "model": "gpt-4" + } + } + ``` + +### 상태 확인 + +```bash +# 현재 설정 확인 +sym llm status + +# 연결 테스트 +sym llm test + +# 설정 안내 보기 +sym llm setup +``` From 77b48aa1354096036f4339496c457a1c99454a78 Mon Sep 17 00:00:00 2001 From: ikjeong Date: Tue, 9 Dec 2025 09:22:51 +0000 Subject: [PATCH 07/12] refactor: use internal/git package for repo detection - Remove cmd/git.go, use internal/git.GetRepoRoot() instead - Update init and validate commands to use git package - Apply verbose flag consistently in convert/validate --- internal/cmd/convert.go | 1 + internal/cmd/git.go | 44 ---------------------------------------- internal/cmd/init.go | 9 +++++--- internal/cmd/root.go | 1 - internal/cmd/validate.go | 8 ++++---- 5 files changed, 11 insertions(+), 52 deletions(-) delete mode 100644 internal/cmd/git.go diff --git a/internal/cmd/convert.go b/internal/cmd/convert.go index 173c5f5..ebcdd7e 100644 --- a/internal/cmd/convert.go +++ b/internal/cmd/convert.go @@ -81,6 +81,7 @@ func runNewConverter(userPolicy *schema.UserPolicy) error { // Create LLM provider cfg := llm.LoadConfig() + cfg.Verbose = verbose llmProvider, err := llm.New(cfg) if err != nil { return fmt.Errorf("no available LLM backend for convert: %w\nTip: configure provider in .sym/config.json", err) diff --git a/internal/cmd/git.go b/internal/cmd/git.go deleted file mode 100644 index 39b9c2a..0000000 --- a/internal/cmd/git.go +++ /dev/null @@ -1,44 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "path/filepath" -) - -// findGitRoot finds the git repository root by looking for .git directory -func findGitRoot() (string, error) { - // Start from current directory - dir, err := os.Getwd() - if err != nil { - return "", fmt.Errorf("failed to get current directory: %w", err) - } - - // Walk up the directory tree - for { - gitDir := filepath.Join(dir, ".git") - if info, err := os.Stat(gitDir); err == nil && info.IsDir() { - return dir, nil - } - - // Check if we've reached the root - parent := filepath.Dir(dir) - if parent == dir { - break - } - dir = parent - } - - return "", fmt.Errorf("not in a git repository") -} - -// getSymDir returns the .sym directory path in the git root -func getSymDir() (string, error) { - gitRoot, err := findGitRoot() - if err != nil { - return "", err - } - - symDir := filepath.Join(gitRoot, ".sym") - return symDir, nil -} diff --git a/internal/cmd/init.go b/internal/cmd/init.go index 22eccf8..225ba37 100644 --- a/internal/cmd/init.go +++ b/internal/cmd/init.go @@ -6,6 +6,7 @@ import ( "path/filepath" "github.com/DevSymphony/sym-cli/internal/config" + "github.com/DevSymphony/sym-cli/internal/git" "github.com/DevSymphony/sym-cli/internal/linter" "github.com/DevSymphony/sym-cli/internal/policy" "github.com/DevSymphony/sym-cli/internal/roles" @@ -46,11 +47,12 @@ func init() { func runInit(cmd *cobra.Command, args []string) { // Check if .sym directory already exists - symDir, err := getSymDir() + repoRoot, err := git.GetRepoRoot() if err != nil { - ui.PrintError(fmt.Sprintf("Failed to determine .sym directory: %v", err)) + ui.PrintError(fmt.Sprintf("Failed to find git repository: %v", err)) os.Exit(1) } + symDir := filepath.Join(repoRoot, ".sym") symDirExists := false if _, err := os.Stat(symDir); err == nil { @@ -203,8 +205,9 @@ func removeExistingCodePolicy() error { convertGeneratedFiles = append(convertGeneratedFiles, linter.Global().GetAllConfigFiles()...) // Check and remove from .sym directory - symDir, err := getSymDir() + repoRoot, err := git.GetRepoRoot() if err == nil { + symDir := filepath.Join(repoRoot, ".sym") for _, filename := range convertGeneratedFiles { filePath := filepath.Join(symDir, filename) if _, err := os.Stat(filePath); err == nil { diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 89ae22c..03deb30 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -8,7 +8,6 @@ import ( ) // verbose is a global flag for verbose output -// Used by convert and validate commands var verbose bool var rootCmd = &cobra.Command{ diff --git a/internal/cmd/validate.go b/internal/cmd/validate.go index c04de83..cf21a41 100644 --- a/internal/cmd/validate.go +++ b/internal/cmd/validate.go @@ -57,11 +57,11 @@ func runValidate(cmd *cobra.Command, args []string) error { // Load code policy policyPath := validatePolicyFile if policyPath == "" { - symDir, err := getSymDir() + repoRoot, err := git.GetRepoRoot() if err != nil { - return fmt.Errorf("failed to find .sym directory: %w", err) + return fmt.Errorf("failed to find git repository: %w", err) } - policyPath = filepath.Join(symDir, "code-policy.json") + policyPath = filepath.Join(repoRoot, ".sym", "code-policy.json") } policyData, err := os.ReadFile(policyPath) @@ -104,7 +104,7 @@ func runValidate(cmd *cobra.Command, args []string) error { fmt.Printf("Found %d changed file(s)\n", len(changes)) // Create unified validator that handles all engines + RBAC - v := validator.NewValidator(&policy, true) // verbose=true for CLI + v := validator.NewValidator(&policy, verbose) v.SetLLMProvider(llmProvider) defer func() { if err := v.Close(); err != nil { From 79e1d745e96a5b6fa428f08f57e1d7de3b982c76 Mon Sep 17 00:00:00 2001 From: ikjeong Date: Tue, 9 Dec 2025 09:22:58 +0000 Subject: [PATCH 08/12] refactor: simplify mcp commands - Remove auto-dashboard launch from mcp command - Remove claude-desktop and cline support from mcp register - Remove unused fileExists and launchDashboard functions --- internal/cmd/mcp.go | 45 +-------------------- internal/cmd/mcp_register.go | 78 ++++-------------------------------- 2 files changed, 9 insertions(+), 114 deletions(-) diff --git a/internal/cmd/mcp.go b/internal/cmd/mcp.go index 2c348bf..82d709a 100644 --- a/internal/cmd/mcp.go +++ b/internal/cmd/mcp.go @@ -4,12 +4,9 @@ import ( "fmt" "os" "path/filepath" - "time" "github.com/DevSymphony/sym-cli/internal/git" "github.com/DevSymphony/sym-cli/internal/mcp" - "github.com/DevSymphony/sym-cli/internal/ui" - "github.com/pkg/browser" "github.com/spf13/cobra" ) @@ -57,49 +54,11 @@ func runMCP(cmd *cobra.Command, args []string) error { } // Check if user-policy.json exists - userPolicyExists := fileExists(userPolicyPath) - - // If no user-policy.json → Launch dashboard - if !userPolicyExists { - ui.PrintError(fmt.Sprintf("User policy not found at: %s", userPolicyPath)) - fmt.Println("Opening dashboard to create policy...") - - // Launch dashboard - if err := launchDashboard(); err != nil { - return fmt.Errorf("failed to launch dashboard: %w", err) - } - - fmt.Println() - ui.PrintOK("Dashboard launched at http://localhost:8787") - fmt.Println("Please create your policy in the dashboard, then restart MCP server.") - return nil + if _, err := os.Stat(userPolicyPath); os.IsNotExist(err) { + return fmt.Errorf("user policy not found: %s\nRun 'sym init' first or 'sym dashboard' to create policy", userPolicyPath) } // Start MCP server - it will handle conversion automatically if needed server := mcp.NewServer(configPath) return server.Start() } - -// fileExists checks if a file exists -func fileExists(path string) bool { - _, err := os.Stat(path) - return err == nil -} - -// launchDashboard launches the dashboard in the background -func launchDashboard() error { - // Open browser to dashboard - url := "http://localhost:8787" - go func() { - time.Sleep(1 * time.Second) - _ = browser.OpenURL(url) // Ignore error - browser opening is best-effort - }() - - // Start dashboard server in background - // Note: This will block, so in practice you'd want to run this in a separate process - // For now, we just inform the user to run it manually - fmt.Println("Please run in another terminal:") - fmt.Println(" sym dashboard") - - return nil -} diff --git a/internal/cmd/mcp_register.go b/internal/cmd/mcp_register.go index f86b03b..fb5e51c 100644 --- a/internal/cmd/mcp_register.go +++ b/internal/cmd/mcp_register.go @@ -6,14 +6,13 @@ import ( "os" "os/exec" "path/filepath" - "runtime" "strings" "github.com/AlecAivazis/survey/v2" "github.com/DevSymphony/sym-cli/internal/ui" ) -// Section markers for Symphony instructions in append-mode files (CLAUDE.md, .clinerules) +// Section markers for Symphony instructions in append-mode files (CLAUDE.md) const ( symphonySectionStart = "" symphonySectionEnd = "" @@ -257,61 +256,30 @@ func registerMCP(app string) error { // 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") + return filepath.Join(cwd, ".mcp.json") case "cursor": - // Project-specific configuration - path = filepath.Join(cwd, ".cursor", "mcp.json") + return filepath.Join(cwd, ".cursor", "mcp.json") case "vscode": - // Project-specific configuration - path = filepath.Join(cwd, ".vscode", "mcp.json") - case "cline": - // Global configuration (VS Code extension storage) - switch runtime.GOOS { - case "windows": - path = filepath.Join(os.Getenv("APPDATA"), "Code", "User", "globalStorage", "saoudrizwan.claude-dev", "settings", "cline_mcp_settings.json") - case "darwin": - path = filepath.Join(homeDir, "Library", "Application Support", "Code", "User", "globalStorage", "saoudrizwan.claude-dev", "settings", "cline_mcp_settings.json") - case "linux": - path = filepath.Join(homeDir, ".config", "Code", "User", "globalStorage", "saoudrizwan.claude-dev", "settings", "cline_mcp_settings.json") - } + return filepath.Join(cwd, ".vscode", "mcp.json") + default: + return "" } - - 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 Copilot" - case "cline": - return "Cline" default: return app } @@ -362,22 +330,15 @@ func createInstructionsFile(app string) error { content = getClaudeCodeInstructions() appendMode = true case "cursor": - // Use new .cursor/rules format instructionsPath = filepath.Join(".cursor", "rules", "symphony.mdc") content = getCursorInstructions() appendMode = false case "vscode": - // Use .github/instructions/symphony.instructions.md for VS Code Copilot instructionsPath = filepath.Join(".github", "instructions", "symphony.instructions.md") content = getVSCodeInstructions() appendMode = false - case "cline": - // Use .clinerules for Cline (Markdown format in project root) - instructionsPath = ".clinerules" - content = getClineInstructions() - appendMode = true default: - return nil // No instructions file for this app + return nil } // Check if file exists @@ -541,28 +502,3 @@ Check MCP → Query Conventions → Write Code → Validate → Fix → Commit Auto-generated by Symphony ` } - -// getClineInstructions returns instructions for Cline (.clinerules) -func getClineInstructions() string { - return symphonySectionStart + ` -# Symphony Code Conventions - -This project uses Symphony MCP for automated code convention management. - -## Required Workflow - -### Before Writing Code -1. **Verify Symphony MCP is active** - Check that the Symphony MCP server is available -2. **Query conventions** - Use the symphony/query_conventions tool - - Select appropriate category: security, style, documentation, error_handling, architecture, performance, testing - - Filter by programming language as needed - -### After Writing Code -1. **Validate all changes** - Use the symphony/validate_code tool to check against project conventions -2. **Fix any violations** - Address all issues before proceeding -3. **Only commit after validation passes** - -## Summary -Always: Check MCP → Query Conventions → Write Code → Validate → Fix → Commit -` + symphonySectionEnd + "\n" -} From 5ac8f2ab61225c68f092caf59a8359261e9328c1 Mon Sep 17 00:00:00 2001 From: ikjeong Date: Tue, 9 Dec 2025 09:23:03 +0000 Subject: [PATCH 09/12] refactor: remove redundant wrapper functions - Remove saveAPIKeyToEnv wrapper, call envutil directly - Inline handleError in my_role command --- internal/cmd/llm.go | 7 +------ internal/cmd/my_role.go | 15 +++++---------- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/internal/cmd/llm.go b/internal/cmd/llm.go index 8449983..889c623 100644 --- a/internal/cmd/llm.go +++ b/internal/cmd/llm.go @@ -301,7 +301,7 @@ func promptAndSaveAPIKey(providerName string) error { // Save to .env file envPath := config.GetProjectEnvPath() - if err := saveAPIKeyToEnv(envPath, envVarName, apiKey); err != nil { + if err := envutil.SaveKeyToEnvFile(envPath, envVarName, apiKey); err != nil { return err } @@ -315,11 +315,6 @@ func promptAndSaveAPIKey(providerName string) error { return nil } -// saveAPIKeyToEnv saves the API key to the .env file -func saveAPIKeyToEnv(envPath, envVarName, apiKey string) error { - return envutil.SaveKeyToEnvFile(envPath, envVarName, apiKey) -} - // ensureGitignore ensures that the given path is in .gitignore func ensureGitignore(path string) error { gitignorePath := ".gitignore" diff --git a/internal/cmd/my_role.go b/internal/cmd/my_role.go index ef83156..c14c7c6 100644 --- a/internal/cmd/my_role.go +++ b/internal/cmd/my_role.go @@ -55,7 +55,11 @@ func runMyRole(cmd *cobra.Command, args []string) { // Get current role role, err := roles.GetCurrentRole() if err != nil { - handleError("Failed to get current role", err, myRoleJSON) + if myRoleJSON { + _ = json.NewEncoder(os.Stdout).Encode(map[string]string{"error": fmt.Sprintf("Failed to get current role: %v", err)}) + } else { + ui.PrintError(fmt.Sprintf("Failed to get current role: %v", err)) + } os.Exit(1) } @@ -117,12 +121,3 @@ func selectNewRole() { ui.PrintOK(fmt.Sprintf("Your role has been changed to: %s", selectedRole)) } - -func handleError(msg string, err error, jsonMode bool) { - if jsonMode { - output := map[string]string{"error": fmt.Sprintf("%s: %v", msg, err)} - _ = json.NewEncoder(os.Stdout).Encode(output) - } else { - ui.PrintError(fmt.Sprintf("%s: %v", msg, err)) - } -} From c6837343d158b4683b0502a3f7266ccaeeda08c2 Mon Sep 17 00:00:00 2001 From: ikjeong Date: Tue, 9 Dec 2025 09:25:36 +0000 Subject: [PATCH 10/12] refactor: unify instruction file update logic - Change appendMode to isSharedFile for clarity - Remove unnecessary .bak backup for Symphony-dedicated files - Consistent logic with MCP config handling --- internal/cmd/mcp_register.go | 68 ++++++++++++++++-------------------- 1 file changed, 31 insertions(+), 37 deletions(-) diff --git a/internal/cmd/mcp_register.go b/internal/cmd/mcp_register.go index fb5e51c..9d9edb6 100644 --- a/internal/cmd/mcp_register.go +++ b/internal/cmd/mcp_register.go @@ -318,68 +318,63 @@ func updateSymphonySection(existingContent, symphonyContent string) string { return existingContent + separator + symphonyContent } -// createInstructionsFile creates or updates the instructions file for the specified app +// createInstructionsFile creates or updates the instructions file for the specified app. +// Logic is consistent with MCP config handling: +// - File doesn't exist → create new +// - File exists → update symphony content (no backup for Symphony-dedicated files) +// - Shared files (CLAUDE.md) use section markers to preserve other content func createInstructionsFile(app string) error { var instructionsPath string var content string - var appendMode bool + var isSharedFile bool // true for files that may contain other content (CLAUDE.md) switch app { case "claude-code": instructionsPath = "CLAUDE.md" content = getClaudeCodeInstructions() - appendMode = true + isSharedFile = true // CLAUDE.md may have other project instructions case "cursor": instructionsPath = filepath.Join(".cursor", "rules", "symphony.mdc") content = getCursorInstructions() - appendMode = false + isSharedFile = false // Symphony-dedicated file case "vscode": instructionsPath = filepath.Join(".github", "instructions", "symphony.instructions.md") content = getVSCodeInstructions() - appendMode = false + isSharedFile = false // Symphony-dedicated file default: return nil } + // Create directory if needed + dir := filepath.Dir(instructionsPath) + if dir != "." { + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + } + // Check if file exists existingContent, err := os.ReadFile(instructionsPath) fileExists := err == nil - if fileExists { - if appendMode { - // Update Symphony section (replace if exists, append if not) - existingStr := string(existingContent) - content = updateSymphonySection(existingStr, content) - - // Determine message based on whether section was replaced or appended - if strings.Contains(existingStr, symphonySectionStart) { - fmt.Println(ui.Indent(fmt.Sprintf("Updated Symphony section in %s", instructionsPath))) - } else { - fmt.Println(ui.Indent(fmt.Sprintf("Appended Symphony section to %s", instructionsPath))) - } + if fileExists && isSharedFile { + // Shared file: update/append Symphony section only, preserve other content + existingStr := string(existingContent) + content = updateSymphonySection(existingStr, content) + + if strings.Contains(existingStr, symphonySectionStart) { + fmt.Println(ui.Indent(fmt.Sprintf("Updated Symphony section in %s", instructionsPath))) } else { - // Create backup - backupPath := instructionsPath + ".bak" - if err := os.WriteFile(backupPath, existingContent, 0644); err != nil { - fmt.Println(ui.Indent(fmt.Sprintf("Failed to create backup: %v", err))) - } else { - fmt.Println(ui.Indent(fmt.Sprintf("Backup: %s", filepath.Base(backupPath)))) - } - fmt.Println(ui.Indent(fmt.Sprintf("Created %s", instructionsPath))) + fmt.Println(ui.Indent(fmt.Sprintf("Appended Symphony section to %s", instructionsPath))) } + } else if fileExists { + // Symphony-dedicated file: just overwrite (no backup needed) + fmt.Println(ui.Indent(fmt.Sprintf("Updated %s", instructionsPath))) } else { - // Create new file + // New file fmt.Println(ui.Indent(fmt.Sprintf("Created %s", instructionsPath))) } - // Create directory if needed - dir := filepath.Dir(instructionsPath) - if dir != "." { - if err := os.MkdirAll(dir, 0755); err != nil { - return fmt.Errorf("failed to create directory: %w", err) - } - } - // Write file if err := os.WriteFile(instructionsPath, []byte(content), 0644); err != nil { return fmt.Errorf("failed to write file: %w", err) @@ -387,11 +382,10 @@ func createInstructionsFile(app string) error { // Add VS Code instructions directory to .gitignore if app == "vscode" { - gitignorePath := ".github/instructions/" - if err := ensureGitignore(gitignorePath); err != nil { + if err := ensureGitignore(".github/instructions/"); err != nil { fmt.Println(ui.Indent(fmt.Sprintf("Warning: Failed to update .gitignore: %v", err))) } else { - fmt.Println(ui.Indent(fmt.Sprintf("Added %s to .gitignore", gitignorePath))) + fmt.Println(ui.Indent("Added .github/instructions/ to .gitignore")) } } From 52312d1aca1b33286fd3103f49b13889386b4c4a Mon Sep 17 00:00:00 2001 From: ikjeong Date: Tue, 9 Dec 2025 10:11:29 +0000 Subject: [PATCH 11/12] chore: bump version to 0.1.8 in package.json --- npm/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/npm/package.json b/npm/package.json index dbf6ef6..d37f1fa 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,6 +1,6 @@ { "name": "@dev-symphony/sym", - "version": "0.1.7", + "version": "0.1.8", "description": "Symphony - LLM-friendly convention linter for AI coding assistants", "keywords": [ "mcp", From a0a48891fff432165e5d9015eca4cb38ae97a622 Mon Sep 17 00:00:00 2001 From: ikjeong Date: Tue, 9 Dec 2025 11:29:22 +0000 Subject: [PATCH 12/12] refactor: move colors.go to cmd package and make functions private MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - internal/ui/colors.go → internal/cmd/colors.go - 모든 exported 함수/상수를 private으로 전환 - 미사용 함수 삭제 (Info, PrintInfo, Title, PrintIndent) - 8개 의존 파일에서 import 및 호출 업데이트 --- CLAUDE.md | 30 +++++++++ internal/cmd/colors.go | 91 ++++++++++++++++++++++++++++ internal/cmd/convert.go | 9 ++- internal/cmd/dashboard.go | 7 +-- internal/cmd/init.go | 41 ++++++------- internal/cmd/llm.go | 37 ++++++------ internal/cmd/mcp_register.go | 53 ++++++++-------- internal/cmd/my_role.go | 25 ++++---- internal/cmd/policy.go | 19 +++--- internal/cmd/validate.go | 3 +- internal/ui/colors.go | 114 ----------------------------------- 11 files changed, 214 insertions(+), 215 deletions(-) create mode 100644 CLAUDE.md create mode 100644 internal/cmd/colors.go delete mode 100644 internal/ui/colors.go diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ae91a1c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,30 @@ + +# Symphony Code Conventions + +**This project uses Symphony MCP for automated code convention management.** + +## Critical Requirements + +### 1. Before Writing Code + +**Check MCP Status**: Verify Symphony MCP server is active. If unavailable, warn the user and do not proceed. + +**Query Conventions**: Use `mcp__symphony__query_conventions` to retrieve relevant rules. +- Select appropriate category: security, style, documentation, error_handling, architecture, performance, testing +- Filter by languages as needed + +### 2. After Writing Code + +**Validate Changes**: Always run `mcp__symphony__validate_code` to check all changes against project conventions. + +**Fix Violations**: Address any issues found before committing. + +## Workflow + +1. Verify Symphony MCP is active +2. Query conventions for your task +3. Write code +4. Validate with Symphony +5. Fix violations +6. Commit + diff --git a/internal/cmd/colors.go b/internal/cmd/colors.go new file mode 100644 index 0000000..7c342d8 --- /dev/null +++ b/internal/cmd/colors.go @@ -0,0 +1,91 @@ +package cmd + +import ( + "fmt" + "os" + + "golang.org/x/term" +) + +// ANSI color codes +const ( + reset = "\033[0m" + red = "\033[31m" + green = "\033[32m" + yellow = "\033[33m" + cyan = "\033[36m" + bold = "\033[1m" +) + +// isTTY checks if stdout is a terminal +func isTTY() bool { + return term.IsTerminal(int(os.Stdout.Fd())) +} + +// colorize applies color only if output is a TTY +func colorize(color, msg string) string { + if !isTTY() { + return msg + } + return color + msg + reset +} + +// ok formats a success message with [OK] prefix in green +func ok(msg string) string { + prefix := colorize(green, "[OK]") + return fmt.Sprintf("%s %s", prefix, msg) +} + +// formatError formats an error message with [ERROR] prefix in red +func formatError(msg string) string { + prefix := colorize(red, "[ERROR]") + return fmt.Sprintf("%s %s", prefix, msg) +} + +// warn formats a warning message with [WARN] prefix in yellow +func warn(msg string) string { + prefix := colorize(yellow, "[WARN]") + return fmt.Sprintf("%s %s", prefix, msg) +} + +// titleWithDesc formats a section title with description +func titleWithDesc(title, desc string) string { + prefix := colorize(bold+cyan, fmt.Sprintf("[%s]", title)) + return fmt.Sprintf("%s %s", prefix, desc) +} + +// done formats a completion message with [DONE] prefix in green +func done(msg string) string { + prefix := colorize(green+bold, "[DONE]") + return fmt.Sprintf("%s %s", prefix, msg) +} + +// printOK prints a success message +func printOK(msg string) { + fmt.Println(ok(msg)) +} + +// printError prints an error message +func printError(msg string) { + fmt.Println(formatError(msg)) +} + +// printWarn prints a warning message +func printWarn(msg string) { + fmt.Println(warn(msg)) +} + +// printTitle prints a section title +func printTitle(title, desc string) { + fmt.Println(titleWithDesc(title, desc)) +} + +// printDone prints a completion message +func printDone(msg string) { + fmt.Println(done(msg)) +} + +// indent returns the message with indentation +func indent(msg string) string { + return " " + msg +} diff --git a/internal/cmd/convert.go b/internal/cmd/convert.go index ebcdd7e..7936d4b 100644 --- a/internal/cmd/convert.go +++ b/internal/cmd/convert.go @@ -10,7 +10,6 @@ import ( "github.com/DevSymphony/sym-cli/internal/config" "github.com/DevSymphony/sym-cli/internal/converter" "github.com/DevSymphony/sym-cli/internal/llm" - "github.com/DevSymphony/sym-cli/internal/ui" "github.com/DevSymphony/sym-cli/pkg/schema" "github.com/spf13/cobra" ) @@ -95,7 +94,7 @@ func runNewConverter(userPolicy *schema.UserPolicy) error { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) defer cancel() - ui.PrintTitle("Convert", "Language-based routing with parallel LLM inference") + printTitle("Convert", "Language-based routing with parallel LLM inference") fmt.Printf("Output: %s\n\n", convertOutputDir) // Convert @@ -106,7 +105,7 @@ func runNewConverter(userPolicy *schema.UserPolicy) error { // Print results fmt.Println() - ui.PrintOK("Conversion completed successfully") + printOK("Conversion completed successfully") fmt.Printf("Generated %d configuration file(s):\n", len(result.GeneratedFiles)) for _, file := range result.GeneratedFiles { fmt.Printf(" - %s\n", file) @@ -114,7 +113,7 @@ func runNewConverter(userPolicy *schema.UserPolicy) error { if len(result.Errors) > 0 { fmt.Println() - ui.PrintWarn(fmt.Sprintf("Errors (%d):", len(result.Errors))) + printWarn(fmt.Sprintf("Errors (%d):", len(result.Errors))) for linter, err := range result.Errors { fmt.Printf(" - %s: %v\n", linter, err) } @@ -122,7 +121,7 @@ func runNewConverter(userPolicy *schema.UserPolicy) error { if len(result.Warnings) > 0 { fmt.Println() - ui.PrintWarn(fmt.Sprintf("Warnings (%d):", len(result.Warnings))) + printWarn(fmt.Sprintf("Warnings (%d):", len(result.Warnings))) for _, warning := range result.Warnings { fmt.Printf(" - %s\n", warning) } diff --git a/internal/cmd/dashboard.go b/internal/cmd/dashboard.go index b61fd8b..8301ff9 100644 --- a/internal/cmd/dashboard.go +++ b/internal/cmd/dashboard.go @@ -6,7 +6,6 @@ import ( "github.com/DevSymphony/sym-cli/internal/roles" "github.com/DevSymphony/sym-cli/internal/server" - "github.com/DevSymphony/sym-cli/internal/ui" "github.com/spf13/cobra" ) @@ -34,7 +33,7 @@ func runDashboard(cmd *cobra.Command, args []string) { // Check if roles.json exists exists, err := roles.RolesExists() if err != nil || !exists { - ui.PrintError("roles.json not found") + printError("roles.json not found") fmt.Println("Run 'sym init' to create it") os.Exit(1) } @@ -42,12 +41,12 @@ func runDashboard(cmd *cobra.Command, args []string) { // Start server srv, err := server.NewServer(dashboardPort) if err != nil { - ui.PrintError(fmt.Sprintf("Failed to create server: %v", err)) + printError(fmt.Sprintf("Failed to create server: %v", err)) os.Exit(1) } if err := srv.Start(); err != nil { - ui.PrintError(fmt.Sprintf("Failed to start server: %v", err)) + printError(fmt.Sprintf("Failed to start server: %v", err)) os.Exit(1) } } diff --git a/internal/cmd/init.go b/internal/cmd/init.go index 225ba37..aa52ae2 100644 --- a/internal/cmd/init.go +++ b/internal/cmd/init.go @@ -10,7 +10,6 @@ import ( "github.com/DevSymphony/sym-cli/internal/linter" "github.com/DevSymphony/sym-cli/internal/policy" "github.com/DevSymphony/sym-cli/internal/roles" - "github.com/DevSymphony/sym-cli/internal/ui" "github.com/DevSymphony/sym-cli/pkg/schema" "github.com/spf13/cobra" @@ -49,7 +48,7 @@ func runInit(cmd *cobra.Command, args []string) { // Check if .sym directory already exists repoRoot, err := git.GetRepoRoot() if err != nil { - ui.PrintError(fmt.Sprintf("Failed to find git repository: %v", err)) + printError(fmt.Sprintf("Failed to find git repository: %v", err)) os.Exit(1) } symDir := filepath.Join(repoRoot, ".sym") @@ -58,12 +57,12 @@ func runInit(cmd *cobra.Command, args []string) { if _, err := os.Stat(symDir); err == nil { symDirExists = true } else if !os.IsNotExist(err) { - ui.PrintError(fmt.Sprintf("Failed to check .sym directory: %v", err)) + printError(fmt.Sprintf("Failed to check .sym directory: %v", err)) os.Exit(1) } if symDirExists && !initForce { - ui.PrintWarn(".sym directory already exists") + printWarn(".sym directory already exists") fmt.Println("Use --force flag to reinitialize") os.Exit(1) } @@ -76,34 +75,34 @@ func runInit(cmd *cobra.Command, args []string) { } if err := roles.SaveRoles(newRoles); err != nil { - ui.PrintError(fmt.Sprintf("Failed to create roles.json: %v", err)) + printError(fmt.Sprintf("Failed to create roles.json: %v", err)) os.Exit(1) } rolesPath, _ := roles.GetRolesPath() - ui.PrintOK("roles.json created") - fmt.Println(ui.Indent(fmt.Sprintf("Location: %s", rolesPath))) + printOK("roles.json created") + fmt.Println(indent(fmt.Sprintf("Location: %s", rolesPath))) // Create default policy file with RBAC roles if err := createDefaultPolicy(); err != nil { - ui.PrintWarn(fmt.Sprintf("Failed to create policy file: %v", err)) - fmt.Println(ui.Indent("You can manually create it later using the dashboard")) + printWarn(fmt.Sprintf("Failed to create policy file: %v", err)) + fmt.Println(indent("You can manually create it later using the dashboard")) } else { - ui.PrintOK("user-policy.json created with default RBAC roles") + printOK("user-policy.json created with default RBAC roles") } // Create .sym/config.json with default settings if err := initializeConfigFile(); err != nil { - ui.PrintWarn(fmt.Sprintf("Failed to create config.json: %v", err)) + printWarn(fmt.Sprintf("Failed to create config.json: %v", err)) } else { - ui.PrintOK("config.json created") + printOK("config.json created") } // Set default role to admin during initialization if err := roles.SetCurrentRole("admin"); err != nil { - ui.PrintWarn(fmt.Sprintf("Failed to save role selection: %v", err)) + printWarn(fmt.Sprintf("Failed to save role selection: %v", err)) } else { - ui.PrintOK("Your role has been set to: admin") + printOK("Your role has been set to: admin") } // MCP registration prompt @@ -119,17 +118,17 @@ func runInit(cmd *cobra.Command, args []string) { // Clean up generated files at the end (only when --force is set) if initForce { if err := removeExistingCodePolicy(); err != nil { - ui.PrintWarn(fmt.Sprintf("Failed to remove generated files: %v", err)) + printWarn(fmt.Sprintf("Failed to remove generated files: %v", err)) } } // Show completion message fmt.Println() - ui.PrintDone("Initialization complete") + printDone("Initialization complete") fmt.Println() fmt.Println("Next steps:") - fmt.Println(ui.Indent("Run 'sym dashboard' to manage roles and policies")) - fmt.Println(ui.Indent("Commit .sym/ folder to share with your team")) + fmt.Println(indent("Run 'sym dashboard' to manage roles and policies")) + fmt.Println(indent("Commit .sym/ folder to share with your team")) } // createDefaultPolicy creates a default policy file with RBAC roles @@ -212,9 +211,9 @@ func removeExistingCodePolicy() error { filePath := filepath.Join(symDir, filename) if _, err := os.Stat(filePath); err == nil { if err := os.Remove(filePath); err != nil { - ui.PrintWarn(fmt.Sprintf("Failed to remove %s: %v", filePath, err)) + printWarn(fmt.Sprintf("Failed to remove %s: %v", filePath, err)) } else { - fmt.Println(ui.Indent(fmt.Sprintf("Removed existing %s", filePath))) + fmt.Println(indent(fmt.Sprintf("Removed existing %s", filePath))) } } } @@ -226,7 +225,7 @@ func removeExistingCodePolicy() error { if err := os.Remove(legacyPath); err != nil { return fmt.Errorf("failed to remove %s: %w", legacyPath, err) } - fmt.Println(ui.Indent(fmt.Sprintf("Removed existing %s", legacyPath))) + fmt.Println(indent(fmt.Sprintf("Removed existing %s", legacyPath))) } return nil diff --git a/internal/cmd/llm.go b/internal/cmd/llm.go index 889c623..52d6dc2 100644 --- a/internal/cmd/llm.go +++ b/internal/cmd/llm.go @@ -12,7 +12,6 @@ import ( "github.com/DevSymphony/sym-cli/internal/config" "github.com/DevSymphony/sym-cli/internal/envutil" "github.com/DevSymphony/sym-cli/internal/llm" - "github.com/DevSymphony/sym-cli/internal/ui" "github.com/spf13/cobra" ) @@ -60,7 +59,7 @@ func init() { } func runLLMStatus(_ *cobra.Command, _ []string) { - ui.PrintTitle("LLM", "Provider Status") + printTitle("LLM", "Provider Status") fmt.Println() // Load config @@ -95,9 +94,9 @@ func runLLMStatus(_ *cobra.Command, _ []string) { // Try to create provider provider, err := llm.New(cfg) if err != nil { - ui.PrintWarn(fmt.Sprintf("Configuration error: %v", err)) + printWarn(fmt.Sprintf("Configuration error: %v", err)) } else { - ui.PrintOK(fmt.Sprintf("Active provider: %s", provider.Name())) + printOK(fmt.Sprintf("Active provider: %s", provider.Name())) } fmt.Println() @@ -106,7 +105,7 @@ func runLLMStatus(_ *cobra.Command, _ []string) { } func runLLMTest(_ *cobra.Command, _ []string) { - ui.PrintTitle("LLM", "Testing Provider Connection") + printTitle("LLM", "Testing Provider Connection") fmt.Println() // Load config @@ -115,7 +114,7 @@ func runLLMTest(_ *cobra.Command, _ []string) { // Create provider provider, err := llm.New(cfg) if err != nil { - ui.PrintError(fmt.Sprintf("Failed to create provider: %v", err)) + printError(fmt.Sprintf("Failed to create provider: %v", err)) fmt.Println() fmt.Println("Please configure a provider:") fmt.Println(" sym llm setup") @@ -132,16 +131,16 @@ func runLLMTest(_ *cobra.Command, _ []string) { response, err := provider.Execute(ctx, prompt, llm.Text) if err != nil { - ui.PrintError(fmt.Sprintf("Test failed: %v", err)) + printError(fmt.Sprintf("Test failed: %v", err)) return } - ui.PrintOK("Test successful!") + printOK("Test successful!") fmt.Printf(" Response: %s\n", strings.TrimSpace(response)) } func runLLMSetup(_ *cobra.Command, _ []string) { - ui.PrintTitle("LLM", "Provider Setup Instructions") + printTitle("LLM", "Provider Setup Instructions") fmt.Println() // Show available providers @@ -202,8 +201,8 @@ func promptLLMBackendSetup() { defer restore() fmt.Println() - ui.PrintTitle("LLM", "Configure LLM Provider") - fmt.Println(ui.Indent("Symphony uses LLM for policy conversion and code validation")) + printTitle("LLM", "Configure LLM Provider") + fmt.Println(indent("Symphony uses LLM for policy conversion and code validation")) fmt.Println() // Get provider options dynamically from registry @@ -223,14 +222,14 @@ func promptLLMBackendSetup() { if selectedDisplayName == "Skip" { fmt.Println("Skipped LLM configuration") - fmt.Println(ui.Indent("Tip: Run 'sym llm setup' to configure later")) + fmt.Println(indent("Tip: Run 'sym llm setup' to configure later")) return } // Get provider info from registry providerInfo := llm.GetProviderByDisplayName(selectedDisplayName) if providerInfo == nil { - ui.PrintError(fmt.Sprintf("Unknown provider: %s", selectedDisplayName)) + printError(fmt.Sprintf("Unknown provider: %s", selectedDisplayName)) return } @@ -240,7 +239,7 @@ func promptLLMBackendSetup() { // Handle API key if required if llm.RequiresAPIKey(providerName) { if err := promptAndSaveAPIKey(providerName); err != nil { - ui.PrintError(fmt.Sprintf("Failed to save API key: %v", err)) + printError(fmt.Sprintf("Failed to save API key: %v", err)) return } } @@ -266,11 +265,11 @@ func promptLLMBackendSetup() { // Save to config.json if err := config.UpdateProjectConfigLLM(providerName, modelID); err != nil { - ui.PrintError(fmt.Sprintf("Failed to save config: %v", err)) + printError(fmt.Sprintf("Failed to save config: %v", err)) return } - ui.PrintOK(fmt.Sprintf("LLM provider saved: %s (%s)", selectedDisplayName, modelID)) + printOK(fmt.Sprintf("LLM provider saved: %s (%s)", selectedDisplayName, modelID)) } // promptAndSaveAPIKey prompts for API key and saves to .env @@ -291,7 +290,7 @@ func promptAndSaveAPIKey(providerName string) error { // Validate API key using registry if err := llm.ValidateAPIKey(providerName, apiKey); err != nil { - ui.PrintWarn(err.Error()) + printWarn(err.Error()) // Continue anyway - it's a warning, not a blocking error // But if the key is empty, we should return the error if apiKey == "" { @@ -305,11 +304,11 @@ func promptAndSaveAPIKey(providerName string) error { return err } - ui.PrintOK("API key saved to .sym/.env (gitignored)") + printOK("API key saved to .sym/.env (gitignored)") // Ensure .env is in .gitignore if err := ensureGitignore(".sym/.env"); err != nil { - ui.PrintWarn(fmt.Sprintf("Failed to update .gitignore: %v", err)) + printWarn(fmt.Sprintf("Failed to update .gitignore: %v", err)) } return nil diff --git a/internal/cmd/mcp_register.go b/internal/cmd/mcp_register.go index 9d9edb6..59e01a6 100644 --- a/internal/cmd/mcp_register.go +++ b/internal/cmd/mcp_register.go @@ -9,7 +9,6 @@ import ( "strings" "github.com/AlecAivazis/survey/v2" - "github.com/DevSymphony/sym-cli/internal/ui" ) // Section markers for Symphony instructions in append-mode files (CLAUDE.md) @@ -65,8 +64,8 @@ var mcpToolToApp = map[string]string{ func promptMCPRegistration() { // Check if npx is available if !checkNpxAvailable() { - ui.PrintWarn("'npx' not found. MCP features require Node.js.") - fmt.Println(ui.Indent("Download: https://nodejs.org/")) + printWarn("'npx' not found. MCP features require Node.js.") + fmt.Println(indent("Download: https://nodejs.org/")) var continueAnyway bool prompt := &survey.Confirm{ @@ -84,9 +83,9 @@ func promptMCPRegistration() { defer restore() fmt.Println() - ui.PrintTitle("MCP", "Register Symphony as an MCP server") - fmt.Println(ui.Indent("Symphony MCP provides code convention tools for AI assistants")) - fmt.Println(ui.Indent("(Use arrows to move, space to select, enter to submit)")) + printTitle("MCP", "Register Symphony as an MCP server") + fmt.Println(indent("Symphony MCP provides code convention tools for AI assistants")) + fmt.Println(indent("(Use arrows to move, space to select, enter to submit)")) fmt.Println() // Multi-select prompt for tools @@ -104,7 +103,7 @@ func promptMCPRegistration() { // If no tools selected, skip if len(selectedTools) == 0 { fmt.Println("Skipped MCP registration") - fmt.Println(ui.Indent("Tip: Run 'sym mcp register' to register MCP later")) + fmt.Println(indent("Tip: Run 'sym mcp register' to register MCP later")) return } @@ -124,11 +123,11 @@ func promptMCPRegistration() { // Print results fmt.Println() if len(registered) > 0 { - ui.PrintOK(fmt.Sprintf("Registered: %s", strings.Join(registered, ", "))) - fmt.Println(ui.Indent("Reload/restart the tools to use Symphony")) + printOK(fmt.Sprintf("Registered: %s", strings.Join(registered, ", "))) + fmt.Println(indent("Reload/restart the tools to use Symphony")) } for _, f := range failed { - ui.PrintError(fmt.Sprintf("Failed to register %s", f)) + printError(fmt.Sprintf("Failed to register %s", f)) } } @@ -137,13 +136,13 @@ func registerMCP(app string) error { configPath := getMCPConfigPath(app) if configPath == "" { - fmt.Println(ui.Warn(fmt.Sprintf("%s config path could not be determined", getAppDisplayName(app)))) + fmt.Println(warn(fmt.Sprintf("%s config path could not be determined", getAppDisplayName(app)))) return fmt.Errorf("config path not determined") } // All supported apps are now project-specific - fmt.Println(ui.Indent(fmt.Sprintf("Configuring %s", getAppDisplayName(app)))) - fmt.Println(ui.Indent(fmt.Sprintf("Location: %s", configPath))) + fmt.Println(indent(fmt.Sprintf("Configuring %s", getAppDisplayName(app)))) + fmt.Println(indent(fmt.Sprintf("Location: %s", configPath))) // Create config directory if it doesn't exist configDir := filepath.Dir(configPath) @@ -166,15 +165,15 @@ func registerMCP(app string) error { // Invalid JSON, create backup and start fresh backupPath := configPath + ".bak" if err := os.WriteFile(backupPath, existingData, 0644); err != nil { - fmt.Println(ui.Indent(fmt.Sprintf("Failed to create backup: %v", err))) + fmt.Println(indent(fmt.Sprintf("Failed to create backup: %v", err))) } else { - fmt.Println(ui.Indent(fmt.Sprintf("Invalid JSON, backup created: %s", filepath.Base(backupPath)))) + fmt.Println(indent(fmt.Sprintf("Invalid JSON, backup created: %s", filepath.Base(backupPath)))) } vscodeConfig = VSCodeMCPConfig{} } // Valid JSON: no backup needed, just update symphony entry } else { - fmt.Println(ui.Indent("Creating new configuration file")) + fmt.Println(indent("Creating new configuration file")) } // Initialize Servers if nil @@ -203,15 +202,15 @@ func registerMCP(app string) error { // Invalid JSON, create backup and start fresh backupPath := configPath + ".bak" if err := os.WriteFile(backupPath, existingData, 0644); err != nil { - fmt.Println(ui.Indent(fmt.Sprintf("Failed to create backup: %v", err))) + fmt.Println(indent(fmt.Sprintf("Failed to create backup: %v", err))) } else { - fmt.Println(ui.Indent(fmt.Sprintf("Invalid JSON, backup created: %s", filepath.Base(backupPath)))) + fmt.Println(indent(fmt.Sprintf("Invalid JSON, backup created: %s", filepath.Base(backupPath)))) } config = MCPRegistrationConfig{} } // Valid JSON: no backup needed, just update symphony entry } else { - fmt.Println(ui.Indent("Creating new configuration file")) + fmt.Println(indent("Creating new configuration file")) } // Initialize MCPServers if nil @@ -244,11 +243,11 @@ func registerMCP(app string) error { return fmt.Errorf("failed to write config: %w", err) } - fmt.Println(ui.Indent("Symphony MCP server registered")) + fmt.Println(indent("Symphony MCP server registered")) // Create instructions file for all supported apps if err := createInstructionsFile(app); err != nil { - fmt.Println(ui.Indent(fmt.Sprintf("Failed to create instructions file: %v", err))) + fmt.Println(indent(fmt.Sprintf("Failed to create instructions file: %v", err))) } return nil @@ -363,16 +362,16 @@ func createInstructionsFile(app string) error { content = updateSymphonySection(existingStr, content) if strings.Contains(existingStr, symphonySectionStart) { - fmt.Println(ui.Indent(fmt.Sprintf("Updated Symphony section in %s", instructionsPath))) + fmt.Println(indent(fmt.Sprintf("Updated Symphony section in %s", instructionsPath))) } else { - fmt.Println(ui.Indent(fmt.Sprintf("Appended Symphony section to %s", instructionsPath))) + fmt.Println(indent(fmt.Sprintf("Appended Symphony section to %s", instructionsPath))) } } else if fileExists { // Symphony-dedicated file: just overwrite (no backup needed) - fmt.Println(ui.Indent(fmt.Sprintf("Updated %s", instructionsPath))) + fmt.Println(indent(fmt.Sprintf("Updated %s", instructionsPath))) } else { // New file - fmt.Println(ui.Indent(fmt.Sprintf("Created %s", instructionsPath))) + fmt.Println(indent(fmt.Sprintf("Created %s", instructionsPath))) } // Write file @@ -383,9 +382,9 @@ func createInstructionsFile(app string) error { // Add VS Code instructions directory to .gitignore if app == "vscode" { if err := ensureGitignore(".github/instructions/"); err != nil { - fmt.Println(ui.Indent(fmt.Sprintf("Warning: Failed to update .gitignore: %v", err))) + fmt.Println(indent(fmt.Sprintf("Warning: Failed to update .gitignore: %v", err))) } else { - fmt.Println(ui.Indent("Added .github/instructions/ to .gitignore")) + fmt.Println(indent("Added .github/instructions/ to .gitignore")) } } diff --git a/internal/cmd/my_role.go b/internal/cmd/my_role.go index c14c7c6..20e208a 100644 --- a/internal/cmd/my_role.go +++ b/internal/cmd/my_role.go @@ -7,7 +7,6 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/DevSymphony/sym-cli/internal/roles" - "github.com/DevSymphony/sym-cli/internal/ui" "github.com/spf13/cobra" ) @@ -40,8 +39,8 @@ func runMyRole(cmd *cobra.Command, args []string) { output := map[string]string{"error": "roles.json not found"} _ = json.NewEncoder(os.Stdout).Encode(output) } else { - ui.PrintError("roles.json not found") - fmt.Println(ui.Indent("Run 'sym init' first")) + printError("roles.json not found") + fmt.Println(indent("Run 'sym init' first")) } os.Exit(1) } @@ -58,7 +57,7 @@ func runMyRole(cmd *cobra.Command, args []string) { if myRoleJSON { _ = json.NewEncoder(os.Stdout).Encode(map[string]string{"error": fmt.Sprintf("Failed to get current role: %v", err)}) } else { - ui.PrintError(fmt.Sprintf("Failed to get current role: %v", err)) + printError(fmt.Sprintf("Failed to get current role: %v", err)) } os.Exit(1) } @@ -70,11 +69,11 @@ func runMyRole(cmd *cobra.Command, args []string) { _ = json.NewEncoder(os.Stdout).Encode(output) } else { if role == "" { - ui.PrintWarn("No role selected") - fmt.Println(ui.Indent("Run 'sym my-role --select' to select a role")) + printWarn("No role selected") + fmt.Println(indent("Run 'sym my-role --select' to select a role")) } else { fmt.Printf("Current role: %s\n", role) - fmt.Println(ui.Indent("Run 'sym my-role --select' to change")) + fmt.Println(indent("Run 'sym my-role --select' to change")) } } } @@ -86,19 +85,19 @@ func selectNewRole() { availableRoles, err := roles.GetAvailableRoles() if err != nil { - ui.PrintError(fmt.Sprintf("Failed to get available roles: %v", err)) + printError(fmt.Sprintf("Failed to get available roles: %v", err)) os.Exit(1) } if len(availableRoles) == 0 { - ui.PrintError("No roles defined in roles.json") + printError("No roles defined in roles.json") os.Exit(1) } currentRole, _ := roles.GetCurrentRole() fmt.Println() - ui.PrintTitle("Role", "Select your role") + printTitle("Role", "Select your role") fmt.Println() // Use survey.Select for consistent UI @@ -110,14 +109,14 @@ func selectNewRole() { } if err := survey.AskOne(prompt, &selectedRole); err != nil { - ui.PrintWarn("No selection made") + printWarn("No selection made") return } if err := roles.SetCurrentRole(selectedRole); err != nil { - ui.PrintError(fmt.Sprintf("Failed to save role: %v", err)) + printError(fmt.Sprintf("Failed to save role: %v", err)) os.Exit(1) } - ui.PrintOK(fmt.Sprintf("Your role has been changed to: %s", selectedRole)) + printOK(fmt.Sprintf("Your role has been changed to: %s", selectedRole)) } diff --git a/internal/cmd/policy.go b/internal/cmd/policy.go index 1e7b36d..7bc63a9 100644 --- a/internal/cmd/policy.go +++ b/internal/cmd/policy.go @@ -6,7 +6,6 @@ import ( "github.com/DevSymphony/sym-cli/internal/config" "github.com/DevSymphony/sym-cli/internal/policy" - "github.com/DevSymphony/sym-cli/internal/ui" "github.com/spf13/cobra" ) @@ -50,7 +49,7 @@ func init() { func runPolicyPath(cmd *cobra.Command, args []string) { cfg, err := config.LoadConfig() if err != nil { - ui.PrintError(fmt.Sprintf("Failed to load config: %v", err)) + printError(fmt.Sprintf("Failed to load config: %v", err)) os.Exit(1) } @@ -58,11 +57,11 @@ func runPolicyPath(cmd *cobra.Command, args []string) { // Set new path cfg.PolicyPath = policyPathSet if err := config.SaveConfig(cfg); err != nil { - ui.PrintError(fmt.Sprintf("Failed to save config: %v", err)) + printError(fmt.Sprintf("Failed to save config: %v", err)) os.Exit(1) } - ui.PrintOK(fmt.Sprintf("Policy path updated: %s", policyPathSet)) + printOK(fmt.Sprintf("Policy path updated: %s", policyPathSet)) } else { // Show current path policyPath := cfg.PolicyPath @@ -83,9 +82,9 @@ func runPolicyPath(cmd *cobra.Command, args []string) { if err != nil { fmt.Printf("Error checking file: %v\n", err) } else if exists { - ui.PrintOK("Policy file exists") + printOK("Policy file exists") } else { - ui.PrintWarn("Policy file does not exist") + printWarn("Policy file does not exist") } } } @@ -93,7 +92,7 @@ func runPolicyPath(cmd *cobra.Command, args []string) { func runPolicyValidate(cmd *cobra.Command, args []string) { cfg, err := config.LoadConfig() if err != nil { - ui.PrintError(fmt.Sprintf("Failed to load config: %v", err)) + printError(fmt.Sprintf("Failed to load config: %v", err)) os.Exit(1) } @@ -101,16 +100,16 @@ func runPolicyValidate(cmd *cobra.Command, args []string) { policyData, err := policy.LoadPolicy(cfg.PolicyPath) if err != nil { - ui.PrintError(fmt.Sprintf("Failed to load policy: %v", err)) + printError(fmt.Sprintf("Failed to load policy: %v", err)) os.Exit(1) } if err := policy.ValidatePolicy(policyData); err != nil { - ui.PrintError(fmt.Sprintf("Validation failed: %v", err)) + printError(fmt.Sprintf("Validation failed: %v", err)) os.Exit(1) } - ui.PrintOK("Policy file is valid") + printOK("Policy file is valid") fmt.Printf(" Version: %s\n", policyData.Version) fmt.Printf(" Rules: %d\n", len(policyData.Rules)) diff --git a/internal/cmd/validate.go b/internal/cmd/validate.go index cf21a41..22c567d 100644 --- a/internal/cmd/validate.go +++ b/internal/cmd/validate.go @@ -9,7 +9,6 @@ import ( "github.com/DevSymphony/sym-cli/internal/git" "github.com/DevSymphony/sym-cli/internal/llm" - "github.com/DevSymphony/sym-cli/internal/ui" "github.com/DevSymphony/sym-cli/internal/validator" "github.com/DevSymphony/sym-cli/pkg/schema" "github.com/spf13/cobra" @@ -136,7 +135,7 @@ func printValidationResult(result *validator.ValidationResult) { fmt.Printf("Failed: %d\n\n", result.Failed) if len(result.Violations) == 0 { - ui.PrintOK("All checks passed") + printOK("All checks passed") return } diff --git a/internal/ui/colors.go b/internal/ui/colors.go deleted file mode 100644 index 0685368..0000000 --- a/internal/ui/colors.go +++ /dev/null @@ -1,114 +0,0 @@ -package ui - -import ( - "fmt" - "os" - - "golang.org/x/term" -) - -// ANSI color codes -const ( - Reset = "\033[0m" - Red = "\033[31m" - Green = "\033[32m" - Yellow = "\033[33m" - Blue = "\033[34m" - Cyan = "\033[36m" - Bold = "\033[1m" -) - -// isTTY checks if stdout is a terminal -func isTTY() bool { - return term.IsTerminal(int(os.Stdout.Fd())) -} - -// colorize applies color only if output is a TTY -func colorize(color, msg string) string { - if !isTTY() { - return msg - } - return color + msg + Reset -} - -// OK formats a success message with [OK] prefix in green -func OK(msg string) string { - prefix := colorize(Green, "[OK]") - return fmt.Sprintf("%s %s", prefix, msg) -} - -// Error formats an error message with [ERROR] prefix in red -func Error(msg string) string { - prefix := colorize(Red, "[ERROR]") - return fmt.Sprintf("%s %s", prefix, msg) -} - -// Warn formats a warning message with [WARN] prefix in yellow -func Warn(msg string) string { - prefix := colorize(Yellow, "[WARN]") - return fmt.Sprintf("%s %s", prefix, msg) -} - -// Info formats an info message with [INFO] prefix in blue -func Info(msg string) string { - prefix := colorize(Blue, "[INFO]") - return fmt.Sprintf("%s %s", prefix, msg) -} - -// Title formats a section title in bold cyan -func Title(msg string) string { - prefix := colorize(Bold+Cyan, fmt.Sprintf("[%s]", msg)) - return prefix -} - -// TitleWithDesc formats a section title with description -func TitleWithDesc(title, desc string) string { - prefix := colorize(Bold+Cyan, fmt.Sprintf("[%s]", title)) - return fmt.Sprintf("%s %s", prefix, desc) -} - -// Done formats a completion message with [DONE] prefix in green -func Done(msg string) string { - prefix := colorize(Green+Bold, "[DONE]") - return fmt.Sprintf("%s %s", prefix, msg) -} - -// PrintOK prints a success message -func PrintOK(msg string) { - fmt.Println(OK(msg)) -} - -// PrintError prints an error message -func PrintError(msg string) { - fmt.Println(Error(msg)) -} - -// PrintWarn prints a warning message -func PrintWarn(msg string) { - fmt.Println(Warn(msg)) -} - -// PrintInfo prints an info message -func PrintInfo(msg string) { - fmt.Println(Info(msg)) -} - -// PrintTitle prints a section title -func PrintTitle(title, desc string) { - fmt.Println(TitleWithDesc(title, desc)) -} - -// PrintDone prints a completion message -func PrintDone(msg string) { - fmt.Println(Done(msg)) -} - -// Indent returns the message with indentation -func Indent(msg string) string { - return " " + msg -} - -// PrintIndent prints an indented message -func PrintIndent(msg string) { - fmt.Println(Indent(msg)) -}