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:
2026-02-04 17:12:41 +01:00
parent 9efcca217c
commit ea4c69bc23
17 changed files with 2559 additions and 63 deletions

View File

@@ -44,10 +44,12 @@ func (s *SQLiteStore) Initialize(ctx context.Context) error {
dropStmts := []string{
DropDeclarations,
DropOptions,
DropPackages,
DropFiles,
DropRevisions,
DropSchemaInfo,
"DROP TABLE IF EXISTS options_fts",
"DROP TABLE IF EXISTS packages_fts",
}
for _, stmt := range dropStmts {
if _, err := s.db.ExecContext(ctx, stmt); err != nil {
@@ -63,10 +65,13 @@ func (s *SQLiteStore) Initialize(ctx context.Context) error {
OptionsTable,
DeclarationsTable,
FilesTable,
PackagesTable,
IndexOptionsRevisionName,
IndexOptionsRevisionParent,
IndexFilesRevisionPath,
IndexDeclarationsOption,
IndexPackagesRevisionAttr,
IndexPackagesRevisionPname,
}
for _, stmt := range createStmts {
@@ -88,8 +93,8 @@ func (s *SQLiteStore) Initialize(ctx context.Context) error {
return fmt.Errorf("failed to create FTS table: %w", err)
}
// Create triggers to keep FTS in sync
triggers := []string{
// Create triggers to keep options FTS in sync
optionsTriggers := []string{
`CREATE TRIGGER IF NOT EXISTS options_ai AFTER INSERT ON options BEGIN
INSERT INTO options_fts(rowid, name, description) VALUES (new.id, new.name, new.description);
END`,
@@ -101,9 +106,42 @@ func (s *SQLiteStore) Initialize(ctx context.Context) error {
INSERT INTO options_fts(rowid, name, description) VALUES (new.id, new.name, new.description);
END`,
}
for _, trigger := range triggers {
for _, trigger := range optionsTriggers {
if _, err := s.db.ExecContext(ctx, trigger); err != nil {
return fmt.Errorf("failed to create trigger: %w", err)
return fmt.Errorf("failed to create options trigger: %w", err)
}
}
// Create FTS5 virtual table for packages full-text search
_, err = s.db.ExecContext(ctx, `
CREATE VIRTUAL TABLE IF NOT EXISTS packages_fts USING fts5(
attr_path,
pname,
description,
content='packages',
content_rowid='id'
)
`)
if err != nil {
return fmt.Errorf("failed to create packages FTS table: %w", err)
}
// Create triggers to keep packages FTS in sync
packagesTriggers := []string{
`CREATE TRIGGER IF NOT EXISTS packages_ai AFTER INSERT ON packages BEGIN
INSERT INTO packages_fts(rowid, attr_path, pname, description) VALUES (new.id, new.attr_path, new.pname, new.description);
END`,
`CREATE TRIGGER IF NOT EXISTS packages_ad AFTER DELETE ON packages BEGIN
INSERT INTO packages_fts(packages_fts, rowid, attr_path, pname, description) VALUES('delete', old.id, old.attr_path, old.pname, old.description);
END`,
`CREATE TRIGGER IF NOT EXISTS packages_au AFTER UPDATE ON packages BEGIN
INSERT INTO packages_fts(packages_fts, rowid, attr_path, pname, description) VALUES('delete', old.id, old.attr_path, old.pname, old.description);
INSERT INTO packages_fts(rowid, attr_path, pname, description) VALUES (new.id, new.attr_path, new.pname, new.description);
END`,
}
for _, trigger := range packagesTriggers {
if _, err := s.db.ExecContext(ctx, trigger); err != nil {
return fmt.Errorf("failed to create packages trigger: %w", err)
}
}
@@ -127,9 +165,9 @@ func (s *SQLiteStore) Close() error {
// CreateRevision creates a new revision record.
func (s *SQLiteStore) CreateRevision(ctx context.Context, rev *Revision) error {
result, err := s.db.ExecContext(ctx, `
INSERT INTO revisions (git_hash, channel_name, commit_date, option_count)
VALUES (?, ?, ?, ?)`,
rev.GitHash, rev.ChannelName, rev.CommitDate, rev.OptionCount,
INSERT INTO revisions (git_hash, channel_name, commit_date, option_count, package_count)
VALUES (?, ?, ?, ?, ?)`,
rev.GitHash, rev.ChannelName, rev.CommitDate, rev.OptionCount, rev.PackageCount,
)
if err != nil {
return fmt.Errorf("failed to create revision: %w", err)
@@ -155,9 +193,9 @@ func (s *SQLiteStore) CreateRevision(ctx context.Context, rev *Revision) error {
func (s *SQLiteStore) GetRevision(ctx context.Context, gitHash string) (*Revision, error) {
rev := &Revision{}
err := s.db.QueryRowContext(ctx, `
SELECT id, git_hash, channel_name, commit_date, indexed_at, option_count
SELECT id, git_hash, channel_name, commit_date, indexed_at, option_count, package_count
FROM revisions WHERE git_hash = ?`, gitHash,
).Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount)
).Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount, &rev.PackageCount)
if err == sql.ErrNoRows {
return nil, nil
}
@@ -171,10 +209,10 @@ func (s *SQLiteStore) GetRevision(ctx context.Context, gitHash string) (*Revisio
func (s *SQLiteStore) GetRevisionByChannel(ctx context.Context, channel string) (*Revision, error) {
rev := &Revision{}
err := s.db.QueryRowContext(ctx, `
SELECT id, git_hash, channel_name, commit_date, indexed_at, option_count
SELECT id, git_hash, channel_name, commit_date, indexed_at, option_count, package_count
FROM revisions WHERE channel_name = ?
ORDER BY indexed_at DESC LIMIT 1`, channel,
).Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount)
).Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount, &rev.PackageCount)
if err == sql.ErrNoRows {
return nil, nil
}
@@ -187,17 +225,17 @@ func (s *SQLiteStore) GetRevisionByChannel(ctx context.Context, channel string)
// ListRevisions returns all indexed revisions.
func (s *SQLiteStore) ListRevisions(ctx context.Context) ([]*Revision, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, git_hash, channel_name, commit_date, indexed_at, option_count
SELECT id, git_hash, channel_name, commit_date, indexed_at, option_count, package_count
FROM revisions ORDER BY indexed_at DESC`)
if err != nil {
return nil, fmt.Errorf("failed to list revisions: %w", err)
}
defer rows.Close() //nolint:errcheck // rows.Err() checked after iteration //nolint:errcheck // rows.Err() checked after iteration
defer rows.Close() //nolint:errcheck // rows.Err() checked after iteration
var revisions []*Revision
for rows.Next() {
rev := &Revision{}
if err := rows.Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount); err != nil {
if err := rows.Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount, &rev.PackageCount); err != nil {
return nil, fmt.Errorf("failed to scan revision: %w", err)
}
revisions = append(revisions, rev)
@@ -588,6 +626,138 @@ func (s *SQLiteStore) GetFileWithRange(ctx context.Context, revisionID int64, pa
return applyLineRange(file, r), nil
}
// CreatePackage creates a new package record.
func (s *SQLiteStore) CreatePackage(ctx context.Context, pkg *Package) error {
result, err := s.db.ExecContext(ctx, `
INSERT INTO packages (revision_id, attr_path, pname, version, description, long_description, homepage, license, platforms, maintainers, broken, unfree, insecure)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
pkg.RevisionID, pkg.AttrPath, pkg.Pname, pkg.Version, pkg.Description, pkg.LongDescription, pkg.Homepage, pkg.License, pkg.Platforms, pkg.Maintainers, pkg.Broken, pkg.Unfree, pkg.Insecure,
)
if err != nil {
return fmt.Errorf("failed to create package: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("failed to get last insert id: %w", err)
}
pkg.ID = id
return nil
}
// CreatePackagesBatch creates multiple packages in a batch.
func (s *SQLiteStore) CreatePackagesBatch(ctx context.Context, pkgs []*Package) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback() //nolint:errcheck // rollback after commit returns error, which is expected
stmt, err := tx.PrepareContext(ctx, `
INSERT INTO packages (revision_id, attr_path, pname, version, description, long_description, homepage, license, platforms, maintainers, broken, unfree, insecure)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
if err != nil {
return fmt.Errorf("failed to prepare statement: %w", err)
}
defer stmt.Close() //nolint:errcheck // statement closed with transaction
for _, pkg := range pkgs {
result, err := stmt.ExecContext(ctx,
pkg.RevisionID, pkg.AttrPath, pkg.Pname, pkg.Version, pkg.Description, pkg.LongDescription, pkg.Homepage, pkg.License, pkg.Platforms, pkg.Maintainers, pkg.Broken, pkg.Unfree, pkg.Insecure,
)
if err != nil {
return fmt.Errorf("failed to insert package %s: %w", pkg.AttrPath, err)
}
id, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("failed to get last insert id: %w", err)
}
pkg.ID = id
}
return tx.Commit()
}
// GetPackage retrieves a package by revision and attr_path.
func (s *SQLiteStore) GetPackage(ctx context.Context, revisionID int64, attrPath string) (*Package, error) {
pkg := &Package{}
err := s.db.QueryRowContext(ctx, `
SELECT id, revision_id, attr_path, pname, version, description, long_description, homepage, license, platforms, maintainers, broken, unfree, insecure
FROM packages WHERE revision_id = ? AND attr_path = ?`, revisionID, attrPath,
).Scan(&pkg.ID, &pkg.RevisionID, &pkg.AttrPath, &pkg.Pname, &pkg.Version, &pkg.Description, &pkg.LongDescription, &pkg.Homepage, &pkg.License, &pkg.Platforms, &pkg.Maintainers, &pkg.Broken, &pkg.Unfree, &pkg.Insecure)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get package: %w", err)
}
return pkg, nil
}
// SearchPackages searches for packages matching a query.
func (s *SQLiteStore) SearchPackages(ctx context.Context, revisionID int64, query string, filters PackageSearchFilters) ([]*Package, error) {
baseQuery := `
SELECT p.id, p.revision_id, p.attr_path, p.pname, p.version, p.description, p.long_description, p.homepage, p.license, p.platforms, p.maintainers, p.broken, p.unfree, p.insecure
FROM packages p
INNER JOIN packages_fts fts ON p.id = fts.rowid
WHERE p.revision_id = ?
AND packages_fts MATCH ?`
// Escape the query for FTS5 by wrapping in double quotes for literal matching.
escapedQuery := `"` + strings.ReplaceAll(query, `"`, `""`) + `"`
args := []interface{}{revisionID, escapedQuery}
if filters.Broken != nil {
baseQuery += " AND p.broken = ?"
args = append(args, *filters.Broken)
}
if filters.Unfree != nil {
baseQuery += " AND p.unfree = ?"
args = append(args, *filters.Unfree)
}
if filters.Insecure != nil {
baseQuery += " AND p.insecure = ?"
args = append(args, *filters.Insecure)
}
baseQuery += " ORDER BY p.attr_path"
if filters.Limit > 0 {
baseQuery += fmt.Sprintf(" LIMIT %d", filters.Limit)
}
if filters.Offset > 0 {
baseQuery += fmt.Sprintf(" OFFSET %d", filters.Offset)
}
rows, err := s.db.QueryContext(ctx, baseQuery, args...)
if err != nil {
return nil, fmt.Errorf("failed to search packages: %w", err)
}
defer rows.Close() //nolint:errcheck // rows.Err() checked after iteration
var packages []*Package
for rows.Next() {
pkg := &Package{}
if err := rows.Scan(&pkg.ID, &pkg.RevisionID, &pkg.AttrPath, &pkg.Pname, &pkg.Version, &pkg.Description, &pkg.LongDescription, &pkg.Homepage, &pkg.License, &pkg.Platforms, &pkg.Maintainers, &pkg.Broken, &pkg.Unfree, &pkg.Insecure); err != nil {
return nil, fmt.Errorf("failed to scan package: %w", err)
}
packages = append(packages, pkg)
}
return packages, rows.Err()
}
// UpdateRevisionPackageCount updates the package count for a revision.
func (s *SQLiteStore) UpdateRevisionPackageCount(ctx context.Context, id int64, count int) error {
_, err := s.db.ExecContext(ctx,
"UPDATE revisions SET package_count = ? WHERE id = ?", count, id)
if err != nil {
return fmt.Errorf("failed to update package count: %w", err)
}
return nil
}
// countLines counts the number of lines in content.
func countLines(content string) int {
if content == "" {