diff --git a/.github/workflows/update-crush-settings.yml b/.github/workflows/update-crush-settings.yml new file mode 100644 index 0000000..e0b8c3e --- /dev/null +++ b/.github/workflows/update-crush-settings.yml @@ -0,0 +1,57 @@ +name: Update Crush Settings + +on: + schedule: + # Run daily at 3:00 UTC + - cron: "0 3 * * *" + workflow_dispatch: # Allow manual triggering + +jobs: + update-schema: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Install nix + uses: cachix/install-nix-action@v31 + with: + extra_nix_config: | + experimental-features = nix-command flakes + access-tokens = github.com=${{ secrets.GITHUB_TOKEN }} + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: "1.21" + + - name: Generate options from schema + run: | + cd scripts && go generate + + - name: Format with alejandra + run: | + nix-shell -p alejandra --run "alejandra modules/crush/options/settings.nix" + + - name: Check for changes + id: check_changes + run: | + if git diff --quiet modules/crush/options.nix; then + echo "changed=false" >> $GITHUB_OUTPUT + else + echo "changed=true" >> $GITHUB_OUTPUT + fi + + - name: Open pull request + if: steps.check_changes.outputs.changed == 'true' + uses: peter-evans/create-pull-request@v7 + with: + commit-message: "chore: update crush schema options" + title: "Update Crush schema options" + body: | + This PR updates the Crush module options from the latest schema. + + Generated automatically by the daily schema update workflow. + branch: "update-crush-schema" + base: "master" + delete-branch: true diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..5c1857a --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,23 @@ +# Scripts + +## Crush module options generator + +Generates Nix module options from the Crush JSON schema. + +### Usage + +```bash +cd scripts && go generate +``` + +The generator automatically fetches the version from `pkgs/crush/default.nix` and downloads the corresponding schema from GitHub. + +You can also run it manually: + +```bash +go run scripts/generate-crush-settings.go -output modules/crush/options/settings.nix +``` + +### Automatic updates + +A GitHub Actions workflow runs daily at 3:00 UTC to check for schema updates and automatically opens a PR if changes are detected. diff --git a/scripts/doc.go b/scripts/doc.go new file mode 100644 index 0000000..ef181f2 --- /dev/null +++ b/scripts/doc.go @@ -0,0 +1,4 @@ +// Package main generates Nix module options from Crush schema. +// +//go:generate sh -c "cd .. && go run scripts/generate-crush-settings.go -output modules/crush/options/settings.nix" +package main diff --git a/scripts/generate-crush-settings.go b/scripts/generate-crush-settings.go new file mode 100644 index 0000000..3b9dce4 --- /dev/null +++ b/scripts/generate-crush-settings.go @@ -0,0 +1,410 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + "text/template" + "time" +) + +type Schema struct { + Ref string `json:"$ref"` + Defs map[string]any `json:"$defs"` + Props map[string]*Property `json:"properties"` +} + +type Property struct { + Type string `json:"type"` + Description string `json:"description"` + Default any `json:"default"` + Enum []string `json:"enum"` + Ref string `json:"$ref"` + Properties map[string]*Property `json:"properties"` + Items *Property `json:"items"` + AdditionalProperties *Property `json:"additionalProperties"` +} + +type OptionData struct { + Name string + Type string + Default string + Description string + EnumValues []string + Children []OptionData + Indent string +} + +const templates = ` +{{- define "root" -}} +{lib}: +lib.mkOption { + type = lib.types.submodule { + options = { +{{- range .Options}} +{{template "option" .}} +{{- end}} + }; + }; + default = {}; +} +{{end}} + +{{- define "option" -}} +{{if .Children}}{{template "submodule" .}} +{{- else if .EnumValues}}{{template "enum" .}} +{{- else}}{{template "simple" .}} +{{- end}} +{{- end}} + +{{- define "simple" -}} +{{.Indent}}{{.Name}} = lib.mkOption { +{{.Indent}} type = {{.Type}}; +{{- if .Default}} +{{.Indent}} default = {{.Default}}; +{{- end}} +{{- if .Description}} +{{.Indent}} description = "{{.Description}}"; +{{- end}} +{{.Indent}}}; +{{end}} + +{{- define "enum" -}} +{{.Indent}}{{.Name}} = lib.mkOption { +{{.Indent}} type = lib.types.enum [ +{{- range .EnumValues}} +{{$.Indent}} "{{.}}" +{{- end}} +{{.Indent}} ]; +{{- if .Default}} +{{.Indent}} default = {{.Default}}; +{{- end}} +{{- if .Description}} +{{.Indent}} description = "{{.Description}}"; +{{- end}} +{{.Indent}}}; +{{end}} + +{{- define "submodule" -}} +{{.Indent}}{{.Name}} = lib.mkOption { +{{- if eq .Type "lib.types.submodule"}} +{{.Indent}} type = lib.types.submodule { +{{.Indent}} options = { +{{- range .Children}} +{{template "option" .}} +{{- end}} +{{.Indent}} }; +{{.Indent}} }; +{{- else}} +{{.Indent}} type = {{.Type}} { +{{.Indent}} options = { +{{- range .Children}} +{{template "option" .}} +{{- end}} +{{.Indent}} }; +{{.Indent}} }); +{{- end}} +{{- if .Default}} +{{.Indent}} default = {{.Default}}; +{{- end}} +{{- if .Description}} +{{.Indent}} description = "{{.Description}}"; +{{- end}} +{{.Indent}}}; +{{end}} +` + +var tmpl *template.Template + +func init() { + tmpl = template.Must(template.New("nix").Parse(templates)) +} + +func main() { + schemaURL := flag.String("url", "", "Schema URL to fetch (defaults to version from pkgs/crush/default.nix)") + output := flag.String("output", "modules/crush/options/settings.nix", "Output file (defaults to modules/crush/options/settings.nix)") + flag.Parse() + + // Get version from crush package if URL not provided + url := *schemaURL + if url == "" { + version, err := getCrushVersion() + if err != nil { + fmt.Fprintf(os.Stderr, "Error getting crush version: %v\n", err) + os.Exit(1) + } + url = fmt.Sprintf("https://raw.githubusercontent.com/charmbracelet/crush/refs/tags/v%s/schema.json", version) + } + + schema, err := fetchSchema(url) + if err != nil { + fmt.Fprintf(os.Stderr, "Error fetching schema: %v\n", err) + os.Exit(1) + } + + rootDef := resolveRootDef(schema) + options := generateOptions(rootDef.Properties, schema, " ") + + outputPath := *output + + var w io.Writer = os.Stdout + if outputPath != "-" { + f, err := os.Create(outputPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Error creating output file: %v\n", err) + os.Exit(1) + } + defer f.Close() + w = f + fmt.Fprintf(os.Stderr, "Generated %s\n", outputPath) + } + + if err := tmpl.ExecuteTemplate(w, "root", map[string]any{ + "Options": options, + }); err != nil { + fmt.Fprintf(os.Stderr, "Error executing template: %v\n", err) + os.Exit(1) + } +} + +func getCrushVersion() (string, error) { + // Try finding from script directory + scriptDir, err := filepath.Abs(filepath.Dir(os.Args[0])) + if err == nil { + nixFile := filepath.Join(scriptDir, "..", "pkgs", "crush", "default.nix") + if version, err := extractVersion(nixFile); err == nil { + return version, nil + } + } + + // Try from current working directory + nixFile := filepath.Join("pkgs", "crush", "default.nix") + return extractVersion(nixFile) +} + +func extractVersion(nixFile string) (string, error) { + data, err := os.ReadFile(nixFile) + if err != nil { + return "", err + } + + re := regexp.MustCompile(`version = "([^"]+)"`) + matches := re.FindSubmatch(data) + if len(matches) < 2 { + return "", fmt.Errorf("version not found in %s", nixFile) + } + + return string(matches[1]), nil +} + +func fetchSchema(url string) (*Schema, error) { + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var schema Schema + if err := json.Unmarshal(body, &schema); err != nil { + return nil, err + } + + return &schema, nil +} + +func resolveRootDef(schema *Schema) *Property { + if schema.Ref != "" { + return resolveRef(schema.Ref, schema) + } + return &Property{Properties: schema.Props} +} + +func resolveRef(ref string, schema *Schema) *Property { + if !strings.HasPrefix(ref, "#/$defs/") { + return &Property{} + } + defName := strings.TrimPrefix(ref, "#/$defs/") + + if def, ok := schema.Defs[defName]; ok { + data, _ := json.Marshal(def) + var prop Property + json.Unmarshal(data, &prop) + return &prop + } + return &Property{} +} + +func isValidNixIdentifier(name string) bool { + if name == "" { + return false + } + if name[0] >= '0' && name[0] <= '9' || name[0] == '$' { + return false + } + validChars := regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) + return validChars.MatchString(name) +} + +func escapeNixString(s string) string { + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, `"`, `\"`) + s = strings.ReplaceAll(s, "\n", `\n`) + return s +} + +func nixDefault(val any) string { + if val == nil { + return "null" + } + switch v := val.(type) { + case bool: + if v { + return "true" + } + return "false" + case float64, int, int64: + return fmt.Sprintf("%v", v) + case string: + return fmt.Sprintf(`"%s"`, escapeNixString(v)) + case []any: + return "[]" + case map[string]any: + return "{}" + default: + return fmt.Sprintf("%v", v) + } +} + +func nixType(jsonType string) string { + switch jsonType { + case "string": + return "lib.types.str" + case "number": + return "lib.types.number" + case "integer": + return "lib.types.int" + case "boolean": + return "lib.types.bool" + case "array": + return "lib.types.listOf lib.types.str" + case "object": + return "lib.types.attrsOf lib.types.anything" + default: + return "lib.types.anything" + } +} + +func generateOptions(props map[string]*Property, schema *Schema, indent string) []OptionData { + if props == nil { + return nil + } + + var options []OptionData + for name, prop := range props { + if !isValidNixIdentifier(name) { + continue + } + + if prop.Ref != "" { + prop = resolveRef(prop.Ref, schema) + } + + opt := OptionData{ + Name: name, + Description: escapeNixString(prop.Description), + Indent: indent, + } + + // Enums + if len(prop.Enum) > 0 { + opt.EnumValues = prop.Enum + if prop.Default != nil { + opt.Default = fmt.Sprintf(`"%v"`, prop.Default) + } + options = append(options, opt) + continue + } + + // Nested objects with properties + if prop.Type == "object" && len(prop.Properties) > 0 { + opt.Type = "lib.types.submodule" + opt.Children = generateOptions(prop.Properties, schema, indent+" ") + if prop.Default != nil { + opt.Default = "{}" + } + options = append(options, opt) + continue + } + + // Arrays of objects + if prop.Type == "array" && prop.Items != nil { + items := prop.Items + if items.Ref != "" { + items = resolveRef(items.Ref, schema) + } + + if items.Type == "object" && len(items.Properties) > 0 { + opt.Type = "lib.types.listOf (lib.types.submodule" + opt.Children = generateOptions(items.Properties, schema, indent+" ") + if prop.Default != nil { + opt.Default = "[]" + } + options = append(options, opt) + continue + } + + opt.Type = "lib.types.listOf lib.types.str" + if prop.Default != nil { + opt.Default = "[]" + } + options = append(options, opt) + continue + } + + // AdditionalProperties (attrs of objects) + if prop.Type == "object" && prop.AdditionalProperties != nil { + additionalProps := prop.AdditionalProperties + if additionalProps.Ref != "" { + additionalProps = resolveRef(additionalProps.Ref, schema) + } + + if additionalProps.Type == "object" && len(additionalProps.Properties) > 0 { + opt.Type = "lib.types.attrsOf (lib.types.submodule" + opt.Children = generateOptions(additionalProps.Properties, schema, indent+" ") + if prop.Default != nil { + opt.Default = "{}" + } + options = append(options, opt) + continue + } + + opt.Type = "lib.types.attrsOf lib.types.anything" + if prop.Default != nil { + opt.Default = "{}" + } + options = append(options, opt) + continue + } + + // Simple types + opt.Type = nixType(prop.Type) + if prop.Default != nil { + opt.Default = nixDefault(prop.Default) + } + options = append(options, opt) + } + + return options +} diff --git a/scripts/generate-options.sh b/scripts/generate-options.sh deleted file mode 100755 index 20672ae..0000000 --- a/scripts/generate-options.sh +++ /dev/null @@ -1,333 +0,0 @@ -#!/usr/bin/env nix-shell -#!nix-shell -i bash -p jq curl alejandra - -set -euo pipefail - -# Fetch the JSON schema -SCHEMA_URL="https://charm.land/crush.json" -SCHEMA=$(curl -s "$SCHEMA_URL") - -# Extract enum values for provider types -PROVIDER_TYPES=$(echo "$SCHEMA" | jq -r '.["$defs"].ProviderConfig.properties.type.enum[]' | sed 's/^/ "/' | sed 's/$/"/') - -# Extract enum values for MCP types -MCP_TYPES=$(echo "$SCHEMA" | jq -r '.["$defs"].MCPConfig.properties.type.enum[]' | sed 's/^/ "/' | sed 's/$/"/') - -# Extract enum values for reasoning effort -REASONING_EFFORTS=$(echo "$SCHEMA" | jq -r '.["$defs"].SelectedModel.properties.reasoning_effort.enum[]' | sed 's/^/ "/' | sed 's/$/"/') - -# Generate the Nix file to temporary location -TEMP_FILE=$(mktemp) -cat << EOF > "$TEMP_FILE" -{lib}: -lib.mkOption { - type = lib.types.submodule { - options = { - providers = lib.mkOption { - type = lib.types.attrsOf ( - lib.types.submodule { - options = { - id = lib.mkOption { - type = lib.types.str; - description = "Unique identifier for the provider"; - }; - name = lib.mkOption { - type = lib.types.str; - description = "Human-readable name for the provider"; - }; - base_url = lib.mkOption { - type = lib.types.str; - default = ""; - description = "Base URL for the provider's API"; - }; - type = lib.mkOption { - type = lib.types.enum [ -$PROVIDER_TYPES - ]; - default = "openai"; - description = "Provider type that determines the API format"; - }; - api_key = lib.mkOption { - type = lib.types.str; - default = ""; - description = "API key for authentication with the provider"; - }; - disable = lib.mkOption { - type = lib.types.bool; - default = false; - description = "Whether this provider is disabled"; - }; - system_prompt_prefix = lib.mkOption { - type = lib.types.str; - default = ""; - description = "Custom prefix to add to system prompts for this provider"; - }; - extra_headers = lib.mkOption { - type = lib.types.attrsOf lib.types.str; - default = {}; - description = "Additional HTTP headers to send with requests"; - }; - extra_body = lib.mkOption { - type = lib.types.attrsOf lib.types.anything; - default = {}; - description = "Additional fields to include in request bodies"; - }; - models = lib.mkOption { - type = lib.types.listOf ( - lib.types.submodule { - options = { - id = lib.mkOption { - type = lib.types.str; - description = "Model ID"; - }; - name = lib.mkOption { - type = lib.types.str; - description = "Model display name"; - }; - cost_per_1m_in = lib.mkOption { - type = lib.types.number; - default = 0; - description = "Cost per 1M input tokens"; - }; - cost_per_1m_out = lib.mkOption { - type = lib.types.number; - default = 0; - description = "Cost per 1M output tokens"; - }; - cost_per_1m_in_cached = lib.mkOption { - type = lib.types.number; - default = 0; - description = "Cost per 1M cached input tokens"; - }; - cost_per_1m_out_cached = lib.mkOption { - type = lib.types.number; - default = 0; - description = "Cost per 1M cached output tokens"; - }; - context_window = lib.mkOption { - type = lib.types.int; - default = 128000; - description = "Maximum context window size"; - }; - default_max_tokens = lib.mkOption { - type = lib.types.int; - default = 8192; - description = "Default maximum tokens for responses"; - }; - can_reason = lib.mkOption { - type = lib.types.bool; - default = false; - description = "Whether the model supports reasoning"; - }; - has_reasoning_efforts = lib.mkOption { - type = lib.types.bool; - default = false; - description = "Whether the model supports reasoning effort levels"; - }; - default_reasoning_effort = lib.mkOption { - type = lib.types.str; - default = ""; - description = "Default reasoning effort level"; - }; - supports_attachments = lib.mkOption { - type = lib.types.bool; - default = false; - description = "Whether the model supports file attachments"; - }; - }; - } - ); - default = []; - description = "List of models available from this provider"; - }; - }; - } - ); - default = {}; - description = "AI provider configurations"; - }; - - lsp = lib.mkOption { - type = lib.types.attrsOf ( - lib.types.submodule { - options = { - command = lib.mkOption { - type = lib.types.str; - description = "Command to execute for the LSP server"; - }; - args = lib.mkOption { - type = lib.types.listOf lib.types.str; - default = []; - description = "Arguments to pass to the LSP server command"; - }; - options = lib.mkOption { - type = lib.types.attrsOf lib.types.anything; - default = {}; - description = "LSP server-specific configuration options"; - }; - enabled = lib.mkOption { - type = lib.types.bool; - default = false; - description = "Whether this LSP server is enabled"; - }; - }; - } - ); - default = {}; - description = "Language Server Protocol configurations"; - }; - - mcp = lib.mkOption { - type = lib.types.attrsOf ( - lib.types.submodule { - options = { - command = lib.mkOption { - type = lib.types.str; - default = ""; - description = "Command to execute for stdio MCP servers"; - }; - env = lib.mkOption { - type = lib.types.attrsOf lib.types.str; - default = {}; - description = "Environment variables to set for the MCP server"; - }; - args = lib.mkOption { - type = lib.types.listOf lib.types.str; - default = []; - description = "Arguments to pass to the MCP server command"; - }; - type = lib.mkOption { - type = lib.types.enum [ -$MCP_TYPES - ]; - default = "stdio"; - description = "Type of MCP connection"; - }; - url = lib.mkOption { - type = lib.types.str; - default = ""; - description = "URL for HTTP or SSE MCP servers"; - }; - disabled = lib.mkOption { - type = lib.types.bool; - default = false; - description = "Whether this MCP server is disabled"; - }; - headers = lib.mkOption { - type = lib.types.attrsOf lib.types.str; - default = {}; - description = "HTTP headers for HTTP/SSE MCP servers"; - }; - }; - } - ); - default = {}; - description = "Model Context Protocol server configurations"; - }; - - options = lib.mkOption { - type = lib.types.submodule { - options = { - context_paths = lib.mkOption { - type = lib.types.listOf lib.types.str; - default = []; - description = "Paths to files containing context information for the AI"; - }; - tui = lib.mkOption { - type = lib.types.submodule { - options = { - compact_mode = lib.mkOption { - type = lib.types.bool; - default = false; - description = "Enable compact mode for the TUI interface"; - }; - }; - }; - default = {}; - description = "Terminal user interface options"; - }; - debug = lib.mkOption { - type = lib.types.bool; - default = false; - description = "Enable debug logging"; - }; - debug_lsp = lib.mkOption { - type = lib.types.bool; - default = false; - description = "Enable debug logging for LSP servers"; - }; - disable_auto_summarize = lib.mkOption { - type = lib.types.bool; - default = false; - description = "Disable automatic conversation summarization"; - }; - data_directory = lib.mkOption { - type = lib.types.str; - default = ".crush"; - description = "Directory for storing application data (relative to working directory)"; - }; - }; - }; - default = {}; - description = "General application options"; - }; - - permissions = lib.mkOption { - type = lib.types.submodule { - options = { - allowed_tools = lib.mkOption { - type = lib.types.listOf lib.types.str; - default = []; - description = "List of tools that don't require permission prompts"; - }; - }; - }; - default = {}; - description = "Permission settings for tool usage"; - }; - - models = lib.mkOption { - type = lib.types.attrsOf ( - lib.types.submodule { - options = { - model = lib.mkOption { - type = lib.types.str; - description = "The model ID as used by the provider API"; - }; - provider = lib.mkOption { - type = lib.types.str; - description = "The model provider ID that matches a key in the providers config"; - }; - reasoning_effort = lib.mkOption { - type = lib.types.enum [ -$REASONING_EFFORTS - ]; - default = ""; - description = "Reasoning effort level for OpenAI models that support it"; - }; - max_tokens = lib.mkOption { - type = lib.types.int; - default = 0; - description = "Maximum number of tokens for model responses"; - }; - think = lib.mkOption { - type = lib.types.bool; - default = false; - description = "Enable thinking mode for Anthropic models that support reasoning"; - }; - }; - } - ); - default = {}; - description = "Model configurations"; - }; - }; - }; - default = {}; -} -EOF - -# Format with alejandra and output -alejandra "$TEMP_FILE" -cat "$TEMP_FILE" -rm "$TEMP_FILE" diff --git a/scripts/go.mod b/scripts/go.mod new file mode 100644 index 0000000..53f6cde --- /dev/null +++ b/scripts/go.mod @@ -0,0 +1,3 @@ +module github.com/charmbracelet/nur/scripts + +go 1.21