From fea42be8c750252224e11ed86e6856c4b245a617 Mon Sep 17 00:00:00 2001 From: ikjeong Date: Thu, 11 Dec 2025 16:42:48 +0000 Subject: [PATCH 1/5] feat: add convention import package - Implement importer package to extract coding conventions from external documents - Use LLM for automatic category/rule extraction - Support append/clear import modes --- internal/importer/extractor.go | 202 +++++++++++++++++++++++++++++++++ internal/importer/importer.go | 173 ++++++++++++++++++++++++++++ internal/importer/reader.go | 133 ++++++++++++++++++++++ internal/importer/types.go | 61 ++++++++++ 4 files changed, 569 insertions(+) create mode 100644 internal/importer/extractor.go create mode 100644 internal/importer/importer.go create mode 100644 internal/importer/reader.go create mode 100644 internal/importer/types.go diff --git a/internal/importer/extractor.go b/internal/importer/extractor.go new file mode 100644 index 0000000..b55663e --- /dev/null +++ b/internal/importer/extractor.go @@ -0,0 +1,202 @@ +package importer + +import ( + "context" + "encoding/json" + "fmt" + "path/filepath" + "strings" + + "github.com/DevSymphony/sym-cli/internal/llm" + "github.com/DevSymphony/sym-cli/pkg/schema" +) + +// Extractor uses LLM to extract conventions from document content +type Extractor struct { + provider llm.Provider + verbose bool +} + +// NewExtractor creates a new Extractor instance +func NewExtractor(provider llm.Provider, verbose bool) *Extractor { + return &Extractor{ + provider: provider, + verbose: verbose, + } +} + +// Extract analyzes document content and extracts coding conventions +func (e *Extractor) Extract(ctx context.Context, doc *DocumentContent) (*ExtractedConventions, error) { + // Build prompt + prompt := e.buildExtractionPrompt(doc.Content, filepath.Base(doc.Path)) + + // Call LLM + response, err := e.provider.Execute(ctx, prompt, llm.JSON) + if err != nil { + return nil, fmt.Errorf("LLM execution failed: %w", err) + } + + // Parse response + conventions, err := e.parseExtractionResponse(response, doc.Path) + if err != nil { + return nil, fmt.Errorf("failed to parse LLM response: %w", err) + } + + return conventions, nil +} + +// buildExtractionPrompt builds the LLM prompt for convention extraction +func (e *Extractor) buildExtractionPrompt(content string, filename string) string { + // Truncate content if too long + maxContentLen := 40000 + if len(content) > maxContentLen { + content = content[:maxContentLen] + "\n\n... (content truncated)" + } + + return fmt.Sprintf(`You are a coding standards expert. Analyze the following document and extract coding conventions/rules from it. + +SOURCE DOCUMENT: %s + +DOCUMENT CONTENT: +--- +%s +--- + +TASK: Extract all coding conventions, rules, and guidelines from this document. + +OUTPUT FORMAT: Return ONLY valid JSON (no markdown fencing, no preamble text): +{ + "categories": [ + {"name": "category_name", "description": "1-2 sentence description of the category"} + ], + "rules": [ + { + "id": "CATEGORY-001", + "say": "Natural language description of what the rule enforces", + "category": "category_name", + "languages": ["javascript", "typescript"], + "severity": "error", + "message": "Short message shown when rule is violated", + "example": "Optional example of correct/incorrect code" + } + ] +} + +RULES FOR EXTRACTION: +1. Category names MUST be lowercase with underscores (e.g., "error_handling", "code_style") +2. Use standard categories when applicable: security, style, documentation, error_handling, architecture, performance, testing, naming, formatting +3. Rule IDs MUST be unique and follow pattern: UPPERCASE_CATEGORY-NNN (e.g., SEC-001, STYLE-001, DOC-001) +4. The "say" field MUST be a clear, actionable statement (e.g., "Use async/await instead of Promise callbacks") +5. Languages should be lowercase (e.g., "javascript", "python", "go", "java") +6. Severity MUST be one of: "error", "warning", "info" +7. If the document doesn't contain coding conventions, return: {"categories": [], "rules": []} +8. Extract ONLY coding conventions, not general documentation or explanations +9. Each rule should be specific and enforceable + +EXAMPLES OF GOOD EXTRACTIONS: +- "All functions must have JSDoc comments" -> {"id": "DOC-001", "say": "All functions must have JSDoc comments", "category": "documentation", "severity": "warning"} +- "No console.log in production code" -> {"id": "STYLE-001", "say": "Remove all console.log statements from production code", "category": "style", "severity": "error"} +- "Use parameterized queries" -> {"id": "SEC-001", "say": "Use parameterized queries for all database operations to prevent SQL injection", "category": "security", "severity": "error"} +- "Function names must use camelCase" -> {"id": "NAMING-001", "say": "Function names must use camelCase convention", "category": "naming", "severity": "warning"}`, filename, content) +} + +// parseExtractionResponse parses the LLM JSON response into conventions +func (e *Extractor) parseExtractionResponse(response string, source string) (*ExtractedConventions, error) { + // Clean response - remove potential markdown fencing + response = cleanJSONResponse(response) + + // Parse JSON + var llmResponse LLMExtractionResponse + if err := json.Unmarshal([]byte(response), &llmResponse); err != nil { + return nil, fmt.Errorf("invalid JSON response: %w (response: %s)", err, truncateString(response, 200)) + } + + // Convert to schema types + conventions := &ExtractedConventions{ + Source: source, + Categories: make([]schema.CategoryDef, 0, len(llmResponse.Categories)), + Rules: make([]schema.UserRule, 0, len(llmResponse.Rules)), + } + + // Convert categories + for _, cat := range llmResponse.Categories { + if cat.Name == "" { + continue + } + conventions.Categories = append(conventions.Categories, schema.CategoryDef{ + Name: normalizeCategory(cat.Name), + Description: cat.Description, + }) + } + + // Convert rules + for _, rule := range llmResponse.Rules { + if rule.ID == "" || rule.Say == "" { + continue + } + + userRule := schema.UserRule{ + ID: rule.ID, + Say: rule.Say, + Category: normalizeCategory(rule.Category), + Languages: rule.Languages, + Severity: normalizeSeverity(rule.Severity), + Message: rule.Message, + Example: rule.Example, + } + conventions.Rules = append(conventions.Rules, userRule) + } + + return conventions, nil +} + +// cleanJSONResponse removes markdown fencing and extra whitespace from JSON response +func cleanJSONResponse(response string) string { + response = strings.TrimSpace(response) + + // Remove markdown code fencing + if strings.HasPrefix(response, "```json") { + response = strings.TrimPrefix(response, "```json") + } else if strings.HasPrefix(response, "```") { + response = strings.TrimPrefix(response, "```") + } + + response = strings.TrimSuffix(response, "```") + + return strings.TrimSpace(response) +} + +// normalizeCategory normalizes category name to lowercase with underscores +func normalizeCategory(category string) string { + if category == "" { + return "general" + } + // Convert to lowercase and replace spaces/hyphens with underscores + category = strings.ToLower(category) + category = strings.ReplaceAll(category, " ", "_") + category = strings.ReplaceAll(category, "-", "_") + return category +} + +// normalizeSeverity normalizes severity to valid values +func normalizeSeverity(severity string) string { + severity = strings.ToLower(strings.TrimSpace(severity)) + switch severity { + case "error", "err": + return "error" + case "warning", "warn": + return "warning" + case "info", "information": + return "info" + default: + return "warning" // Default to warning + } +} + +// truncateString truncates a string to maxLen characters +func truncateString(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "..." +} diff --git a/internal/importer/importer.go b/internal/importer/importer.go new file mode 100644 index 0000000..381563f --- /dev/null +++ b/internal/importer/importer.go @@ -0,0 +1,173 @@ +package importer + +import ( + "context" + "fmt" + + "github.com/DevSymphony/sym-cli/internal/llm" + "github.com/DevSymphony/sym-cli/internal/policy" + "github.com/DevSymphony/sym-cli/pkg/schema" +) + +// Importer handles the complete import workflow +type Importer struct { + reader *Reader + extractor *Extractor + verbose bool +} + +// NewImporter creates a new Importer instance +func NewImporter(provider llm.Provider, verbose bool) *Importer { + return &Importer{ + reader: NewReader(verbose), + extractor: NewExtractor(provider, verbose), + verbose: verbose, + } +} + +// Import executes the import workflow for a single file +func (i *Importer) Import(ctx context.Context, input *ImportInput) (*ImportResult, error) { + result := &ImportResult{ + CategoriesAdded: []schema.CategoryDef{}, + RulesAdded: []schema.UserRule{}, + Warnings: []string{}, + } + + // Validate input + if input.Path == "" { + return nil, fmt.Errorf("file path is required") + } + + // Step 1: Read the file + doc, err := i.reader.ReadFile(ctx, input.Path) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + result.FileProcessed = doc.Path + + // Step 2: Extract conventions using LLM + extracted, err := i.extractor.Extract(ctx, doc) + if err != nil { + return nil, fmt.Errorf("failed to extract conventions: %w", err) + } + + if len(extracted.Categories) == 0 && len(extracted.Rules) == 0 { + result.Warnings = append(result.Warnings, "No conventions found in the document") + return result, nil + } + + // Step 3: Load existing policy + existingPolicy, err := policy.LoadPolicy("") + if err != nil { + return nil, fmt.Errorf("failed to load existing policy: %w", err) + } + + // Step 4: Apply import mode + if input.Mode == ImportModeClear { + result.CategoriesRemoved = len(existingPolicy.Category) + result.RulesRemoved = len(existingPolicy.Rules) + existingPolicy.Category = []schema.CategoryDef{} + existingPolicy.Rules = []schema.UserRule{} + existingPolicy.Defaults.Languages = []string{} + } + + // Step 5: Generate unique IDs and merge + newCategories, newRules := i.assignUniqueIDs(existingPolicy, extracted, result) + + existingPolicy.Category = append(existingPolicy.Category, newCategories...) + existingPolicy.Rules = append(existingPolicy.Rules, newRules...) + + result.CategoriesAdded = newCategories + result.RulesAdded = newRules + + // Step 5.5: Update defaults.languages with new languages from rules + i.updateDefaultsLanguages(existingPolicy, newRules) + + // Step 6: Save updated policy + if err := policy.SavePolicy(existingPolicy, ""); err != nil { + return result, fmt.Errorf("failed to save policy: %w", err) + } + + return result, nil +} + +// assignUniqueIDs generates unique IDs for all extracted items +func (i *Importer) assignUniqueIDs( + existing *schema.UserPolicy, + extracted *ExtractedConventions, + result *ImportResult, +) ([]schema.CategoryDef, []schema.UserRule) { + // Build map of existing category names + existingCategoryNames := make(map[string]bool) + for _, cat := range existing.Category { + existingCategoryNames[cat.Name] = true + } + + // Build map of existing rule IDs + existingRuleIDs := make(map[string]bool) + for _, rule := range existing.Rules { + existingRuleIDs[rule.ID] = true + } + + // Process categories: skip duplicates + var newCategories []schema.CategoryDef + for _, cat := range extracted.Categories { + if existingCategoryNames[cat.Name] { + result.Warnings = append(result.Warnings, + fmt.Sprintf("Category '%s' already exists, skipped", cat.Name)) + continue + } + newCategories = append(newCategories, cat) + existingCategoryNames[cat.Name] = true + } + + // Process rules: generate unique IDs + var newRules []schema.UserRule + for _, rule := range extracted.Rules { + uniqueID := i.generateUniqueID(rule.ID, existingRuleIDs) + if uniqueID != rule.ID { + result.Warnings = append(result.Warnings, + fmt.Sprintf("Rule ID '%s' already exists, renamed to '%s'", rule.ID, uniqueID)) + } + rule.ID = uniqueID + existingRuleIDs[uniqueID] = true + newRules = append(newRules, rule) + } + + return newCategories, newRules +} + +// generateUniqueID generates a unique rule ID +func (i *Importer) generateUniqueID(baseID string, existingIDs map[string]bool) string { + if !existingIDs[baseID] { + return baseID + } + + counter := 1 + for { + newID := fmt.Sprintf("%s-%d", baseID, counter) + if !existingIDs[newID] { + return newID + } + counter++ + } +} + +// updateDefaultsLanguages adds new languages from rules to defaults.languages +func (i *Importer) updateDefaultsLanguages(p *schema.UserPolicy, newRules []schema.UserRule) { + // Build set of existing default languages + existingLangs := make(map[string]bool) + for _, lang := range p.Defaults.Languages { + existingLangs[lang] = true + } + + // Collect new languages from rules + for _, rule := range newRules { + for _, lang := range rule.Languages { + if !existingLangs[lang] { + p.Defaults.Languages = append(p.Defaults.Languages, lang) + existingLangs[lang] = true + } + } + } +} diff --git a/internal/importer/reader.go b/internal/importer/reader.go new file mode 100644 index 0000000..3f2bd7c --- /dev/null +++ b/internal/importer/reader.go @@ -0,0 +1,133 @@ +package importer + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" +) + +// SupportedFormats lists all supported file extensions +var SupportedFormats = map[string]bool{ + // Text documents + ".txt": true, + ".md": true, + ".markdown": true, + // Code files + ".go": true, + ".js": true, + ".ts": true, + ".jsx": true, + ".tsx": true, + ".py": true, + ".java": true, + ".rs": true, + ".rb": true, + ".php": true, + ".c": true, + ".cpp": true, + ".h": true, + ".hpp": true, + ".cs": true, + ".swift": true, + ".kt": true, + ".scala": true, + // Config/data files + ".yaml": true, + ".yml": true, + ".json": true, + ".toml": true, + ".xml": true, + // Web files + ".html": true, + ".htm": true, + ".css": true, + ".scss": true, + ".less": true, + // Other + ".rst": true, + ".adoc": true, +} + +// MaxFileSizeBytes is the maximum size for a single file (50KB for LLM context) +const MaxFileSizeBytes = 50 * 1024 + +// Reader handles file reading and format detection +type Reader struct { + verbose bool +} + +// NewReader creates a new Reader instance +func NewReader(verbose bool) *Reader { + return &Reader{verbose: verbose} +} + +// ReadFile reads a single file and extracts text content +func (r *Reader) ReadFile(ctx context.Context, filePath string) (*DocumentContent, error) { + // Check context cancellation + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + // Get absolute path + absPath, err := filepath.Abs(filePath) + if err != nil { + return nil, fmt.Errorf("failed to get absolute path: %w", err) + } + + // Check if path exists + info, err := os.Stat(absPath) + if err != nil { + return nil, fmt.Errorf("file not found: %w", err) + } + + // Ensure it's a file, not a directory + if info.IsDir() { + return nil, fmt.Errorf("path is a directory, not a file: %s", absPath) + } + + // Check file extension + ext := strings.ToLower(filepath.Ext(absPath)) + if !IsSupportedFormat(ext) { + return nil, fmt.Errorf("unsupported format: %s", ext) + } + + // Check file size + if info.Size() == 0 { + return nil, fmt.Errorf("empty file") + } + + if info.Size() > MaxFileSizeBytes { + return nil, fmt.Errorf("file too large (%d bytes, max %d bytes)", info.Size(), MaxFileSizeBytes) + } + + // Read file content + content, err := os.ReadFile(absPath) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + + return &DocumentContent{ + Path: absPath, + Content: string(content), + Format: ext, + Size: info.Size(), + }, nil +} + +// IsSupportedFormat checks if a file extension is supported +func IsSupportedFormat(ext string) bool { + return SupportedFormats[strings.ToLower(ext)] +} + +// GetSupportedExtensions returns a list of supported extensions +func GetSupportedExtensions() []string { + var exts []string + for ext := range SupportedFormats { + exts = append(exts, ext) + } + return exts +} diff --git a/internal/importer/types.go b/internal/importer/types.go new file mode 100644 index 0000000..92fda72 --- /dev/null +++ b/internal/importer/types.go @@ -0,0 +1,61 @@ +package importer + +import "github.com/DevSymphony/sym-cli/pkg/schema" + +// ImportMode defines how imported conventions are merged with existing ones +type ImportMode string + +const ( + // ImportModeAppend keeps existing categories/rules and adds new ones + ImportModeAppend ImportMode = "append" + // ImportModeClear removes existing categories/rules, then imports new ones + ImportModeClear ImportMode = "clear" +) + +// ImportInput represents the input for import operation +type ImportInput struct { + Path string // Single file path to import + Mode ImportMode // Import mode (clear or append) +} + +// ImportResult represents the result of import operation +type ImportResult struct { + CategoriesAdded []schema.CategoryDef // New categories added + RulesAdded []schema.UserRule // New rules added + CategoriesRemoved int // Categories removed (clear mode only) + RulesRemoved int // Rules removed (clear mode only) + FileProcessed string // Processed file path + Warnings []string // Non-fatal warnings +} + +// DocumentContent represents parsed document content +type DocumentContent struct { + Path string // File path + Content string // Extracted text content + Format string // Original format (txt, md, go, etc.) + Size int64 // Original file size +} + +// ExtractedConventions represents LLM-extracted conventions from a document +type ExtractedConventions struct { + Categories []schema.CategoryDef + Rules []schema.UserRule + Source string // Source document path +} + +// LLMExtractionResponse represents the expected JSON response from LLM +type LLMExtractionResponse struct { + Categories []struct { + Name string `json:"name"` + Description string `json:"description"` + } `json:"categories"` + Rules []struct { + ID string `json:"id"` + Say string `json:"say"` + Category string `json:"category"` + Languages []string `json:"languages,omitempty"` + Severity string `json:"severity,omitempty"` + Message string `json:"message,omitempty"` + Example string `json:"example,omitempty"` + } `json:"rules"` +} From 7ea721c3d2f02bd2d90d5ffc48d454cf0376d2ac Mon Sep 17 00:00:00 2001 From: ikjeong Date: Thu, 11 Dec 2025 16:42:58 +0000 Subject: [PATCH 2/5] feat: add import command - Add sym import command - Support --mode flag for append/clear mode selection - Add user confirmation prompt before clear mode execution --- internal/cmd/import.go | 147 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 internal/cmd/import.go diff --git a/internal/cmd/import.go b/internal/cmd/import.go new file mode 100644 index 0000000..4525fb6 --- /dev/null +++ b/internal/cmd/import.go @@ -0,0 +1,147 @@ +package cmd + +import ( + "bufio" + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/DevSymphony/sym-cli/internal/importer" + "github.com/DevSymphony/sym-cli/internal/llm" + "github.com/spf13/cobra" +) + +var importMode string + +var importCmd = &cobra.Command{ + Use: "import ", + Short: "Import conventions from external document", + Long: `Import coding conventions from an external document into user-policy.json. + +Supported formats: txt, md, and code files (go, js, ts, py, java, etc.) + +The import process: +1. Reads document content +2. Uses LLM to extract coding conventions +3. Generates categories and rules with unique IDs +4. Merges with existing user-policy.json + +Import modes: + append - Keep existing categories/rules, add new ones (default) + clear - Remove all existing categories/rules, then import`, + Example: ` # Import from a file (append mode, default) + sym import coding-standards.md + + # Clear existing conventions and import fresh + sym import new-rules.md --mode clear`, + Args: cobra.ExactArgs(1), + RunE: runImport, +} + +func init() { + rootCmd.AddCommand(importCmd) + + importCmd.Flags().StringVarP(&importMode, "mode", "m", "append", + "Import mode: 'append' (keep existing, add new) or 'clear' (remove existing, then import)") +} + +func runImport(cmd *cobra.Command, args []string) error { + // Validate mode + mode := importer.ImportModeAppend + if importMode == "clear" { + mode = importer.ImportModeClear + // Confirm clear mode + fmt.Println("WARNING: Clear mode will remove all existing categories and rules.") + fmt.Print("Continue? [y/N]: ") + reader := bufio.NewReader(os.Stdin) + confirm, _ := reader.ReadString('\n') + confirm = strings.TrimSpace(confirm) + if confirm != "y" && confirm != "Y" { + fmt.Println("Import cancelled.") + return nil + } + } else if importMode != "append" { + return fmt.Errorf("invalid mode '%s': must be 'append' or 'clear'", importMode) + } + + // Setup LLM provider + llmCfg := llm.LoadConfig() + llmCfg.Verbose = verbose + llmProvider, err := llm.New(llmCfg) + if err != nil { + return fmt.Errorf("failed to create LLM provider: %w\nTip: configure provider in .sym/config.json", err) + } + defer func() { _ = llmProvider.Close() }() + + // Create importer + imp := importer.NewImporter(llmProvider, verbose) + + // Setup context with timeout + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + // Execute import + input := &importer.ImportInput{ + Path: args[0], + Mode: mode, + } + + printTitle("Import Conventions", fmt.Sprintf("Processing: %s", args[0])) + fmt.Printf("Mode: %s\n\n", importMode) + + result, err := imp.Import(ctx, input) + if err != nil { + // Print partial results if available + if result != nil { + printImportResults(result) + } + return fmt.Errorf("import failed: %w", err) + } + + // Print results + printImportResults(result) + return nil +} + +func printImportResults(result *importer.ImportResult) { + fmt.Println() + + if result.FileProcessed != "" { + printOK(fmt.Sprintf("Processed: %s", result.FileProcessed)) + } + + if result.CategoriesRemoved > 0 || result.RulesRemoved > 0 { + fmt.Println() + printWarn(fmt.Sprintf("Removed %d categories, %d rules (clear mode)", + result.CategoriesRemoved, result.RulesRemoved)) + } + + if len(result.CategoriesAdded) > 0 { + fmt.Println() + printOK(fmt.Sprintf("Added %d categories:", len(result.CategoriesAdded))) + for _, cat := range result.CategoriesAdded { + fmt.Printf(" • %s: %s\n", cat.Name, cat.Description) + } + } + + if len(result.RulesAdded) > 0 { + fmt.Println() + printOK(fmt.Sprintf("Added %d rules:", len(result.RulesAdded))) + for _, rule := range result.RulesAdded { + fmt.Printf(" • [%s] %s (%s)\n", rule.ID, rule.Say, rule.Category) + } + } + + if len(result.Warnings) > 0 { + fmt.Println() + printWarn(fmt.Sprintf("Warnings (%d):", len(result.Warnings))) + for _, w := range result.Warnings { + fmt.Printf(" %s\n", w) + } + } + + fmt.Println() + printDone("Import complete") +} From 3d0bac48c0e4ed203e0b6f3619e2379200668fcb Mon Sep 17 00:00:00 2001 From: ikjeong Date: Thu, 11 Dec 2025 16:43:06 +0000 Subject: [PATCH 3/5] feat: add import_convention MCP tool - Register import_convention tool in MCP server - Enable AI agents to automatically import conventions from documents --- internal/mcp/server.go | 141 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 9515e30..4a95169 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -10,6 +10,7 @@ import ( "time" "github.com/DevSymphony/sym-cli/internal/converter" + "github.com/DevSymphony/sym-cli/internal/importer" "github.com/DevSymphony/sym-cli/internal/llm" "github.com/DevSymphony/sym-cli/internal/policy" "github.com/DevSymphony/sym-cli/internal/roles" @@ -236,6 +237,12 @@ type RemoveCategoryInput struct { Names []string `json:"names" jsonschema:"Array of category names to remove"` } +// ImportConventionsInput represents the input schema for the import_convention tool. +type ImportConventionsInput struct { + Path string `json:"path" jsonschema:"File path to import conventions from"` + Mode string `json:"mode,omitempty" jsonschema:"Import mode: 'append' (default) keeps existing, 'clear' removes existing first"` +} + // runStdioWithSDK runs a spec-compliant MCP server over stdio using the official go-sdk. func (s *Server) runStdioWithSDK(ctx context.Context) error { server := sdkmcp.NewServer(&sdkmcp.Implementation{ @@ -323,6 +330,18 @@ func (s *Server) runStdioWithSDK(ctx context.Context) error { return nil, result.(map[string]any), nil }) + // Tool: import_convention + sdkmcp.AddTool(server, &sdkmcp.Tool{ + Name: "import_convention", + Description: "Import coding conventions from external documents (txt, md, code files) into user-policy.json. Uses LLM to extract categories and rules from document content.", + }, func(ctx context.Context, req *sdkmcp.CallToolRequest, input ImportConventionsInput) (*sdkmcp.CallToolResult, map[string]any, error) { + result, rpcErr := s.handleImportConventions(ctx, input) + if rpcErr != nil { + return &sdkmcp.CallToolResult{IsError: true}, nil, fmt.Errorf("%s", rpcErr.Message) + } + return nil, result.(map[string]any), nil + }) + // Run the server over stdio until the client disconnects return server.Run(ctx, &sdkmcp.StdioTransport{}) } @@ -1175,6 +1194,128 @@ func (s *Server) buildBatchResponse(action string, succeeded []string, failed [] } } +// handleImportConventions handles the import_convention tool. +func (s *Server) handleImportConventions(ctx context.Context, input ImportConventionsInput) (interface{}, *RPCError) { + // Validate input + if input.Path == "" { + return nil, &RPCError{Code: -32602, Message: "File path is required"} + } + + // Default mode to append + mode := importer.ImportModeAppend + if input.Mode == "clear" { + mode = importer.ImportModeClear + } + + // Setup LLM provider + llmCfg := llm.LoadConfig() + llmProvider, err := llm.New(llmCfg) + if err != nil { + return nil, &RPCError{Code: -32000, Message: fmt.Sprintf("Failed to create LLM provider: %v", err)} + } + defer func() { _ = llmProvider.Close() }() + + // Create importer and execute + imp := importer.NewImporter(llmProvider, false) + importInput := &importer.ImportInput{ + Path: input.Path, + Mode: mode, + } + + result, err := imp.Import(ctx, importInput) + if err != nil { + // Build partial result response if available + if result != nil { + return s.buildImportResponse(result, err), nil + } + return nil, &RPCError{Code: -32000, Message: fmt.Sprintf("Import failed: %v", err)} + } + + // Reload user policy after import + if s.userPolicy != nil { + userPolicyPath := s.getUserPolicyPath() + if userPolicy, err := s.loader.LoadUserPolicy(userPolicyPath); err == nil { + s.userPolicy = userPolicy + } + } + + return s.buildImportResponse(result, nil), nil +} + +// buildImportResponse builds the MCP response for import operation. +func (s *Server) buildImportResponse(result *importer.ImportResult, importErr error) map[string]interface{} { + var textContent strings.Builder + textContent.WriteString("Convention Import ") + + if importErr != nil { + textContent.WriteString("(completed with errors)\n\n") + } else { + textContent.WriteString("Complete\n\n") + } + + if result.FileProcessed != "" { + textContent.WriteString(fmt.Sprintf("Processed: %s\n\n", result.FileProcessed)) + } + + if result.CategoriesRemoved > 0 || result.RulesRemoved > 0 { + textContent.WriteString(fmt.Sprintf("Removed: %d categories, %d rules (clear mode)\n\n", + result.CategoriesRemoved, result.RulesRemoved)) + } + + if len(result.CategoriesAdded) > 0 { + textContent.WriteString(fmt.Sprintf("Added %d categories:\n", len(result.CategoriesAdded))) + for _, cat := range result.CategoriesAdded { + textContent.WriteString(fmt.Sprintf(" • %s: %s\n", cat.Name, cat.Description)) + } + textContent.WriteString("\n") + } + + if len(result.RulesAdded) > 0 { + textContent.WriteString(fmt.Sprintf("Added %d rules:\n", len(result.RulesAdded))) + for _, rule := range result.RulesAdded { + textContent.WriteString(fmt.Sprintf(" • [%s] %s (%s)\n", rule.ID, rule.Say, rule.Category)) + } + textContent.WriteString("\n") + } + + if len(result.Warnings) > 0 { + textContent.WriteString(fmt.Sprintf("Warnings (%d):\n", len(result.Warnings))) + for _, w := range result.Warnings { + textContent.WriteString(fmt.Sprintf(" • %s\n", w)) + } + textContent.WriteString("\n") + } + + if importErr != nil { + textContent.WriteString(fmt.Sprintf("Import Error: %v\n", importErr)) + } + + return map[string]interface{}{ + "content": []map[string]interface{}{ + {"type": "text", "text": textContent.String()}, + }, + } +} + +// getUserPolicyPath returns the user policy file path. +func (s *Server) getUserPolicyPath() string { + projectCfg, _ := config.LoadProjectConfig() + userPolicyPath := projectCfg.PolicyPath + if userPolicyPath == "" { + repoRoot, err := git.GetRepoRoot() + if err != nil { + return ".sym/user-policy.json" + } + return filepath.Join(repoRoot, ".sym", "user-policy.json") + } + if !filepath.IsAbs(userPolicyPath) { + if repoRoot, err := git.GetRepoRoot(); err == nil { + userPolicyPath = filepath.Join(repoRoot, userPolicyPath) + } + } + return userPolicyPath +} + // saveUserPolicy saves the user policy to file. func (s *Server) saveUserPolicy() error { return policy.SavePolicy(s.userPolicy, "") From 495893d837bf3eb030b7b9ec47384a3ac2c4f820 Mon Sep 17 00:00:00 2001 From: ikjeong Date: Thu, 11 Dec 2025 16:43:15 +0000 Subject: [PATCH 4/5] feat: add import UI to dashboard - Add /api/import HTTP endpoint - Implement import modal UI - Support file path input and mode selection --- internal/server/server.go | 109 ++++++++++++++++++++++++ internal/server/static/index.html | 38 +++++++++ internal/server/static/policy-editor.js | 80 +++++++++++++++++ 3 files changed, 227 insertions(+) diff --git a/internal/server/server.go b/internal/server/server.go index 864c537..a6d3775 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -14,6 +14,7 @@ import ( "time" "github.com/DevSymphony/sym-cli/internal/converter" + "github.com/DevSymphony/sym-cli/internal/importer" "github.com/DevSymphony/sym-cli/internal/llm" "github.com/DevSymphony/sym-cli/internal/policy" "github.com/DevSymphony/sym-cli/internal/roles" @@ -60,6 +61,9 @@ func (s *Server) Start() error { mux.HandleFunc("/api/categories", s.handleCategories) mux.HandleFunc("/api/categories/", s.handleCategoryByName) + // Import API endpoint + mux.HandleFunc("/api/import", s.handleImport) + // Static files staticFS, err := fs.Sub(staticFiles, "static") if err != nil { @@ -1006,3 +1010,108 @@ func (s *Server) handleDeleteCategory(w http.ResponseWriter, r *http.Request, ca "message": fmt.Sprintf("Category '%s' removed successfully", categoryName), }) } + +// ImportRequest represents the HTTP request body for import +type ImportRequest struct { + Path string `json:"path"` // Single file path to import + Mode string `json:"mode"` // "append" or "clear" +} + +// ImportResponse represents the HTTP response for import +type ImportResponse struct { + Status string `json:"status"` + CategoriesAdded []schema.CategoryDef `json:"categoriesAdded"` + RulesAdded []schema.UserRule `json:"rulesAdded"` + CategoriesRemoved int `json:"categoriesRemoved,omitempty"` + RulesRemoved int `json:"rulesRemoved,omitempty"` + FileProcessed string `json:"fileProcessed"` + Warnings []string `json:"warnings,omitempty"` + Error string `json:"error,omitempty"` +} + +// handleImport handles POST /api/import +func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Check permission + currentRole, err := roles.GetCurrentRole() + if err != nil { + http.Error(w, "Failed to get current role", http.StatusInternalServerError) + return + } + + canEdit, err := s.hasPermissionForRole(currentRole, "editPolicy") + if err != nil || !canEdit { + http.Error(w, "Forbidden: editPolicy permission required", http.StatusForbidden) + return + } + + // Parse request + var req ImportRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Validate + if req.Path == "" { + http.Error(w, "File path is required", http.StatusBadRequest) + return + } + + // Setup LLM provider + llmCfg := llm.LoadConfig() + llmProvider, err := llm.New(llmCfg) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to create LLM provider: %v", err), http.StatusInternalServerError) + return + } + defer func() { _ = llmProvider.Close() }() + + // Create importer and execute + mode := importer.ImportModeAppend + if req.Mode == "clear" { + mode = importer.ImportModeClear + } + + imp := importer.NewImporter(llmProvider, false) + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute) + defer cancel() + + input := &importer.ImportInput{ + Path: req.Path, + Mode: mode, + } + + result, err := imp.Import(ctx, input) + + // Build response + response := ImportResponse{ + Status: "success", + } + + if result != nil { + response.CategoriesAdded = result.CategoriesAdded + response.RulesAdded = result.RulesAdded + response.CategoriesRemoved = result.CategoriesRemoved + response.RulesRemoved = result.RulesRemoved + response.FileProcessed = result.FileProcessed + response.Warnings = result.Warnings + } + + if err != nil { + response.Status = "error" + response.Error = err.Error() + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) +} diff --git a/internal/server/static/index.html b/internal/server/static/index.html index e699a99..e74cf8b 100644 --- a/internal/server/static/index.html +++ b/internal/server/static/index.html @@ -46,6 +46,9 @@

템플릿 + @@ -211,6 +214,41 @@

+ + +