feat: project structure and nix build setup

- Add CLI entry point with urfave/cli/v2 (serve, index, list, search commands)
- Add database interface and implementations for PostgreSQL and SQLite
- Add schema versioning with automatic recreation on version mismatch
- Add MCP protocol types and server scaffold
- Add NixOS option types
- Configure flake.nix with devShell and buildGoModule package

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-03 17:30:11 +01:00
parent 740b846f0c
commit 6326b3a3c1
11 changed files with 1921 additions and 6 deletions

99
cmd/nixos-options/main.go Normal file
View File

@@ -0,0 +1,99 @@
package main
import (
"fmt"
"log"
"os"
"github.com/urfave/cli/v2"
)
func main() {
app := &cli.App{
Name: "nixos-options",
Usage: "MCP server for NixOS options search and query",
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
},
},
{
Name: "index",
Usage: "Index a nixpkgs revision",
ArgsUsage: "<revision>",
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 {
if c.NArg() < 1 {
return fmt.Errorf("revision argument required")
}
fmt.Printf("Indexing revision: %s\n", c.Args().First())
return nil
},
},
{
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
},
},
{
Name: "search",
Usage: "Search for options",
ArgsUsage: "<query>",
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)",
},
},
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
},
},
},
}
if err := app.Run(os.Args); err != nil {
log.Fatal(err)
}
}

View File

@@ -1,15 +1,64 @@
{ {
description = "A very basic flake"; description = "LabMCP - Collection of MCP servers";
inputs = { inputs = {
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
}; };
outputs = { self, nixpkgs }: { outputs = { self, nixpkgs }:
let
supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
pkgsFor = system: nixpkgs.legacyPackages.${system};
in
{
packages = forAllSystems (system:
let
pkgs = pkgsFor system;
in
{
nixos-options = pkgs.buildGoModule {
pname = "nixos-options-mcp";
version = "0.1.0";
src = ./.;
packages.x86_64-linux.hello = nixpkgs.legacyPackages.x86_64-linux.hello; vendorHash = null; # Will be set after first build
packages.x86_64-linux.default = self.packages.x86_64-linux.hello; subPackages = [ "cmd/nixos-options" ];
meta = with pkgs.lib; {
description = "MCP server for NixOS options search and query";
homepage = "https://git.t-juice.club/torjus/labmcp";
license = licenses.mit;
maintainers = [ ];
mainProgram = "nixos-options";
};
};
default = self.packages.${system}.nixos-options;
});
devShells = forAllSystems (system:
let
pkgs = pkgsFor system;
in
{
default = pkgs.mkShell {
buildInputs = with pkgs; [
go_1_24
gopls
gotools
go-tools
golangci-lint
postgresql
sqlite
];
shellHook = ''
echo "LabMCP development shell"
echo "Go version: $(go version)"
'';
};
});
}; };
} }

27
go.mod
View File

@@ -1,3 +1,28 @@
module git.t-juice.club/torjus/labmcp module git.t-juice.club/torjus/labmcp
go 1.25.5 go 1.24
require (
github.com/lib/pq v1.10.9
github.com/urfave/cli/v2 v2.27.5
modernc.org/sqlite v1.34.4
)
require (
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
golang.org/x/sys v0.22.0 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
modernc.org/libc v1.55.3 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
)

59
go.sum Normal file
View File

@@ -0,0 +1,59 @@
github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w=
github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.34.4 h1:sjdARozcL5KJBvYQvLlZEmctRgW9xqIZc2ncN7PU0P8=
modernc.org/sqlite v1.34.4/go.mod h1:3QQFCG2SEMtc2nv+Wq4cQCH7Hjcg+p/RMlS1XK+zwbk=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View File

@@ -0,0 +1,88 @@
// Package database provides database abstraction for storing NixOS options.
package database
import (
"context"
"time"
)
// Revision represents an indexed nixpkgs revision.
type Revision struct {
ID int64
GitHash string
ChannelName string
CommitDate time.Time
IndexedAt time.Time
OptionCount int
}
// Option represents a NixOS configuration option.
type Option struct {
ID int64
RevisionID int64
Name string
ParentPath string
Type string
DefaultValue string // JSON text
Example string // JSON text
Description string
ReadOnly bool
}
// Declaration represents a file where an option is declared.
type Declaration struct {
ID int64
OptionID int64
FilePath string
Line int
}
// File represents a cached file from nixpkgs.
type File struct {
ID int64
RevisionID int64
FilePath string
Extension string
Content string
}
// SearchFilters contains optional filters for option search.
type SearchFilters struct {
Type string
Namespace string
HasDefault *bool
Limit int
Offset int
}
// Store defines the interface for database operations.
type Store interface {
// Schema operations
Initialize(ctx context.Context) error
Close() error
// Revision operations
CreateRevision(ctx context.Context, rev *Revision) error
GetRevision(ctx context.Context, gitHash string) (*Revision, error)
GetRevisionByChannel(ctx context.Context, channel string) (*Revision, error)
ListRevisions(ctx context.Context) ([]*Revision, error)
DeleteRevision(ctx context.Context, id int64) error
UpdateRevisionOptionCount(ctx context.Context, id int64, count int) error
// Option operations
CreateOption(ctx context.Context, opt *Option) error
CreateOptionsBatch(ctx context.Context, opts []*Option) error
GetOption(ctx context.Context, revisionID int64, name string) (*Option, error)
GetChildren(ctx context.Context, revisionID int64, parentPath string) ([]*Option, error)
SearchOptions(ctx context.Context, revisionID int64, query string, filters SearchFilters) ([]*Option, error)
// Declaration operations
CreateDeclaration(ctx context.Context, decl *Declaration) error
CreateDeclarationsBatch(ctx context.Context, decls []*Declaration) error
GetDeclarations(ctx context.Context, optionID int64) ([]*Declaration, error)
// File operations
CreateFile(ctx context.Context, file *File) error
CreateFilesBatch(ctx context.Context, files []*File) error
GetFile(ctx context.Context, revisionID int64, path string) (*File, error)
}

View File

@@ -0,0 +1,473 @@
package database
import (
"context"
"database/sql"
"fmt"
_ "github.com/lib/pq"
)
// PostgresStore implements Store using PostgreSQL.
type PostgresStore struct {
db *sql.DB
}
// NewPostgresStore creates a new PostgreSQL store.
func NewPostgresStore(connStr string) (*PostgresStore, error) {
db, err := sql.Open("postgres", connStr)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
if err := db.Ping(); err != nil {
db.Close()
return nil, fmt.Errorf("failed to ping database: %w", err)
}
return &PostgresStore{db: db}, nil
}
// Initialize creates or migrates the database schema.
func (s *PostgresStore) Initialize(ctx context.Context) error {
// Check current schema version
var version int
err := s.db.QueryRowContext(ctx,
"SELECT version FROM schema_info LIMIT 1").Scan(&version)
needsRecreate := err != nil || version != SchemaVersion
if needsRecreate {
// Drop all tables in correct order (respecting foreign keys)
dropStmts := []string{
DropDeclarations,
DropOptions,
DropFiles,
DropRevisions,
DropSchemaInfo,
}
for _, stmt := range dropStmts {
if _, err := s.db.ExecContext(ctx, stmt); err != nil {
return fmt.Errorf("failed to drop table: %w", err)
}
}
}
// Create tables
createStmts := []string{
SchemaInfoTable,
// PostgreSQL uses SERIAL for auto-increment
`CREATE TABLE IF NOT EXISTS revisions (
id SERIAL PRIMARY KEY,
git_hash TEXT NOT NULL UNIQUE,
channel_name TEXT,
commit_date TIMESTAMP,
indexed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
option_count INTEGER NOT NULL DEFAULT 0
)`,
`CREATE TABLE IF NOT EXISTS options (
id SERIAL PRIMARY KEY,
revision_id INTEGER NOT NULL REFERENCES revisions(id) ON DELETE CASCADE,
name TEXT NOT NULL,
parent_path TEXT NOT NULL,
type TEXT,
default_value TEXT,
example TEXT,
description TEXT,
read_only BOOLEAN NOT NULL DEFAULT FALSE
)`,
`CREATE TABLE IF NOT EXISTS declarations (
id SERIAL PRIMARY KEY,
option_id INTEGER NOT NULL REFERENCES options(id) ON DELETE CASCADE,
file_path TEXT NOT NULL,
line INTEGER
)`,
`CREATE TABLE IF NOT EXISTS files (
id SERIAL PRIMARY KEY,
revision_id INTEGER NOT NULL REFERENCES revisions(id) ON DELETE CASCADE,
file_path TEXT NOT NULL,
extension TEXT,
content TEXT NOT NULL
)`,
IndexOptionsRevisionName,
IndexOptionsRevisionParent,
IndexFilesRevisionPath,
IndexDeclarationsOption,
}
for _, stmt := range createStmts {
if _, err := s.db.ExecContext(ctx, stmt); err != nil {
return fmt.Errorf("failed to create schema: %w", err)
}
}
// Create full-text search index for PostgreSQL
_, err = s.db.ExecContext(ctx, `
CREATE INDEX IF NOT EXISTS idx_options_fts
ON options USING GIN(to_tsvector('english', name || ' ' || COALESCE(description, '')))
`)
if err != nil {
return fmt.Errorf("failed to create FTS index: %w", err)
}
// Set schema version
if needsRecreate {
_, err = s.db.ExecContext(ctx,
"INSERT INTO schema_info (version) VALUES ($1)", SchemaVersion)
if err != nil {
return fmt.Errorf("failed to set schema version: %w", err)
}
}
return nil
}
// Close closes the database connection.
func (s *PostgresStore) Close() error {
return s.db.Close()
}
// CreateRevision creates a new revision record.
func (s *PostgresStore) CreateRevision(ctx context.Context, rev *Revision) error {
err := s.db.QueryRowContext(ctx, `
INSERT INTO revisions (git_hash, channel_name, commit_date, option_count)
VALUES ($1, $2, $3, $4)
RETURNING id, indexed_at`,
rev.GitHash, rev.ChannelName, rev.CommitDate, rev.OptionCount,
).Scan(&rev.ID, &rev.IndexedAt)
if err != nil {
return fmt.Errorf("failed to create revision: %w", err)
}
return nil
}
// GetRevision retrieves a revision by git hash.
func (s *PostgresStore) 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
FROM revisions WHERE git_hash = $1`, gitHash,
).Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get revision: %w", err)
}
return rev, nil
}
// GetRevisionByChannel retrieves a revision by channel name.
func (s *PostgresStore) 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
FROM revisions WHERE channel_name = $1
ORDER BY indexed_at DESC LIMIT 1`, channel,
).Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get revision by channel: %w", err)
}
return rev, nil
}
// ListRevisions returns all indexed revisions.
func (s *PostgresStore) ListRevisions(ctx context.Context) ([]*Revision, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, git_hash, channel_name, commit_date, indexed_at, option_count
FROM revisions ORDER BY indexed_at DESC`)
if err != nil {
return nil, fmt.Errorf("failed to list revisions: %w", err)
}
defer rows.Close()
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 {
return nil, fmt.Errorf("failed to scan revision: %w", err)
}
revisions = append(revisions, rev)
}
return revisions, rows.Err()
}
// DeleteRevision removes a revision and all associated data.
func (s *PostgresStore) DeleteRevision(ctx context.Context, id int64) error {
_, err := s.db.ExecContext(ctx, "DELETE FROM revisions WHERE id = $1", id)
if err != nil {
return fmt.Errorf("failed to delete revision: %w", err)
}
return nil
}
// UpdateRevisionOptionCount updates the option count for a revision.
func (s *PostgresStore) UpdateRevisionOptionCount(ctx context.Context, id int64, count int) error {
_, err := s.db.ExecContext(ctx,
"UPDATE revisions SET option_count = $1 WHERE id = $2", count, id)
if err != nil {
return fmt.Errorf("failed to update option count: %w", err)
}
return nil
}
// CreateOption creates a new option record.
func (s *PostgresStore) CreateOption(ctx context.Context, opt *Option) error {
err := s.db.QueryRowContext(ctx, `
INSERT INTO options (revision_id, name, parent_path, type, default_value, example, description, read_only)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id`,
opt.RevisionID, opt.Name, opt.ParentPath, opt.Type, opt.DefaultValue, opt.Example, opt.Description, opt.ReadOnly,
).Scan(&opt.ID)
if err != nil {
return fmt.Errorf("failed to create option: %w", err)
}
return nil
}
// CreateOptionsBatch creates multiple options in a batch.
func (s *PostgresStore) CreateOptionsBatch(ctx context.Context, opts []*Option) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback()
stmt, err := tx.PrepareContext(ctx, `
INSERT INTO options (revision_id, name, parent_path, type, default_value, example, description, read_only)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id`)
if err != nil {
return fmt.Errorf("failed to prepare statement: %w", err)
}
defer stmt.Close()
for _, opt := range opts {
err := stmt.QueryRowContext(ctx,
opt.RevisionID, opt.Name, opt.ParentPath, opt.Type, opt.DefaultValue, opt.Example, opt.Description, opt.ReadOnly,
).Scan(&opt.ID)
if err != nil {
return fmt.Errorf("failed to insert option %s: %w", opt.Name, err)
}
}
return tx.Commit()
}
// GetOption retrieves an option by revision and name.
func (s *PostgresStore) GetOption(ctx context.Context, revisionID int64, name string) (*Option, error) {
opt := &Option{}
err := s.db.QueryRowContext(ctx, `
SELECT id, revision_id, name, parent_path, type, default_value, example, description, read_only
FROM options WHERE revision_id = $1 AND name = $2`, revisionID, name,
).Scan(&opt.ID, &opt.RevisionID, &opt.Name, &opt.ParentPath, &opt.Type, &opt.DefaultValue, &opt.Example, &opt.Description, &opt.ReadOnly)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get option: %w", err)
}
return opt, nil
}
// GetChildren retrieves direct children of an option.
func (s *PostgresStore) GetChildren(ctx context.Context, revisionID int64, parentPath string) ([]*Option, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, revision_id, name, parent_path, type, default_value, example, description, read_only
FROM options WHERE revision_id = $1 AND parent_path = $2
ORDER BY name`, revisionID, parentPath)
if err != nil {
return nil, fmt.Errorf("failed to get children: %w", err)
}
defer rows.Close()
var options []*Option
for rows.Next() {
opt := &Option{}
if err := rows.Scan(&opt.ID, &opt.RevisionID, &opt.Name, &opt.ParentPath, &opt.Type, &opt.DefaultValue, &opt.Example, &opt.Description, &opt.ReadOnly); err != nil {
return nil, fmt.Errorf("failed to scan option: %w", err)
}
options = append(options, opt)
}
return options, rows.Err()
}
// SearchOptions searches for options matching a query.
func (s *PostgresStore) SearchOptions(ctx context.Context, revisionID int64, query string, filters SearchFilters) ([]*Option, error) {
// Use PostgreSQL full-text search
baseQuery := `
SELECT id, revision_id, name, parent_path, type, default_value, example, description, read_only
FROM options
WHERE revision_id = $1
AND to_tsvector('english', name || ' ' || COALESCE(description, '')) @@ plainto_tsquery('english', $2)`
args := []interface{}{revisionID, query}
argNum := 3
if filters.Type != "" {
baseQuery += fmt.Sprintf(" AND type = $%d", argNum)
args = append(args, filters.Type)
argNum++
}
if filters.Namespace != "" {
baseQuery += fmt.Sprintf(" AND name LIKE $%d", argNum)
args = append(args, filters.Namespace+"%")
argNum++
}
if filters.HasDefault != nil {
if *filters.HasDefault {
baseQuery += " AND default_value IS NOT NULL"
} else {
baseQuery += " AND default_value IS NULL"
}
}
baseQuery += " ORDER BY name"
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 options: %w", err)
}
defer rows.Close()
var options []*Option
for rows.Next() {
opt := &Option{}
if err := rows.Scan(&opt.ID, &opt.RevisionID, &opt.Name, &opt.ParentPath, &opt.Type, &opt.DefaultValue, &opt.Example, &opt.Description, &opt.ReadOnly); err != nil {
return nil, fmt.Errorf("failed to scan option: %w", err)
}
options = append(options, opt)
}
return options, rows.Err()
}
// CreateDeclaration creates a new declaration record.
func (s *PostgresStore) CreateDeclaration(ctx context.Context, decl *Declaration) error {
err := s.db.QueryRowContext(ctx, `
INSERT INTO declarations (option_id, file_path, line)
VALUES ($1, $2, $3)
RETURNING id`,
decl.OptionID, decl.FilePath, decl.Line,
).Scan(&decl.ID)
if err != nil {
return fmt.Errorf("failed to create declaration: %w", err)
}
return nil
}
// CreateDeclarationsBatch creates multiple declarations in a batch.
func (s *PostgresStore) CreateDeclarationsBatch(ctx context.Context, decls []*Declaration) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback()
stmt, err := tx.PrepareContext(ctx, `
INSERT INTO declarations (option_id, file_path, line)
VALUES ($1, $2, $3)
RETURNING id`)
if err != nil {
return fmt.Errorf("failed to prepare statement: %w", err)
}
defer stmt.Close()
for _, decl := range decls {
err := stmt.QueryRowContext(ctx, decl.OptionID, decl.FilePath, decl.Line).Scan(&decl.ID)
if err != nil {
return fmt.Errorf("failed to insert declaration: %w", err)
}
}
return tx.Commit()
}
// GetDeclarations retrieves declarations for an option.
func (s *PostgresStore) GetDeclarations(ctx context.Context, optionID int64) ([]*Declaration, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, option_id, file_path, line
FROM declarations WHERE option_id = $1`, optionID)
if err != nil {
return nil, fmt.Errorf("failed to get declarations: %w", err)
}
defer rows.Close()
var decls []*Declaration
for rows.Next() {
decl := &Declaration{}
if err := rows.Scan(&decl.ID, &decl.OptionID, &decl.FilePath, &decl.Line); err != nil {
return nil, fmt.Errorf("failed to scan declaration: %w", err)
}
decls = append(decls, decl)
}
return decls, rows.Err()
}
// CreateFile creates a new file record.
func (s *PostgresStore) CreateFile(ctx context.Context, file *File) error {
err := s.db.QueryRowContext(ctx, `
INSERT INTO files (revision_id, file_path, extension, content)
VALUES ($1, $2, $3, $4)
RETURNING id`,
file.RevisionID, file.FilePath, file.Extension, file.Content,
).Scan(&file.ID)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
return nil
}
// CreateFilesBatch creates multiple files in a batch.
func (s *PostgresStore) CreateFilesBatch(ctx context.Context, files []*File) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback()
stmt, err := tx.PrepareContext(ctx, `
INSERT INTO files (revision_id, file_path, extension, content)
VALUES ($1, $2, $3, $4)
RETURNING id`)
if err != nil {
return fmt.Errorf("failed to prepare statement: %w", err)
}
defer stmt.Close()
for _, file := range files {
err := stmt.QueryRowContext(ctx, file.RevisionID, file.FilePath, file.Extension, file.Content).Scan(&file.ID)
if err != nil {
return fmt.Errorf("failed to insert file: %w", err)
}
}
return tx.Commit()
}
// GetFile retrieves a file by revision and path.
func (s *PostgresStore) GetFile(ctx context.Context, revisionID int64, path string) (*File, error) {
file := &File{}
err := s.db.QueryRowContext(ctx, `
SELECT id, revision_id, file_path, extension, content
FROM files WHERE revision_id = $1 AND file_path = $2`, revisionID, path,
).Scan(&file.ID, &file.RevisionID, &file.FilePath, &file.Extension, &file.Content)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get file: %w", err)
}
return file, nil
}

102
internal/database/schema.go Normal file
View File

@@ -0,0 +1,102 @@
package database
// SchemaVersion is the current database schema version.
// When this changes, the database will be dropped and recreated.
const SchemaVersion = 1
// Common SQL statements shared between implementations.
const (
// SchemaInfoTable creates the schema version tracking table.
SchemaInfoTable = `
CREATE TABLE IF NOT EXISTS schema_info (
version INTEGER NOT NULL
)`
// RevisionsTable creates the revisions table.
RevisionsTable = `
CREATE TABLE IF NOT EXISTS revisions (
id INTEGER PRIMARY KEY,
git_hash TEXT NOT NULL UNIQUE,
channel_name TEXT,
commit_date TIMESTAMP,
indexed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
option_count INTEGER NOT NULL DEFAULT 0
)`
// OptionsTable creates the options table.
OptionsTable = `
CREATE TABLE IF NOT EXISTS options (
id INTEGER PRIMARY KEY,
revision_id INTEGER NOT NULL REFERENCES revisions(id) ON DELETE CASCADE,
name TEXT NOT NULL,
parent_path TEXT NOT NULL,
type TEXT,
default_value TEXT,
example TEXT,
description TEXT,
read_only BOOLEAN NOT NULL DEFAULT FALSE
)`
// DeclarationsTable creates the declarations table.
DeclarationsTable = `
CREATE TABLE IF NOT EXISTS declarations (
id INTEGER PRIMARY KEY,
option_id INTEGER NOT NULL REFERENCES options(id) ON DELETE CASCADE,
file_path TEXT NOT NULL,
line INTEGER
)`
// FilesTable creates the files table.
FilesTable = `
CREATE TABLE IF NOT EXISTS files (
id INTEGER PRIMARY KEY,
revision_id INTEGER NOT NULL REFERENCES revisions(id) ON DELETE CASCADE,
file_path TEXT NOT NULL,
extension TEXT,
content TEXT NOT NULL
)`
)
// Index creation statements.
const (
// IndexOptionsRevisionName creates an index on options(revision_id, name).
IndexOptionsRevisionName = `
CREATE INDEX IF NOT EXISTS idx_options_revision_name
ON options(revision_id, name)`
// IndexOptionsRevisionParent creates an index on options(revision_id, parent_path).
IndexOptionsRevisionParent = `
CREATE INDEX IF NOT EXISTS idx_options_revision_parent
ON options(revision_id, parent_path)`
// IndexFilesRevisionPath creates an index on files(revision_id, file_path).
IndexFilesRevisionPath = `
CREATE UNIQUE INDEX IF NOT EXISTS idx_files_revision_path
ON files(revision_id, file_path)`
// IndexDeclarationsOption creates an index on declarations(option_id).
IndexDeclarationsOption = `
CREATE INDEX IF NOT EXISTS idx_declarations_option
ON declarations(option_id)`
)
// Drop statements for schema recreation.
const (
DropSchemaInfo = `DROP TABLE IF EXISTS schema_info`
DropDeclarations = `DROP TABLE IF EXISTS declarations`
DropOptions = `DROP TABLE IF EXISTS options`
DropFiles = `DROP TABLE IF EXISTS files`
DropRevisions = `DROP TABLE IF EXISTS revisions`
)
// ParentPath extracts the parent path from an option name.
// For example, "services.nginx.enable" returns "services.nginx".
// Top-level options return an empty string.
func ParentPath(name string) string {
for i := len(name) - 1; i >= 0; i-- {
if name[i] == '.' {
return name[:i]
}
}
return ""
}

507
internal/database/sqlite.go Normal file
View File

@@ -0,0 +1,507 @@
package database
import (
"context"
"database/sql"
"fmt"
_ "modernc.org/sqlite"
)
// SQLiteStore implements Store using SQLite.
type SQLiteStore struct {
db *sql.DB
}
// NewSQLiteStore creates a new SQLite store.
func NewSQLiteStore(path string) (*SQLiteStore, error) {
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
// Enable foreign keys
if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
db.Close()
return nil, fmt.Errorf("failed to enable foreign keys: %w", err)
}
return &SQLiteStore{db: db}, nil
}
// Initialize creates or migrates the database schema.
func (s *SQLiteStore) Initialize(ctx context.Context) error {
// Check current schema version
var version int
err := s.db.QueryRowContext(ctx,
"SELECT version FROM schema_info LIMIT 1").Scan(&version)
needsRecreate := err != nil || version != SchemaVersion
if needsRecreate {
// Drop all tables in correct order (respecting foreign keys)
dropStmts := []string{
DropDeclarations,
DropOptions,
DropFiles,
DropRevisions,
DropSchemaInfo,
"DROP TABLE IF EXISTS options_fts",
}
for _, stmt := range dropStmts {
if _, err := s.db.ExecContext(ctx, stmt); err != nil {
return fmt.Errorf("failed to drop table: %w", err)
}
}
}
// Create tables (SQLite uses INTEGER PRIMARY KEY for auto-increment)
createStmts := []string{
SchemaInfoTable,
RevisionsTable,
OptionsTable,
DeclarationsTable,
FilesTable,
IndexOptionsRevisionName,
IndexOptionsRevisionParent,
IndexFilesRevisionPath,
IndexDeclarationsOption,
}
for _, stmt := range createStmts {
if _, err := s.db.ExecContext(ctx, stmt); err != nil {
return fmt.Errorf("failed to create schema: %w", err)
}
}
// Create FTS5 virtual table for SQLite full-text search
_, err = s.db.ExecContext(ctx, `
CREATE VIRTUAL TABLE IF NOT EXISTS options_fts USING fts5(
name,
description,
content='options',
content_rowid='id'
)
`)
if err != nil {
return fmt.Errorf("failed to create FTS table: %w", err)
}
// Create triggers to keep FTS in sync
triggers := []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`,
`CREATE TRIGGER IF NOT EXISTS options_ad AFTER DELETE ON options BEGIN
INSERT INTO options_fts(options_fts, rowid, name, description) VALUES('delete', old.id, old.name, old.description);
END`,
`CREATE TRIGGER IF NOT EXISTS options_au AFTER UPDATE ON options BEGIN
INSERT INTO options_fts(options_fts, rowid, name, description) VALUES('delete', old.id, old.name, old.description);
INSERT INTO options_fts(rowid, name, description) VALUES (new.id, new.name, new.description);
END`,
}
for _, trigger := range triggers {
if _, err := s.db.ExecContext(ctx, trigger); err != nil {
return fmt.Errorf("failed to create trigger: %w", err)
}
}
// Set schema version
if needsRecreate {
_, err = s.db.ExecContext(ctx,
"INSERT INTO schema_info (version) VALUES (?)", SchemaVersion)
if err != nil {
return fmt.Errorf("failed to set schema version: %w", err)
}
}
return nil
}
// Close closes the database connection.
func (s *SQLiteStore) Close() error {
return s.db.Close()
}
// 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,
)
if err != nil {
return fmt.Errorf("failed to create revision: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("failed to get last insert id: %w", err)
}
rev.ID = id
// Fetch the indexed_at timestamp
err = s.db.QueryRowContext(ctx,
"SELECT indexed_at FROM revisions WHERE id = ?", id).Scan(&rev.IndexedAt)
if err != nil {
return fmt.Errorf("failed to get indexed_at: %w", err)
}
return nil
}
// GetRevision retrieves a revision by git hash.
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
FROM revisions WHERE git_hash = ?`, gitHash,
).Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get revision: %w", err)
}
return rev, nil
}
// GetRevisionByChannel retrieves a revision by channel name.
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
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)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get revision by channel: %w", err)
}
return rev, nil
}
// 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
FROM revisions ORDER BY indexed_at DESC`)
if err != nil {
return nil, fmt.Errorf("failed to list revisions: %w", err)
}
defer rows.Close()
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 {
return nil, fmt.Errorf("failed to scan revision: %w", err)
}
revisions = append(revisions, rev)
}
return revisions, rows.Err()
}
// DeleteRevision removes a revision and all associated data.
func (s *SQLiteStore) DeleteRevision(ctx context.Context, id int64) error {
_, err := s.db.ExecContext(ctx, "DELETE FROM revisions WHERE id = ?", id)
if err != nil {
return fmt.Errorf("failed to delete revision: %w", err)
}
return nil
}
// UpdateRevisionOptionCount updates the option count for a revision.
func (s *SQLiteStore) UpdateRevisionOptionCount(ctx context.Context, id int64, count int) error {
_, err := s.db.ExecContext(ctx,
"UPDATE revisions SET option_count = ? WHERE id = ?", count, id)
if err != nil {
return fmt.Errorf("failed to update option count: %w", err)
}
return nil
}
// CreateOption creates a new option record.
func (s *SQLiteStore) CreateOption(ctx context.Context, opt *Option) error {
result, err := s.db.ExecContext(ctx, `
INSERT INTO options (revision_id, name, parent_path, type, default_value, example, description, read_only)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
opt.RevisionID, opt.Name, opt.ParentPath, opt.Type, opt.DefaultValue, opt.Example, opt.Description, opt.ReadOnly,
)
if err != nil {
return fmt.Errorf("failed to create option: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("failed to get last insert id: %w", err)
}
opt.ID = id
return nil
}
// CreateOptionsBatch creates multiple options in a batch.
func (s *SQLiteStore) CreateOptionsBatch(ctx context.Context, opts []*Option) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback()
stmt, err := tx.PrepareContext(ctx, `
INSERT INTO options (revision_id, name, parent_path, type, default_value, example, description, read_only)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
if err != nil {
return fmt.Errorf("failed to prepare statement: %w", err)
}
defer stmt.Close()
for _, opt := range opts {
result, err := stmt.ExecContext(ctx,
opt.RevisionID, opt.Name, opt.ParentPath, opt.Type, opt.DefaultValue, opt.Example, opt.Description, opt.ReadOnly,
)
if err != nil {
return fmt.Errorf("failed to insert option %s: %w", opt.Name, err)
}
id, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("failed to get last insert id: %w", err)
}
opt.ID = id
}
return tx.Commit()
}
// GetOption retrieves an option by revision and name.
func (s *SQLiteStore) GetOption(ctx context.Context, revisionID int64, name string) (*Option, error) {
opt := &Option{}
err := s.db.QueryRowContext(ctx, `
SELECT id, revision_id, name, parent_path, type, default_value, example, description, read_only
FROM options WHERE revision_id = ? AND name = ?`, revisionID, name,
).Scan(&opt.ID, &opt.RevisionID, &opt.Name, &opt.ParentPath, &opt.Type, &opt.DefaultValue, &opt.Example, &opt.Description, &opt.ReadOnly)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get option: %w", err)
}
return opt, nil
}
// GetChildren retrieves direct children of an option.
func (s *SQLiteStore) GetChildren(ctx context.Context, revisionID int64, parentPath string) ([]*Option, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, revision_id, name, parent_path, type, default_value, example, description, read_only
FROM options WHERE revision_id = ? AND parent_path = ?
ORDER BY name`, revisionID, parentPath)
if err != nil {
return nil, fmt.Errorf("failed to get children: %w", err)
}
defer rows.Close()
var options []*Option
for rows.Next() {
opt := &Option{}
if err := rows.Scan(&opt.ID, &opt.RevisionID, &opt.Name, &opt.ParentPath, &opt.Type, &opt.DefaultValue, &opt.Example, &opt.Description, &opt.ReadOnly); err != nil {
return nil, fmt.Errorf("failed to scan option: %w", err)
}
options = append(options, opt)
}
return options, rows.Err()
}
// SearchOptions searches for options matching a query.
func (s *SQLiteStore) SearchOptions(ctx context.Context, revisionID int64, query string, filters SearchFilters) ([]*Option, error) {
// Use SQLite FTS5 for full-text search
baseQuery := `
SELECT o.id, o.revision_id, o.name, o.parent_path, o.type, o.default_value, o.example, o.description, o.read_only
FROM options o
INNER JOIN options_fts fts ON o.id = fts.rowid
WHERE o.revision_id = ?
AND options_fts MATCH ?`
args := []interface{}{revisionID, query}
if filters.Type != "" {
baseQuery += " AND o.type = ?"
args = append(args, filters.Type)
}
if filters.Namespace != "" {
baseQuery += " AND o.name LIKE ?"
args = append(args, filters.Namespace+"%")
}
if filters.HasDefault != nil {
if *filters.HasDefault {
baseQuery += " AND o.default_value IS NOT NULL"
} else {
baseQuery += " AND o.default_value IS NULL"
}
}
baseQuery += " ORDER BY o.name"
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 options: %w", err)
}
defer rows.Close()
var options []*Option
for rows.Next() {
opt := &Option{}
if err := rows.Scan(&opt.ID, &opt.RevisionID, &opt.Name, &opt.ParentPath, &opt.Type, &opt.DefaultValue, &opt.Example, &opt.Description, &opt.ReadOnly); err != nil {
return nil, fmt.Errorf("failed to scan option: %w", err)
}
options = append(options, opt)
}
return options, rows.Err()
}
// CreateDeclaration creates a new declaration record.
func (s *SQLiteStore) CreateDeclaration(ctx context.Context, decl *Declaration) error {
result, err := s.db.ExecContext(ctx, `
INSERT INTO declarations (option_id, file_path, line)
VALUES (?, ?, ?)`,
decl.OptionID, decl.FilePath, decl.Line,
)
if err != nil {
return fmt.Errorf("failed to create declaration: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("failed to get last insert id: %w", err)
}
decl.ID = id
return nil
}
// CreateDeclarationsBatch creates multiple declarations in a batch.
func (s *SQLiteStore) CreateDeclarationsBatch(ctx context.Context, decls []*Declaration) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback()
stmt, err := tx.PrepareContext(ctx, `
INSERT INTO declarations (option_id, file_path, line)
VALUES (?, ?, ?)`)
if err != nil {
return fmt.Errorf("failed to prepare statement: %w", err)
}
defer stmt.Close()
for _, decl := range decls {
result, err := stmt.ExecContext(ctx, decl.OptionID, decl.FilePath, decl.Line)
if err != nil {
return fmt.Errorf("failed to insert declaration: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("failed to get last insert id: %w", err)
}
decl.ID = id
}
return tx.Commit()
}
// GetDeclarations retrieves declarations for an option.
func (s *SQLiteStore) GetDeclarations(ctx context.Context, optionID int64) ([]*Declaration, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, option_id, file_path, line
FROM declarations WHERE option_id = ?`, optionID)
if err != nil {
return nil, fmt.Errorf("failed to get declarations: %w", err)
}
defer rows.Close()
var decls []*Declaration
for rows.Next() {
decl := &Declaration{}
if err := rows.Scan(&decl.ID, &decl.OptionID, &decl.FilePath, &decl.Line); err != nil {
return nil, fmt.Errorf("failed to scan declaration: %w", err)
}
decls = append(decls, decl)
}
return decls, rows.Err()
}
// CreateFile creates a new file record.
func (s *SQLiteStore) CreateFile(ctx context.Context, file *File) error {
result, err := s.db.ExecContext(ctx, `
INSERT INTO files (revision_id, file_path, extension, content)
VALUES (?, ?, ?, ?)`,
file.RevisionID, file.FilePath, file.Extension, file.Content,
)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("failed to get last insert id: %w", err)
}
file.ID = id
return nil
}
// CreateFilesBatch creates multiple files in a batch.
func (s *SQLiteStore) CreateFilesBatch(ctx context.Context, files []*File) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback()
stmt, err := tx.PrepareContext(ctx, `
INSERT INTO files (revision_id, file_path, extension, content)
VALUES (?, ?, ?, ?)`)
if err != nil {
return fmt.Errorf("failed to prepare statement: %w", err)
}
defer stmt.Close()
for _, file := range files {
result, err := stmt.ExecContext(ctx, file.RevisionID, file.FilePath, file.Extension, file.Content)
if err != nil {
return fmt.Errorf("failed to insert file: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("failed to get last insert id: %w", err)
}
file.ID = id
}
return tx.Commit()
}
// GetFile retrieves a file by revision and path.
func (s *SQLiteStore) GetFile(ctx context.Context, revisionID int64, path string) (*File, error) {
file := &File{}
err := s.db.QueryRowContext(ctx, `
SELECT id, revision_id, file_path, extension, content
FROM files WHERE revision_id = ? AND file_path = ?`, revisionID, path,
).Scan(&file.ID, &file.RevisionID, &file.FilePath, &file.Extension, &file.Content)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get file: %w", err)
}
return file, nil
}

329
internal/mcp/server.go Normal file
View File

@@ -0,0 +1,329 @@
package mcp
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"log"
"git.t-juice.club/torjus/labmcp/internal/database"
)
// Server is an MCP server that handles JSON-RPC requests over stdio.
type Server struct {
store database.Store
tools map[string]ToolHandler
initialized bool
logger *log.Logger
}
// ToolHandler is a function that handles a tool call.
type ToolHandler func(ctx context.Context, args map[string]interface{}) (CallToolResult, error)
// NewServer creates a new MCP server.
func NewServer(store database.Store, logger *log.Logger) *Server {
if logger == nil {
logger = log.New(io.Discard, "", 0)
}
s := &Server{
store: store,
tools: make(map[string]ToolHandler),
logger: logger,
}
s.registerTools()
return s
}
// registerTools registers all available tools.
func (s *Server) registerTools() {
// Tools will be implemented in handlers.go
}
// Run starts the server, reading from r and writing to w.
func (s *Server) Run(ctx context.Context, r io.Reader, w io.Writer) error {
scanner := bufio.NewScanner(r)
encoder := json.NewEncoder(w)
for scanner.Scan() {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
line := scanner.Bytes()
if len(line) == 0 {
continue
}
var req Request
if err := json.Unmarshal(line, &req); err != nil {
s.logger.Printf("Failed to parse request: %v", err)
resp := Response{
JSONRPC: "2.0",
Error: &Error{
Code: ParseError,
Message: "Parse error",
Data: err.Error(),
},
}
if err := encoder.Encode(resp); err != nil {
return fmt.Errorf("failed to write response: %w", err)
}
continue
}
resp := s.handleRequest(ctx, &req)
if resp != nil {
if err := encoder.Encode(resp); err != nil {
return fmt.Errorf("failed to write response: %w", err)
}
}
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("scanner error: %w", err)
}
return nil
}
// handleRequest processes a single request and returns a response.
func (s *Server) handleRequest(ctx context.Context, req *Request) *Response {
s.logger.Printf("Received request: method=%s id=%v", req.Method, req.ID)
switch req.Method {
case MethodInitialize:
return s.handleInitialize(req)
case MethodInitialized:
// This is a notification, no response needed
s.initialized = true
return nil
case MethodToolsList:
return s.handleToolsList(req)
case MethodToolsCall:
return s.handleToolsCall(ctx, req)
default:
return &Response{
JSONRPC: "2.0",
ID: req.ID,
Error: &Error{
Code: MethodNotFound,
Message: "Method not found",
Data: req.Method,
},
}
}
}
// handleInitialize processes the initialize request.
func (s *Server) handleInitialize(req *Request) *Response {
var params InitializeParams
if req.Params != nil {
if err := json.Unmarshal(req.Params, &params); err != nil {
return &Response{
JSONRPC: "2.0",
ID: req.ID,
Error: &Error{
Code: InvalidParams,
Message: "Invalid params",
Data: err.Error(),
},
}
}
}
s.logger.Printf("Client: %s %s, protocol: %s",
params.ClientInfo.Name, params.ClientInfo.Version, params.ProtocolVersion)
result := InitializeResult{
ProtocolVersion: ProtocolVersion,
Capabilities: Capabilities{
Tools: &ToolsCapability{
ListChanged: false,
},
},
ServerInfo: Implementation{
Name: "nixos-options",
Version: "0.1.0",
},
}
return &Response{
JSONRPC: "2.0",
ID: req.ID,
Result: result,
}
}
// handleToolsList returns the list of available tools.
func (s *Server) handleToolsList(req *Request) *Response {
tools := s.getToolDefinitions()
return &Response{
JSONRPC: "2.0",
ID: req.ID,
Result: ListToolsResult{Tools: tools},
}
}
// getToolDefinitions returns the tool definitions.
func (s *Server) getToolDefinitions() []Tool {
return []Tool{
{
Name: "search_options",
Description: "Search for NixOS configuration options by name or description",
InputSchema: InputSchema{
Type: "object",
Properties: map[string]Property{
"query": {
Type: "string",
Description: "Search query (matches option names and descriptions)",
},
"revision": {
Type: "string",
Description: "Git hash or channel name (e.g., 'nixos-unstable'). Uses default if not specified.",
},
"type": {
Type: "string",
Description: "Filter by option type (e.g., 'boolean', 'string', 'list')",
},
"namespace": {
Type: "string",
Description: "Filter by namespace prefix (e.g., 'services.nginx')",
},
"limit": {
Type: "integer",
Description: "Maximum number of results (default: 50)",
Default: 50,
},
},
Required: []string{"query"},
},
},
{
Name: "get_option",
Description: "Get full details for a specific NixOS option including its children",
InputSchema: InputSchema{
Type: "object",
Properties: map[string]Property{
"name": {
Type: "string",
Description: "Full option path (e.g., 'services.nginx.enable')",
},
"revision": {
Type: "string",
Description: "Git hash or channel name. Uses default if not specified.",
},
"include_children": {
Type: "boolean",
Description: "Include direct children of this option (default: true)",
Default: true,
},
},
Required: []string{"name"},
},
},
{
Name: "get_file",
Description: "Fetch the contents of a file from nixpkgs",
InputSchema: InputSchema{
Type: "object",
Properties: map[string]Property{
"path": {
Type: "string",
Description: "File path relative to nixpkgs root (e.g., 'nixos/modules/services/web-servers/nginx/default.nix')",
},
"revision": {
Type: "string",
Description: "Git hash or channel name. Uses default if not specified.",
},
},
Required: []string{"path"},
},
},
{
Name: "index_revision",
Description: "Index a nixpkgs revision to make its options searchable",
InputSchema: InputSchema{
Type: "object",
Properties: map[string]Property{
"revision": {
Type: "string",
Description: "Git hash (full or short) or channel name (e.g., 'nixos-unstable', 'nixos-24.05')",
},
},
Required: []string{"revision"},
},
},
{
Name: "list_revisions",
Description: "List all indexed nixpkgs revisions",
InputSchema: InputSchema{
Type: "object",
Properties: map[string]Property{},
},
},
{
Name: "delete_revision",
Description: "Delete an indexed revision and all its data",
InputSchema: InputSchema{
Type: "object",
Properties: map[string]Property{
"revision": {
Type: "string",
Description: "Git hash or channel name of the revision to delete",
},
},
Required: []string{"revision"},
},
},
}
}
// handleToolsCall handles a tool invocation.
func (s *Server) handleToolsCall(ctx context.Context, req *Request) *Response {
var params CallToolParams
if err := json.Unmarshal(req.Params, &params); err != nil {
return &Response{
JSONRPC: "2.0",
ID: req.ID,
Error: &Error{
Code: InvalidParams,
Message: "Invalid params",
Data: err.Error(),
},
}
}
s.logger.Printf("Tool call: %s with args %v", params.Name, params.Arguments)
handler, ok := s.tools[params.Name]
if !ok {
return &Response{
JSONRPC: "2.0",
ID: req.ID,
Result: CallToolResult{
Content: []Content{TextContent(fmt.Sprintf("Unknown tool: %s", params.Name))},
IsError: true,
},
}
}
result, err := handler(ctx, params.Arguments)
if err != nil {
s.logger.Printf("Tool error: %v", err)
return &Response{
JSONRPC: "2.0",
ID: req.ID,
Result: ErrorContent(err),
}
}
return &Response{
JSONRPC: "2.0",
ID: req.ID,
Result: result,
}
}

139
internal/mcp/types.go Normal file
View File

@@ -0,0 +1,139 @@
// Package mcp implements the Model Context Protocol (MCP) over JSON-RPC.
package mcp
import "encoding/json"
// JSON-RPC 2.0 types
// Request represents a JSON-RPC request.
type Request struct {
JSONRPC string `json:"jsonrpc"`
ID interface{} `json:"id,omitempty"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
// Response represents a JSON-RPC response.
type Response struct {
JSONRPC string `json:"jsonrpc"`
ID interface{} `json:"id,omitempty"`
Result interface{} `json:"result,omitempty"`
Error *Error `json:"error,omitempty"`
}
// Error represents a JSON-RPC error.
type Error struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
// Standard JSON-RPC error codes
const (
ParseError = -32700
InvalidRequest = -32600
MethodNotFound = -32601
InvalidParams = -32602
InternalError = -32603
)
// MCP Protocol types
// InitializeParams are sent by the client during initialization.
type InitializeParams struct {
ProtocolVersion string `json:"protocolVersion"`
Capabilities Capabilities `json:"capabilities"`
ClientInfo Implementation `json:"clientInfo"`
}
// InitializeResult is returned after successful initialization.
type InitializeResult struct {
ProtocolVersion string `json:"protocolVersion"`
Capabilities Capabilities `json:"capabilities"`
ServerInfo Implementation `json:"serverInfo"`
}
// Capabilities describes client or server capabilities.
type Capabilities struct {
Tools *ToolsCapability `json:"tools,omitempty"`
}
// ToolsCapability describes tool-related capabilities.
type ToolsCapability struct {
ListChanged bool `json:"listChanged,omitempty"`
}
// Implementation describes a client or server implementation.
type Implementation struct {
Name string `json:"name"`
Version string `json:"version"`
}
// Tool describes an MCP tool.
type Tool struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
InputSchema InputSchema `json:"inputSchema"`
}
// InputSchema describes the JSON Schema for tool inputs.
type InputSchema struct {
Type string `json:"type"`
Properties map[string]Property `json:"properties,omitempty"`
Required []string `json:"required,omitempty"`
}
// Property describes a single property in an input schema.
type Property struct {
Type string `json:"type"`
Description string `json:"description,omitempty"`
Enum []string `json:"enum,omitempty"`
Default any `json:"default,omitempty"`
}
// ListToolsResult is returned by tools/list.
type ListToolsResult struct {
Tools []Tool `json:"tools"`
}
// CallToolParams are sent when calling a tool.
type CallToolParams struct {
Name string `json:"name"`
Arguments map[string]interface{} `json:"arguments,omitempty"`
}
// CallToolResult is returned after calling a tool.
type CallToolResult struct {
Content []Content `json:"content"`
IsError bool `json:"isError,omitempty"`
}
// Content represents a piece of content in a tool result.
type Content struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
}
// TextContent creates a text content item.
func TextContent(text string) Content {
return Content{Type: "text", Text: text}
}
// ErrorContent creates an error content item.
func ErrorContent(err error) CallToolResult {
return CallToolResult{
Content: []Content{TextContent(err.Error())},
IsError: true,
}
}
// MCP method names
const (
MethodInitialize = "initialize"
MethodInitialized = "notifications/initialized"
MethodToolsList = "tools/list"
MethodToolsCall = "tools/call"
)
// Protocol version
const ProtocolVersion = "2024-11-05"

45
internal/nixos/types.go Normal file
View File

@@ -0,0 +1,45 @@
// Package nixos contains types and logic specific to NixOS options.
package nixos
// RawOption represents an option as parsed from options.json.
// The structure matches the output of `nix-build '<nixpkgs/nixos/release.nix>' -A options`.
type RawOption struct {
Declarations []string `json:"declarations"`
Default *OptionValue `json:"default,omitempty"`
Description string `json:"description"`
Example *OptionValue `json:"example,omitempty"`
ReadOnly bool `json:"readOnly"`
Type string `json:"type"`
Loc []string `json:"loc,omitempty"`
}
// OptionValue wraps a value that may be a literal or a Nix expression.
type OptionValue struct {
// Text is the raw JSON representation of the value
Text string
}
// OptionsFile represents the top-level structure of options.json.
// It's a map from option name to option definition.
type OptionsFile map[string]RawOption
// AllowedExtensions is the default set of file extensions to index.
var AllowedExtensions = map[string]bool{
".nix": true,
".json": true,
".md": true,
".txt": true,
".toml": true,
".yaml": true,
".yml": true,
}
// ChannelAliases maps friendly channel names to their git branch/ref patterns.
var ChannelAliases = map[string]string{
"nixos-unstable": "nixos-unstable",
"nixos-stable": "nixos-24.11", // Update this as new stable releases come out
"nixos-24.11": "nixos-24.11",
"nixos-24.05": "nixos-24.05",
"nixos-23.11": "nixos-23.11",
"nixos-23.05": "nixos-23.05",
}