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>
200 lines
5.1 KiB
Go
200 lines
5.1 KiB
Go
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, ".")
|
|
}
|