feat: MCP tools and nixpkgs indexer
- Add options.json parser with mdDoc support - Add nixpkgs indexer using nix-build - Implement all MCP tool handlers: - search_options: Full-text search with filters - get_option: Option details with children - get_file: Fetch file contents - index_revision: Build and index options - list_revisions: Show indexed versions - delete_revision: Remove indexed data - Add parser tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
126
internal/nixos/parser.go
Normal file
126
internal/nixos/parser.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package nixos
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// ParseOptions parses the options.json file from nixpkgs.
|
||||
// The options.json structure is a map from option name to option definition.
|
||||
func ParseOptions(r io.Reader) (map[string]*ParsedOption, error) {
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.NewDecoder(r).Decode(&raw); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode options.json: %w", err)
|
||||
}
|
||||
|
||||
options := make(map[string]*ParsedOption, len(raw))
|
||||
for name, data := range raw {
|
||||
opt, err := parseOption(name, data)
|
||||
if err != nil {
|
||||
// Log but don't fail - some options might have unusual formats
|
||||
continue
|
||||
}
|
||||
options[name] = opt
|
||||
}
|
||||
|
||||
return options, nil
|
||||
}
|
||||
|
||||
// ParsedOption represents a parsed NixOS option with all its metadata.
|
||||
type ParsedOption struct {
|
||||
Name string
|
||||
Type string
|
||||
Description string
|
||||
Default string // JSON-encoded value
|
||||
Example string // JSON-encoded value
|
||||
ReadOnly bool
|
||||
Declarations []string
|
||||
}
|
||||
|
||||
// optionJSON is the internal structure for parsing options.json entries.
|
||||
type optionJSON struct {
|
||||
Declarations []string `json:"declarations"`
|
||||
Default json.RawMessage `json:"default,omitempty"`
|
||||
Description interface{} `json:"description"` // Can be string or object
|
||||
Example json.RawMessage `json:"example,omitempty"`
|
||||
ReadOnly bool `json:"readOnly"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// parseOption parses a single option entry.
|
||||
func parseOption(name string, data json.RawMessage) (*ParsedOption, error) {
|
||||
var opt optionJSON
|
||||
if err := json.Unmarshal(data, &opt); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse option %s: %w", name, err)
|
||||
}
|
||||
|
||||
// Handle description which can be a string or an object with _type: "mdDoc"
|
||||
description := extractDescription(opt.Description)
|
||||
|
||||
// Convert declarations to relative paths
|
||||
declarations := make([]string, 0, len(opt.Declarations))
|
||||
for _, d := range opt.Declarations {
|
||||
declarations = append(declarations, normalizeDeclarationPath(d))
|
||||
}
|
||||
|
||||
return &ParsedOption{
|
||||
Name: name,
|
||||
Type: opt.Type,
|
||||
Description: description,
|
||||
Default: string(opt.Default),
|
||||
Example: string(opt.Example),
|
||||
ReadOnly: opt.ReadOnly,
|
||||
Declarations: declarations,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// extractDescription extracts the description string from various formats.
|
||||
func extractDescription(desc interface{}) string {
|
||||
switch v := desc.(type) {
|
||||
case string:
|
||||
return v
|
||||
case map[string]interface{}:
|
||||
// Handle mdDoc format: {"_type": "mdDoc", "text": "..."}
|
||||
if text, ok := v["text"].(string); ok {
|
||||
return text
|
||||
}
|
||||
// Try "description" key
|
||||
if text, ok := v["description"].(string); ok {
|
||||
return text
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// normalizeDeclarationPath converts a full store path to a relative nixpkgs path.
|
||||
// Input: "/nix/store/xxx-source/nixos/modules/services/web-servers/nginx/default.nix"
|
||||
// Output: "nixos/modules/services/web-servers/nginx/default.nix"
|
||||
func normalizeDeclarationPath(path string) string {
|
||||
// Look for common prefixes and strip them
|
||||
markers := []string{
|
||||
"/nixos/",
|
||||
"/pkgs/",
|
||||
"/lib/",
|
||||
"/maintainers/",
|
||||
}
|
||||
|
||||
for _, marker := range markers {
|
||||
if idx := findSubstring(path, marker); idx >= 0 {
|
||||
return path[idx+1:] // +1 to skip the leading /
|
||||
}
|
||||
}
|
||||
|
||||
// If no marker found, return as-is
|
||||
return path
|
||||
}
|
||||
|
||||
// findSubstring returns the index of the first occurrence of substr in s, or -1.
|
||||
func findSubstring(s, substr string) int {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
Reference in New Issue
Block a user