feat: add nixpkgs-search binary with package search support
Add a new nixpkgs-search CLI that combines NixOS options search with Nix package search functionality. This provides two MCP servers from a single binary: - `nixpkgs-search options serve` for NixOS options - `nixpkgs-search packages serve` for Nix packages Key changes: - Add packages table to database schema (version 3) - Add Package type and search methods to database layer - Create internal/packages/ with indexer and parser for nix-env JSON - Add MCP server mode (options/packages) with separate tool sets - Add package handlers: search_packages, get_package - Create cmd/nixpkgs-search with combined indexing support - Update flake.nix with nixpkgs-search package (now default) - Bump version to 0.2.0 The index command can index both options and packages together, or use --no-packages/--no-options flags for partial indexing. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
199
internal/packages/parser.go
Normal file
199
internal/packages/parser.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package packages
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ParsePackages reads and parses a nix-env JSON output file.
|
||||
func ParsePackages(r io.Reader) (map[string]*ParsedPackage, error) {
|
||||
var raw PackagesFile
|
||||
if err := json.NewDecoder(r).Decode(&raw); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode packages JSON: %w", err)
|
||||
}
|
||||
|
||||
packages := make(map[string]*ParsedPackage, len(raw))
|
||||
for attrPath, pkg := range raw {
|
||||
parsed := &ParsedPackage{
|
||||
AttrPath: attrPath,
|
||||
Pname: pkg.Pname,
|
||||
Version: pkg.Version,
|
||||
Description: pkg.Meta.Description,
|
||||
LongDescription: pkg.Meta.LongDescription,
|
||||
Homepage: normalizeHomepage(pkg.Meta.Homepage),
|
||||
License: normalizeLicense(pkg.Meta.License),
|
||||
Platforms: normalizePlatforms(pkg.Meta.Platforms),
|
||||
Maintainers: normalizeMaintainers(pkg.Meta.Maintainers),
|
||||
Broken: pkg.Meta.Broken,
|
||||
Unfree: pkg.Meta.Unfree,
|
||||
Insecure: pkg.Meta.Insecure,
|
||||
}
|
||||
packages[attrPath] = parsed
|
||||
}
|
||||
|
||||
return packages, nil
|
||||
}
|
||||
|
||||
// normalizeHomepage converts homepage to a string.
|
||||
func normalizeHomepage(v interface{}) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
switch hp := v.(type) {
|
||||
case string:
|
||||
return hp
|
||||
case []interface{}:
|
||||
if len(hp) > 0 {
|
||||
if s, ok := hp[0].(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// normalizeLicense converts license to a JSON array string.
|
||||
func normalizeLicense(v interface{}) string {
|
||||
if v == nil {
|
||||
return "[]"
|
||||
}
|
||||
|
||||
licenses := make([]string, 0)
|
||||
|
||||
switch l := v.(type) {
|
||||
case string:
|
||||
licenses = append(licenses, l)
|
||||
case map[string]interface{}:
|
||||
// Single license object
|
||||
if spdxID, ok := l["spdxId"].(string); ok {
|
||||
licenses = append(licenses, spdxID)
|
||||
} else if fullName, ok := l["fullName"].(string); ok {
|
||||
licenses = append(licenses, fullName)
|
||||
} else if shortName, ok := l["shortName"].(string); ok {
|
||||
licenses = append(licenses, shortName)
|
||||
}
|
||||
case []interface{}:
|
||||
for _, item := range l {
|
||||
switch li := item.(type) {
|
||||
case string:
|
||||
licenses = append(licenses, li)
|
||||
case map[string]interface{}:
|
||||
if spdxID, ok := li["spdxId"].(string); ok {
|
||||
licenses = append(licenses, spdxID)
|
||||
} else if fullName, ok := li["fullName"].(string); ok {
|
||||
licenses = append(licenses, fullName)
|
||||
} else if shortName, ok := li["shortName"].(string); ok {
|
||||
licenses = append(licenses, shortName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(licenses)
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// normalizePlatforms converts platforms to a JSON array string.
|
||||
func normalizePlatforms(v []interface{}) string {
|
||||
if v == nil {
|
||||
return "[]"
|
||||
}
|
||||
|
||||
platforms := make([]string, 0, len(v))
|
||||
for _, p := range v {
|
||||
switch pv := p.(type) {
|
||||
case string:
|
||||
platforms = append(platforms, pv)
|
||||
// Skip complex platform specs (objects)
|
||||
}
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(platforms)
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// normalizeMaintainers converts maintainers to a JSON array string.
|
||||
func normalizeMaintainers(maintainers []Maintainer) string {
|
||||
if len(maintainers) == 0 {
|
||||
return "[]"
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(maintainers))
|
||||
for _, m := range maintainers {
|
||||
name := m.Name
|
||||
if name == "" && m.Github != "" {
|
||||
name = "@" + m.Github
|
||||
}
|
||||
if name != "" {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(names)
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// ParsePackagesStream parses packages from a reader using streaming to reduce memory usage.
|
||||
// It yields parsed packages through a callback function.
|
||||
func ParsePackagesStream(r io.Reader, callback func(*ParsedPackage) error) (int, error) {
|
||||
dec := json.NewDecoder(r)
|
||||
|
||||
// Read the opening brace
|
||||
t, err := dec.Token()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to read opening token: %w", err)
|
||||
}
|
||||
if delim, ok := t.(json.Delim); !ok || delim != '{' {
|
||||
return 0, fmt.Errorf("expected opening brace, got %v", t)
|
||||
}
|
||||
|
||||
count := 0
|
||||
for dec.More() {
|
||||
// Read the key (attr path)
|
||||
t, err := dec.Token()
|
||||
if err != nil {
|
||||
return count, fmt.Errorf("failed to read attr path: %w", err)
|
||||
}
|
||||
attrPath, ok := t.(string)
|
||||
if !ok {
|
||||
return count, fmt.Errorf("expected string key, got %T", t)
|
||||
}
|
||||
|
||||
// Read the value (package)
|
||||
var pkg RawPackage
|
||||
if err := dec.Decode(&pkg); err != nil {
|
||||
// Skip malformed packages
|
||||
continue
|
||||
}
|
||||
|
||||
parsed := &ParsedPackage{
|
||||
AttrPath: attrPath,
|
||||
Pname: pkg.Pname,
|
||||
Version: pkg.Version,
|
||||
Description: pkg.Meta.Description,
|
||||
LongDescription: pkg.Meta.LongDescription,
|
||||
Homepage: normalizeHomepage(pkg.Meta.Homepage),
|
||||
License: normalizeLicense(pkg.Meta.License),
|
||||
Platforms: normalizePlatforms(pkg.Meta.Platforms),
|
||||
Maintainers: normalizeMaintainers(pkg.Meta.Maintainers),
|
||||
Broken: pkg.Meta.Broken,
|
||||
Unfree: pkg.Meta.Unfree,
|
||||
Insecure: pkg.Meta.Insecure,
|
||||
}
|
||||
|
||||
if err := callback(parsed); err != nil {
|
||||
return count, fmt.Errorf("callback error for %s: %w", attrPath, err)
|
||||
}
|
||||
count++
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// SplitAttrPath splits an attribute path into its components.
|
||||
// For example, "python312Packages.requests" returns ["python312Packages", "requests"].
|
||||
func SplitAttrPath(attrPath string) []string {
|
||||
return strings.Split(attrPath, ".")
|
||||
}
|
||||
Reference in New Issue
Block a user