fix(schema): fix `crush.json` schema generation (#2574)

Bruno Krugel created

Change summary

internal/cmd/schema_test.go |  59 +++++++++++
internal/csync/maps.go      |   5 
schema.json                 | 196 ++++++++++++++++++++++++++++++++++++++
3 files changed, 256 insertions(+), 4 deletions(-)

Detailed changes

internal/cmd/schema_test.go 🔗

@@ -0,0 +1,59 @@
+package cmd
+
+import (
+	"encoding/json"
+	"strings"
+	"testing"
+
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/invopop/jsonschema"
+	"github.com/stretchr/testify/require"
+)
+
+func TestSchemaNoBrokenRefs(t *testing.T) {
+	t.Parallel()
+
+	reflector := new(jsonschema.Reflector)
+	bts, err := json.Marshal(reflector.Reflect(&config.Config{}))
+	require.NoError(t, err)
+
+	var schema struct {
+		Defs map[string]json.RawMessage `json:"$defs"`
+	}
+	require.NoError(t, json.Unmarshal(bts, &schema))
+	require.NotEmpty(t, schema.Defs, "schema should have definitions")
+
+	for name := range schema.Defs {
+		require.NotContains(t, name, "/", "schema $def key %q contains '/' which breaks JSON Pointer $ref resolution", name)
+	}
+}
+
+func TestSchemaProvidersHasAdditionalProperties(t *testing.T) {
+	t.Parallel()
+
+	reflector := new(jsonschema.Reflector)
+	bts, err := json.Marshal(reflector.Reflect(&config.Config{}))
+	require.NoError(t, err)
+
+	var schema struct {
+		Defs map[string]json.RawMessage `json:"$defs"`
+	}
+	require.NoError(t, json.Unmarshal(bts, &schema))
+
+	var cfg struct {
+		Properties map[string]json.RawMessage `json:"properties"`
+	}
+	require.NoError(t, json.Unmarshal(schema.Defs["Config"], &cfg))
+
+	providersRaw, ok := cfg.Properties["providers"]
+	require.True(t, ok, "Config should have a providers property")
+
+	var providers struct {
+		Type                 string          `json:"type"`
+		AdditionalProperties json.RawMessage `json:"additionalProperties"`
+	}
+	require.NoError(t, json.Unmarshal(providersRaw, &providers))
+	require.Equal(t, "object", providers.Type)
+	require.True(t, strings.Contains(string(providers.AdditionalProperties), "ProviderConfig"),
+		"providers should use additionalProperties with a ProviderConfig ref, got: %s", string(providers.AdditionalProperties))
+}

internal/csync/maps.go 🔗

@@ -131,7 +131,10 @@ var (
 	_ json.Marshaler   = &Map[string, any]{}
 )
 
-func (*Map[K, V]) JSONSchemaAlias() any {
+// JSONSchemaAlias returns the underlying map type for JSON schema generation.
+// Value receiver is required because github.com/invopop/jsonschema checks
+// interface satisfaction on the non-pointer type after stripping pointers.
+func (Map[K, V]) JSONSchemaAlias() any { //nolint
 	m := map[K]V{}
 	return m
 }

schema.json 🔗

@@ -64,7 +64,10 @@
           "description": "Model configurations for different model types"
         },
         "providers": {
-          "$ref": "#/$defs/Map[string,github.com/charmbracelet/crush/internal/config.ProviderConfig]",
+          "additionalProperties": {
+            "$ref": "#/$defs/ProviderConfig"
+          },
+          "type": "object",
           "description": "AI provider configurations"
         },
         "mcp": {
@@ -262,8 +265,88 @@
       },
       "type": "object"
     },
-    "Map[string,github.com/charmbracelet/crush/internal/config.ProviderConfig]": {
-      "properties": {},
+    "Model": {
+      "properties": {
+        "id": {
+          "type": "string"
+        },
+        "name": {
+          "type": "string"
+        },
+        "cost_per_1m_in": {
+          "type": "number"
+        },
+        "cost_per_1m_out": {
+          "type": "number"
+        },
+        "cost_per_1m_in_cached": {
+          "type": "number"
+        },
+        "cost_per_1m_out_cached": {
+          "type": "number"
+        },
+        "context_window": {
+          "type": "integer"
+        },
+        "default_max_tokens": {
+          "type": "integer"
+        },
+        "can_reason": {
+          "type": "boolean"
+        },
+        "reasoning_levels": {
+          "items": {
+            "type": "string"
+          },
+          "type": "array"
+        },
+        "default_reasoning_effort": {
+          "type": "string"
+        },
+        "supports_attachments": {
+          "type": "boolean"
+        },
+        "options": {
+          "$ref": "#/$defs/ModelOptions"
+        }
+      },
+      "additionalProperties": false,
+      "type": "object",
+      "required": [
+        "id",
+        "name",
+        "cost_per_1m_in",
+        "cost_per_1m_out",
+        "cost_per_1m_in_cached",
+        "cost_per_1m_out_cached",
+        "context_window",
+        "default_max_tokens",
+        "can_reason",
+        "supports_attachments",
+        "options"
+      ]
+    },
+    "ModelOptions": {
+      "properties": {
+        "temperature": {
+          "type": "number"
+        },
+        "top_p": {
+          "type": "number"
+        },
+        "top_k": {
+          "type": "integer"
+        },
+        "frequency_penalty": {
+          "type": "number"
+        },
+        "presence_penalty": {
+          "type": "number"
+        },
+        "provider_options": {
+          "type": "object"
+        }
+      },
       "additionalProperties": false,
       "type": "object"
     },
@@ -405,6 +488,89 @@
       "additionalProperties": false,
       "type": "object"
     },
+    "ProviderConfig": {
+      "properties": {
+        "id": {
+          "type": "string",
+          "description": "Unique identifier for the provider",
+          "examples": [
+            "openai"
+          ]
+        },
+        "name": {
+          "type": "string",
+          "description": "Human-readable name for the provider",
+          "examples": [
+            "OpenAI"
+          ]
+        },
+        "base_url": {
+          "type": "string",
+          "format": "uri",
+          "description": "Base URL for the provider's API",
+          "examples": [
+            "https://api.openai.com/v1"
+          ]
+        },
+        "type": {
+          "type": "string",
+          "enum": [
+            "openai",
+            "openai-compat",
+            "anthropic",
+            "gemini",
+            "azure",
+            "vertexai"
+          ],
+          "description": "Provider type that determines the API format",
+          "default": "openai"
+        },
+        "api_key": {
+          "type": "string",
+          "description": "API key for authentication with the provider",
+          "examples": [
+            "$OPENAI_API_KEY"
+          ]
+        },
+        "oauth": {
+          "$ref": "#/$defs/Token",
+          "description": "OAuth2 token for authentication with the provider"
+        },
+        "disable": {
+          "type": "boolean",
+          "description": "Whether this provider is disabled",
+          "default": false
+        },
+        "system_prompt_prefix": {
+          "type": "string",
+          "description": "Custom prefix to add to system prompts for this provider"
+        },
+        "extra_headers": {
+          "additionalProperties": {
+            "type": "string"
+          },
+          "type": "object",
+          "description": "Additional HTTP headers to send with requests"
+        },
+        "extra_body": {
+          "type": "object",
+          "description": "Additional fields to include in request bodies"
+        },
+        "provider_options": {
+          "type": "object",
+          "description": "Additional provider-specific options for this provider"
+        },
+        "models": {
+          "items": {
+            "$ref": "#/$defs/Model"
+          },
+          "type": "array",
+          "description": "List of models available from this provider"
+        }
+      },
+      "additionalProperties": false,
+      "type": "object"
+    },
     "SelectedModel": {
       "properties": {
         "model": {
@@ -515,6 +681,30 @@
         "completions"
       ]
     },
+    "Token": {
+      "properties": {
+        "access_token": {
+          "type": "string"
+        },
+        "refresh_token": {
+          "type": "string"
+        },
+        "expires_in": {
+          "type": "integer"
+        },
+        "expires_at": {
+          "type": "integer"
+        }
+      },
+      "additionalProperties": false,
+      "type": "object",
+      "required": [
+        "access_token",
+        "refresh_token",
+        "expires_in",
+        "expires_at"
+      ]
+    },
     "ToolGrep": {
       "properties": {
         "timeout": {