package main import ( "context" "fmt" "log" "os" "os/signal" "strings" "syscall" "time" "github.com/urfave/cli/v2" "code.t-juice.club/torjus/labmcp/internal/database" "code.t-juice.club/torjus/labmcp/internal/mcp" "code.t-juice.club/torjus/labmcp/internal/nixos" "code.t-juice.club/torjus/labmcp/internal/packages" ) const ( defaultDatabase = "sqlite://nixpkgs-search.db" version = "0.4.0" ) func main() { app := &cli.App{ Name: "nixpkgs-search", Usage: "Search nixpkgs options and packages", Version: version, Flags: []cli.Flag{ &cli.StringFlag{ Name: "database", Aliases: []string{"d"}, Usage: "Database connection string (postgres://... or sqlite://...)", EnvVars: []string{"NIXPKGS_SEARCH_DATABASE"}, Value: defaultDatabase, }, }, Commands: []*cli.Command{ optionsCommand(), packagesCommand(), indexCommand(), listCommand(), deleteCommand(), }, } if err := app.Run(os.Args); err != nil { log.Fatal(err) } } // optionsCommand returns the options subcommand. func optionsCommand() *cli.Command { return &cli.Command{ Name: "options", Usage: "NixOS options commands", Subcommands: []*cli.Command{ { Name: "serve", Usage: "Run MCP server for NixOS options", Flags: serveFlags(), Action: func(c *cli.Context) error { return runOptionsServe(c) }, }, { Name: "search", Usage: "Search for options", ArgsUsage: "", Flags: []cli.Flag{ &cli.StringFlag{ Name: "revision", Aliases: []string{"r"}, Usage: "Revision to search (default: most recent)", }, &cli.IntFlag{ Name: "limit", Aliases: []string{"n"}, Usage: "Maximum number of results", Value: 20, }, }, Action: func(c *cli.Context) error { if c.NArg() < 1 { return fmt.Errorf("query argument required") } return runOptionsSearch(c, c.Args().First()) }, }, { Name: "get", Usage: "Get details for a specific option", ArgsUsage: "", Flags: []cli.Flag{ &cli.StringFlag{ Name: "revision", Aliases: []string{"r"}, Usage: "Revision to search (default: most recent)", }, }, Action: func(c *cli.Context) error { if c.NArg() < 1 { return fmt.Errorf("option name required") } return runOptionsGet(c, c.Args().First()) }, }, }, } } // packagesCommand returns the packages subcommand. func packagesCommand() *cli.Command { return &cli.Command{ Name: "packages", Usage: "Nix packages commands", Subcommands: []*cli.Command{ { Name: "serve", Usage: "Run MCP server for Nix packages", Flags: serveFlags(), Action: func(c *cli.Context) error { return runPackagesServe(c) }, }, { Name: "search", Usage: "Search for packages", ArgsUsage: "", Flags: []cli.Flag{ &cli.StringFlag{ Name: "revision", Aliases: []string{"r"}, Usage: "Revision to search (default: most recent)", }, &cli.IntFlag{ Name: "limit", Aliases: []string{"n"}, Usage: "Maximum number of results", Value: 20, }, &cli.BoolFlag{ Name: "broken", Usage: "Include broken packages only", }, &cli.BoolFlag{ Name: "unfree", Usage: "Include unfree packages only", }, }, Action: func(c *cli.Context) error { if c.NArg() < 1 { return fmt.Errorf("query argument required") } return runPackagesSearch(c, c.Args().First()) }, }, { Name: "get", Usage: "Get details for a specific package", ArgsUsage: "", Flags: []cli.Flag{ &cli.StringFlag{ Name: "revision", Aliases: []string{"r"}, Usage: "Revision to search (default: most recent)", }, }, Action: func(c *cli.Context) error { if c.NArg() < 1 { return fmt.Errorf("attr path required") } return runPackagesGet(c, c.Args().First()) }, }, }, } } // indexCommand returns the index command (indexes both options and packages). func indexCommand() *cli.Command { return &cli.Command{ Name: "index", Usage: "Index a nixpkgs revision (options and packages)", ArgsUsage: "", Flags: []cli.Flag{ &cli.BoolFlag{ Name: "no-files", Usage: "Skip indexing file contents", }, &cli.BoolFlag{ Name: "no-packages", Usage: "Skip indexing packages (options only)", }, &cli.BoolFlag{ Name: "no-options", Usage: "Skip indexing options (packages only)", }, &cli.BoolFlag{ Name: "force", Aliases: []string{"f"}, Usage: "Force re-indexing even if revision already exists", }, }, Action: func(c *cli.Context) error { if c.NArg() < 1 { return fmt.Errorf("revision argument required") } return runIndex(c, c.Args().First()) }, } } // listCommand returns the list command. func listCommand() *cli.Command { return &cli.Command{ Name: "list", Usage: "List indexed revisions", Action: func(c *cli.Context) error { return runList(c) }, } } // deleteCommand returns the delete command. func deleteCommand() *cli.Command { return &cli.Command{ Name: "delete", Usage: "Delete an indexed revision", ArgsUsage: "", Action: func(c *cli.Context) error { if c.NArg() < 1 { return fmt.Errorf("revision argument required") } return runDelete(c, c.Args().First()) }, } } // serveFlags returns common flags for serve commands. func serveFlags() []cli.Flag { return []cli.Flag{ &cli.StringFlag{ Name: "transport", Aliases: []string{"t"}, Usage: "Transport type: 'stdio' or 'http'", Value: "stdio", }, &cli.StringFlag{ Name: "http-address", Usage: "HTTP listen address", Value: "127.0.0.1:8080", }, &cli.StringFlag{ Name: "http-endpoint", Usage: "HTTP endpoint path", Value: "/mcp", }, &cli.StringSliceFlag{ Name: "allowed-origins", Usage: "Allowed Origin headers for CORS (can be specified multiple times)", }, &cli.StringFlag{ Name: "tls-cert", Usage: "TLS certificate file", }, &cli.StringFlag{ Name: "tls-key", Usage: "TLS key file", }, &cli.DurationFlag{ Name: "session-ttl", Usage: "Session TTL for HTTP transport", Value: 30 * time.Minute, }, } } // openStore opens a database store based on the connection string. func openStore(connStr string) (database.Store, error) { if strings.HasPrefix(connStr, "sqlite://") { path := strings.TrimPrefix(connStr, "sqlite://") return database.NewSQLiteStore(path) } if strings.HasPrefix(connStr, "postgres://") || strings.HasPrefix(connStr, "postgresql://") { return database.NewPostgresStore(connStr) } // Default to SQLite with the connection string as path return database.NewSQLiteStore(connStr) } func runOptionsServe(c *cli.Context) error { ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer cancel() store, err := openStore(c.String("database")) if err != nil { return fmt.Errorf("failed to open database: %w", err) } defer store.Close() //nolint:errcheck // cleanup on exit if err := store.Initialize(ctx); err != nil { return fmt.Errorf("failed to initialize database: %w", err) } logger := log.New(os.Stderr, "[mcp] ", log.LstdFlags) config := mcp.DefaultNixOSConfig() server := mcp.NewServer(store, logger, config) indexer := nixos.NewIndexer(store) pkgIndexer := packages.NewIndexer(store) server.RegisterHandlersWithPackages(indexer, pkgIndexer) transport := c.String("transport") switch transport { case "stdio": logger.Println("Starting NixOS options MCP server on stdio...") return server.Run(ctx, os.Stdin, os.Stdout) case "http": httpConfig := mcp.HTTPConfig{ Address: c.String("http-address"), Endpoint: c.String("http-endpoint"), AllowedOrigins: c.StringSlice("allowed-origins"), SessionTTL: c.Duration("session-ttl"), TLSCertFile: c.String("tls-cert"), TLSKeyFile: c.String("tls-key"), } httpTransport := mcp.NewHTTPTransport(server, httpConfig) return httpTransport.Run(ctx) default: return fmt.Errorf("unknown transport: %s (use 'stdio' or 'http')", transport) } } func runPackagesServe(c *cli.Context) error { ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer cancel() store, err := openStore(c.String("database")) if err != nil { return fmt.Errorf("failed to open database: %w", err) } defer store.Close() //nolint:errcheck // cleanup on exit if err := store.Initialize(ctx); err != nil { return fmt.Errorf("failed to initialize database: %w", err) } logger := log.New(os.Stderr, "[mcp] ", log.LstdFlags) config := mcp.DefaultNixpkgsPackagesConfig() server := mcp.NewServer(store, logger, config) pkgIndexer := packages.NewIndexer(store) server.RegisterPackageHandlers(pkgIndexer) transport := c.String("transport") switch transport { case "stdio": logger.Println("Starting nixpkgs packages MCP server on stdio...") return server.Run(ctx, os.Stdin, os.Stdout) case "http": httpConfig := mcp.HTTPConfig{ Address: c.String("http-address"), Endpoint: c.String("http-endpoint"), AllowedOrigins: c.StringSlice("allowed-origins"), SessionTTL: c.Duration("session-ttl"), TLSCertFile: c.String("tls-cert"), TLSKeyFile: c.String("tls-key"), } httpTransport := mcp.NewHTTPTransport(server, httpConfig) return httpTransport.Run(ctx) default: return fmt.Errorf("unknown transport: %s (use 'stdio' or 'http')", transport) } } func runIndex(c *cli.Context, revision string) error { ctx := context.Background() store, err := openStore(c.String("database")) if err != nil { return fmt.Errorf("failed to open database: %w", err) } defer store.Close() //nolint:errcheck // cleanup on exit if err := store.Initialize(ctx); err != nil { return fmt.Errorf("failed to initialize database: %w", err) } indexFiles := !c.Bool("no-files") indexOptions := !c.Bool("no-options") indexPackages := !c.Bool("no-packages") force := c.Bool("force") optionsIndexer := nixos.NewIndexer(store) pkgIndexer := packages.NewIndexer(store) // Resolve revision ref := optionsIndexer.ResolveRevision(revision) fmt.Printf("Indexing revision: %s\n", revision) var optionCount, packageCount, fileCount int var rev *database.Revision // Index options first (creates the revision record) if indexOptions { var result *nixos.IndexResult if force { result, err = optionsIndexer.ReindexRevision(ctx, revision) } else { result, err = optionsIndexer.IndexRevision(ctx, revision) } if err != nil { return fmt.Errorf("options indexing failed: %w", err) } if result.AlreadyIndexed && !force { fmt.Printf("Revision already indexed (%d options). Use --force to re-index.\n", result.OptionCount) rev = result.Revision } else { optionCount = result.OptionCount rev = result.Revision fmt.Printf("Indexed %d options\n", optionCount) } } else { // If not indexing options, check if revision exists rev, err = store.GetRevision(ctx, ref) if err != nil { return fmt.Errorf("failed to get revision: %w", err) } if rev == nil { // Create revision record without options commitDate, _ := pkgIndexer.GetCommitDate(ctx, ref) rev = &database.Revision{ GitHash: ref, ChannelName: pkgIndexer.GetChannelName(revision), CommitDate: commitDate, } if err := store.CreateRevision(ctx, rev); err != nil { return fmt.Errorf("failed to create revision: %w", err) } } } // Index files if indexFiles && rev != nil { fmt.Println("Indexing files...") fileCount, err = optionsIndexer.IndexFiles(ctx, rev.ID, rev.GitHash) if err != nil { fmt.Printf("Warning: file indexing failed: %v\n", err) } else { fmt.Printf("Indexed %d files\n", fileCount) } } // Index packages if indexPackages && rev != nil { fmt.Println("Indexing packages...") pkgResult, err := pkgIndexer.IndexPackages(ctx, rev.ID, rev.GitHash) if err != nil { fmt.Printf("Warning: package indexing failed: %v\n", err) } else { packageCount = pkgResult.PackageCount fmt.Printf("Indexed %d packages\n", packageCount) } } // Summary fmt.Println() fmt.Printf("Git hash: %s\n", rev.GitHash) if rev.ChannelName != "" { fmt.Printf("Channel: %s\n", rev.ChannelName) } if optionCount > 0 { fmt.Printf("Options: %d\n", optionCount) } if packageCount > 0 { fmt.Printf("Packages: %d\n", packageCount) } if fileCount > 0 { fmt.Printf("Files: %d\n", fileCount) } return nil } func runList(c *cli.Context) error { ctx := context.Background() store, err := openStore(c.String("database")) if err != nil { return fmt.Errorf("failed to open database: %w", err) } defer store.Close() //nolint:errcheck // cleanup on exit if err := store.Initialize(ctx); err != nil { return fmt.Errorf("failed to initialize database: %w", err) } revisions, err := store.ListRevisions(ctx) if err != nil { return fmt.Errorf("failed to list revisions: %w", err) } if len(revisions) == 0 { fmt.Println("No revisions indexed.") fmt.Println("Use 'nixpkgs-search index ' to index a nixpkgs version.") return nil } fmt.Printf("Indexed revisions (%d):\n\n", len(revisions)) for _, rev := range revisions { fmt.Printf(" %s", rev.GitHash[:12]) if rev.ChannelName != "" { fmt.Printf(" (%s)", rev.ChannelName) } fmt.Printf("\n Options: %d, Packages: %d, Indexed: %s\n", rev.OptionCount, rev.PackageCount, rev.IndexedAt.Format("2006-01-02 15:04")) } return nil } func runDelete(c *cli.Context, revision string) error { ctx := context.Background() store, err := openStore(c.String("database")) if err != nil { return fmt.Errorf("failed to open database: %w", err) } defer store.Close() //nolint:errcheck // cleanup on exit if err := store.Initialize(ctx); err != nil { return fmt.Errorf("failed to initialize database: %w", err) } // Find revision rev, err := store.GetRevision(ctx, revision) if err != nil { return fmt.Errorf("failed to get revision: %w", err) } if rev == nil { rev, err = store.GetRevisionByChannel(ctx, revision) if err != nil { return fmt.Errorf("failed to get revision: %w", err) } } if rev == nil { return fmt.Errorf("revision '%s' not found", revision) } if err := store.DeleteRevision(ctx, rev.ID); err != nil { return fmt.Errorf("failed to delete revision: %w", err) } fmt.Printf("Deleted revision %s\n", rev.GitHash) return nil } // Options search and get functions func runOptionsSearch(c *cli.Context, query string) error { ctx := context.Background() store, err := openStore(c.String("database")) if err != nil { return fmt.Errorf("failed to open database: %w", err) } defer store.Close() //nolint:errcheck // cleanup on exit if err := store.Initialize(ctx); err != nil { return fmt.Errorf("failed to initialize database: %w", err) } rev, err := resolveRevision(ctx, store, c.String("revision")) if err != nil { return err } if rev == nil { return fmt.Errorf("no indexed revision found") } filters := database.SearchFilters{ Limit: c.Int("limit"), } options, err := store.SearchOptions(ctx, rev.ID, query, filters) if err != nil { return fmt.Errorf("search failed: %w", err) } if len(options) == 0 { fmt.Printf("No options found matching '%s'\n", query) return nil } fmt.Printf("Found %d options matching '%s':\n\n", len(options), query) for _, opt := range options { fmt.Printf(" %s\n", opt.Name) fmt.Printf(" Type: %s\n", opt.Type) if opt.Description != "" { desc := opt.Description if len(desc) > 100 { desc = desc[:100] + "..." } fmt.Printf(" %s\n", desc) } fmt.Println() } return nil } func runOptionsGet(c *cli.Context, name string) error { ctx := context.Background() store, err := openStore(c.String("database")) if err != nil { return fmt.Errorf("failed to open database: %w", err) } defer store.Close() //nolint:errcheck // cleanup on exit if err := store.Initialize(ctx); err != nil { return fmt.Errorf("failed to initialize database: %w", err) } rev, err := resolveRevision(ctx, store, c.String("revision")) if err != nil { return err } if rev == nil { return fmt.Errorf("no indexed revision found") } opt, err := store.GetOption(ctx, rev.ID, name) if err != nil { return fmt.Errorf("failed to get option: %w", err) } if opt == nil { return fmt.Errorf("option '%s' not found", name) } fmt.Printf("%s\n", opt.Name) fmt.Printf(" Type: %s\n", opt.Type) if opt.Description != "" { fmt.Printf(" Description: %s\n", opt.Description) } if opt.DefaultValue != "" && opt.DefaultValue != "null" { fmt.Printf(" Default: %s\n", opt.DefaultValue) } if opt.Example != "" && opt.Example != "null" { fmt.Printf(" Example: %s\n", opt.Example) } if opt.ReadOnly { fmt.Println(" Read-only: yes") } // Get declarations declarations, err := store.GetDeclarations(ctx, opt.ID) if err == nil && len(declarations) > 0 { fmt.Println(" Declared in:") for _, decl := range declarations { if decl.Line > 0 { fmt.Printf(" - %s:%d\n", decl.FilePath, decl.Line) } else { fmt.Printf(" - %s\n", decl.FilePath) } } } // Get children children, err := store.GetChildren(ctx, rev.ID, opt.Name) if err == nil && len(children) > 0 { fmt.Println(" Sub-options:") for _, child := range children { shortName := child.Name if strings.HasPrefix(child.Name, opt.Name+".") { shortName = child.Name[len(opt.Name)+1:] } fmt.Printf(" - %s (%s)\n", shortName, child.Type) } } return nil } // Packages search and get functions func runPackagesSearch(c *cli.Context, query string) error { ctx := context.Background() store, err := openStore(c.String("database")) if err != nil { return fmt.Errorf("failed to open database: %w", err) } defer store.Close() //nolint:errcheck // cleanup on exit if err := store.Initialize(ctx); err != nil { return fmt.Errorf("failed to initialize database: %w", err) } rev, err := resolveRevision(ctx, store, c.String("revision")) if err != nil { return err } if rev == nil { return fmt.Errorf("no indexed revision found") } filters := database.PackageSearchFilters{ Limit: c.Int("limit"), } if c.IsSet("broken") { broken := c.Bool("broken") filters.Broken = &broken } if c.IsSet("unfree") { unfree := c.Bool("unfree") filters.Unfree = &unfree } pkgs, err := store.SearchPackages(ctx, rev.ID, query, filters) if err != nil { return fmt.Errorf("search failed: %w", err) } if len(pkgs) == 0 { fmt.Printf("No packages found matching '%s'\n", query) return nil } fmt.Printf("Found %d packages matching '%s':\n\n", len(pkgs), query) for _, pkg := range pkgs { fmt.Printf(" %s\n", pkg.AttrPath) fmt.Printf(" Name: %s", pkg.Pname) if pkg.Version != "" { fmt.Printf(" %s", pkg.Version) } fmt.Println() if pkg.Description != "" { desc := pkg.Description if len(desc) > 100 { desc = desc[:100] + "..." } fmt.Printf(" %s\n", desc) } if pkg.Broken || pkg.Unfree || pkg.Insecure { var flags []string if pkg.Broken { flags = append(flags, "broken") } if pkg.Unfree { flags = append(flags, "unfree") } if pkg.Insecure { flags = append(flags, "insecure") } fmt.Printf(" Flags: %s\n", strings.Join(flags, ", ")) } fmt.Println() } return nil } func runPackagesGet(c *cli.Context, attrPath string) error { ctx := context.Background() store, err := openStore(c.String("database")) if err != nil { return fmt.Errorf("failed to open database: %w", err) } defer store.Close() //nolint:errcheck // cleanup on exit if err := store.Initialize(ctx); err != nil { return fmt.Errorf("failed to initialize database: %w", err) } rev, err := resolveRevision(ctx, store, c.String("revision")) if err != nil { return err } if rev == nil { return fmt.Errorf("no indexed revision found") } pkg, err := store.GetPackage(ctx, rev.ID, attrPath) if err != nil { return fmt.Errorf("failed to get package: %w", err) } if pkg == nil { return fmt.Errorf("package '%s' not found", attrPath) } fmt.Printf("%s\n", pkg.AttrPath) fmt.Printf(" Name: %s\n", pkg.Pname) if pkg.Version != "" { fmt.Printf(" Version: %s\n", pkg.Version) } if pkg.Description != "" { fmt.Printf(" Description: %s\n", pkg.Description) } if pkg.Homepage != "" { fmt.Printf(" Homepage: %s\n", pkg.Homepage) } if pkg.License != "" && pkg.License != "[]" { fmt.Printf(" License: %s\n", pkg.License) } if pkg.Maintainers != "" && pkg.Maintainers != "[]" { fmt.Printf(" Maintainers: %s\n", pkg.Maintainers) } // Status flags if pkg.Broken || pkg.Unfree || pkg.Insecure { fmt.Println(" Status:") if pkg.Broken { fmt.Println(" - broken") } if pkg.Unfree { fmt.Println(" - unfree") } if pkg.Insecure { fmt.Println(" - insecure") } } return nil } // resolveRevision finds a revision by hash or channel, or returns the most recent. func resolveRevision(ctx context.Context, store database.Store, revisionArg string) (*database.Revision, error) { if revisionArg != "" { rev, err := store.GetRevision(ctx, revisionArg) if err != nil { return nil, fmt.Errorf("failed to get revision: %w", err) } if rev != nil { return rev, nil } rev, err = store.GetRevisionByChannel(ctx, revisionArg) if err != nil { return nil, fmt.Errorf("failed to get revision: %w", err) } return rev, nil } // Return most recent revisions, err := store.ListRevisions(ctx) if err != nil { return nil, fmt.Errorf("failed to list revisions: %w", err) } if len(revisions) > 0 { return revisions[0], nil } return nil, nil }