From f7112d445951c13127bc3309b17e2c2c0f9e6344 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torjus=20H=C3=A5kestad?= Date: Tue, 3 Feb 2026 17:36:05 +0100 Subject: [PATCH] feat: CLI integration with database and MCP server - Wire up all CLI commands to database operations - Add 'get' command for single option details - Add '--files' flag to 'index' for file content indexing - Support postgres:// and sqlite:// connection strings - Default to SQLite database file Co-Authored-By: Claude Opus 4.5 --- cmd/nixos-options/main.go | 410 ++++++++++++++++++++++++++++++++++---- 1 file changed, 374 insertions(+), 36 deletions(-) diff --git a/cmd/nixos-options/main.go b/cmd/nixos-options/main.go index 023d2ef..d36fb1b 100644 --- a/cmd/nixos-options/main.go +++ b/cmd/nixos-options/main.go @@ -1,32 +1,40 @@ package main import ( + "context" "fmt" "log" "os" + "strings" "github.com/urfave/cli/v2" + + "git.t-juice.club/torjus/labmcp/internal/database" + "git.t-juice.club/torjus/labmcp/internal/mcp" + "git.t-juice.club/torjus/labmcp/internal/nixos" ) +const defaultDatabase = "sqlite://nixos-options.db" + func main() { app := &cli.App{ Name: "nixos-options", Usage: "MCP server for NixOS options search and query", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "database", + Aliases: []string{"d"}, + Usage: "Database connection string (postgres://... or sqlite://...)", + EnvVars: []string{"NIXOS_OPTIONS_DATABASE"}, + Value: defaultDatabase, + }, + }, Commands: []*cli.Command{ { Name: "serve", Usage: "Run MCP server (stdio)", - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "database", - Aliases: []string{"d"}, - Usage: "Database connection string (postgres://... or sqlite://...)", - EnvVars: []string{"NIXOS_OPTIONS_DATABASE"}, - }, - }, Action: func(c *cli.Context) error { - fmt.Println("MCP server not yet implemented") - return nil + return runServe(c) }, }, { @@ -34,35 +42,23 @@ func main() { Usage: "Index a nixpkgs revision", ArgsUsage: "", Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "database", - Aliases: []string{"d"}, - Usage: "Database connection string", - EnvVars: []string{"NIXOS_OPTIONS_DATABASE"}, + &cli.BoolFlag{ + Name: "files", + Usage: "Also index file contents (slower, enables get_file tool)", }, }, Action: func(c *cli.Context) error { if c.NArg() < 1 { return fmt.Errorf("revision argument required") } - fmt.Printf("Indexing revision: %s\n", c.Args().First()) - return nil + return runIndex(c, c.Args().First(), c.Bool("files")) }, }, { Name: "list", Usage: "List indexed revisions", - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "database", - Aliases: []string{"d"}, - Usage: "Database connection string", - EnvVars: []string{"NIXOS_OPTIONS_DATABASE"}, - }, - }, Action: func(c *cli.Context) error { - fmt.Println("List revisions not yet implemented") - return nil + return runList(c) }, }, { @@ -70,24 +66,52 @@ func main() { Usage: "Search for options", ArgsUsage: "", Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "database", - Aliases: []string{"d"}, - Usage: "Database connection string", - EnvVars: []string{"NIXOS_OPTIONS_DATABASE"}, - }, &cli.StringFlag{ Name: "revision", Aliases: []string{"r"}, - Usage: "Revision to search (default: nixos-stable)", + 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") } - fmt.Printf("Searching for: %s\n", c.Args().First()) - return nil + return runSearch(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 runGet(c, c.Args().First()) + }, + }, + { + 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()) }, }, }, @@ -97,3 +121,317 @@ func main() { log.Fatal(err) } } + +// 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 runServe(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() + + if err := store.Initialize(ctx); err != nil { + return fmt.Errorf("failed to initialize database: %w", err) + } + + logger := log.New(os.Stderr, "[mcp] ", log.LstdFlags) + server := mcp.NewServer(store, logger) + + indexer := nixos.NewIndexer(store) + server.RegisterHandlers(indexer) + + logger.Println("Starting MCP server on stdio...") + return server.Run(ctx, os.Stdin, os.Stdout) +} + +func runIndex(c *cli.Context, revision string, indexFiles bool) 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() + + if err := store.Initialize(ctx); err != nil { + return fmt.Errorf("failed to initialize database: %w", err) + } + + indexer := nixos.NewIndexer(store) + + fmt.Printf("Indexing revision: %s\n", revision) + result, err := indexer.IndexRevision(ctx, revision) + if err != nil { + return fmt.Errorf("indexing failed: %w", err) + } + + fmt.Printf("Indexed %d options in %s\n", result.OptionCount, result.Duration) + fmt.Printf("Git hash: %s\n", result.Revision.GitHash) + if result.Revision.ChannelName != "" { + fmt.Printf("Channel: %s\n", result.Revision.ChannelName) + } + + if indexFiles { + fmt.Println("Indexing files...") + fileCount, err := indexer.IndexFiles(ctx, result.Revision.ID, result.Revision.GitHash) + if err != nil { + return fmt.Errorf("file indexing failed: %w", err) + } + fmt.Printf("Indexed %d files\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() + + 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 'nixos-options 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, Indexed: %s\n", + rev.OptionCount, rev.IndexedAt.Format("2006-01-02 15:04")) + } + + return nil +} + +func runSearch(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() + + if err := store.Initialize(ctx); err != nil { + return fmt.Errorf("failed to initialize database: %w", err) + } + + // Find revision + var rev *database.Revision + revisionArg := c.String("revision") + if revisionArg != "" { + rev, err = store.GetRevision(ctx, revisionArg) + if err != nil { + return fmt.Errorf("failed to get revision: %w", err) + } + if rev == nil { + rev, err = store.GetRevisionByChannel(ctx, revisionArg) + if err != nil { + return fmt.Errorf("failed to get revision: %w", err) + } + } + } else { + revisions, err := store.ListRevisions(ctx) + if err != nil { + return fmt.Errorf("failed to list revisions: %w", err) + } + if len(revisions) > 0 { + rev = revisions[0] + } + } + + 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 runGet(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() + + if err := store.Initialize(ctx); err != nil { + return fmt.Errorf("failed to initialize database: %w", err) + } + + // Find revision + var rev *database.Revision + revisionArg := c.String("revision") + if revisionArg != "" { + rev, err = store.GetRevision(ctx, revisionArg) + if err != nil { + return fmt.Errorf("failed to get revision: %w", err) + } + if rev == nil { + rev, err = store.GetRevisionByChannel(ctx, revisionArg) + if err != nil { + return fmt.Errorf("failed to get revision: %w", err) + } + } + } else { + revisions, err := store.ListRevisions(ctx) + if err != nil { + return fmt.Errorf("failed to list revisions: %w", err) + } + if len(revisions) > 0 { + rev = revisions[0] + } + } + + 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 +} + +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() + + 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 +}