config validation

Kujtim Hoxha created

Change summary

.opencode.json                    |   1 
cmd/schema/README.md              |  64 +++++++
cmd/schema/main.go                | 262 +++++++++++++++++++++++++++++++
internal/config/config.go         | 276 ++++++++++++++++++++++++++++++++
internal/llm/agent/agent.go       |   2 
internal/llm/tools/edit.go        |  27 ++
internal/llm/tools/write.go       |  11 +
internal/permission/permission.go |  10 
internal/tui/tui.go               |   4 
internal/version/version.go       |   2 
opencode-schema.json              | 269 ++++++++++++++++++++++++++++++++
11 files changed, 911 insertions(+), 17 deletions(-)

Detailed changes

.opencode.json 🔗

@@ -1,4 +1,5 @@
 {
+  "$schema": "./opencode-schema.json",
   "lsp": {
     "gopls": {
       "command": "gopls"

cmd/schema/README.md 🔗

@@ -0,0 +1,64 @@
+# OpenCode Configuration Schema Generator
+
+This tool generates a JSON Schema for the OpenCode configuration file. The schema can be used to validate configuration files and provide autocompletion in editors that support JSON Schema.
+
+## Usage
+
+```bash
+go run cmd/schema/main.go > opencode-schema.json
+```
+
+This will generate a JSON Schema file that can be used to validate configuration files.
+
+## Schema Features
+
+The generated schema includes:
+
+- All configuration options with descriptions
+- Default values where applicable
+- Validation for enum values (e.g., model IDs, provider types)
+- Required fields
+- Type checking
+
+## Using the Schema
+
+You can use the generated schema in several ways:
+
+1. **Editor Integration**: Many editors (VS Code, JetBrains IDEs, etc.) support JSON Schema for validation and autocompletion. You can configure your editor to use the generated schema for `.opencode.json` files.
+
+2. **Validation Tools**: You can use tools like [jsonschema](https://github.com/Julian/jsonschema) to validate your configuration files against the schema.
+
+3. **Documentation**: The schema serves as documentation for the configuration options.
+
+## Example Configuration
+
+Here's an example configuration that conforms to the schema:
+
+```json
+{
+  "data": {
+    "directory": ".opencode"
+  },
+  "debug": false,
+  "providers": {
+    "anthropic": {
+      "apiKey": "your-api-key"
+    }
+  },
+  "agents": {
+    "coder": {
+      "model": "claude-3.7-sonnet",
+      "maxTokens": 5000,
+      "reasoningEffort": "medium"
+    },
+    "task": {
+      "model": "claude-3.7-sonnet",
+      "maxTokens": 5000
+    },
+    "title": {
+      "model": "claude-3.7-sonnet",
+      "maxTokens": 80
+    }
+  }
+}
+```

cmd/schema/main.go 🔗

@@ -0,0 +1,262 @@
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+
+	"github.com/kujtimiihoxha/opencode/internal/config"
+	"github.com/kujtimiihoxha/opencode/internal/llm/models"
+)
+
+// JSONSchemaType represents a JSON Schema type
+type JSONSchemaType struct {
+	Type                 string           `json:"type,omitempty"`
+	Description          string           `json:"description,omitempty"`
+	Properties           map[string]any   `json:"properties,omitempty"`
+	Required             []string         `json:"required,omitempty"`
+	AdditionalProperties any              `json:"additionalProperties,omitempty"`
+	Enum                 []any            `json:"enum,omitempty"`
+	Items                map[string]any   `json:"items,omitempty"`
+	OneOf                []map[string]any `json:"oneOf,omitempty"`
+	AnyOf                []map[string]any `json:"anyOf,omitempty"`
+	Default              any              `json:"default,omitempty"`
+}
+
+func main() {
+	schema := generateSchema()
+
+	// Pretty print the schema
+	encoder := json.NewEncoder(os.Stdout)
+	encoder.SetIndent("", "  ")
+	if err := encoder.Encode(schema); err != nil {
+		fmt.Fprintf(os.Stderr, "Error encoding schema: %v\n", err)
+		os.Exit(1)
+	}
+}
+
+func generateSchema() map[string]any {
+	schema := map[string]any{
+		"$schema":     "http://json-schema.org/draft-07/schema#",
+		"title":       "OpenCode Configuration",
+		"description": "Configuration schema for the OpenCode application",
+		"type":        "object",
+		"properties":  map[string]any{},
+	}
+
+	// Add Data configuration
+	schema["properties"].(map[string]any)["data"] = map[string]any{
+		"type":        "object",
+		"description": "Storage configuration",
+		"properties": map[string]any{
+			"directory": map[string]any{
+				"type":        "string",
+				"description": "Directory where application data is stored",
+				"default":     ".opencode",
+			},
+		},
+		"required": []string{"directory"},
+	}
+
+	// Add working directory
+	schema["properties"].(map[string]any)["wd"] = map[string]any{
+		"type":        "string",
+		"description": "Working directory for the application",
+	}
+
+	// Add debug flags
+	schema["properties"].(map[string]any)["debug"] = map[string]any{
+		"type":        "boolean",
+		"description": "Enable debug mode",
+		"default":     false,
+	}
+
+	schema["properties"].(map[string]any)["debugLSP"] = map[string]any{
+		"type":        "boolean",
+		"description": "Enable LSP debug mode",
+		"default":     false,
+	}
+
+	// Add MCP servers
+	schema["properties"].(map[string]any)["mcpServers"] = map[string]any{
+		"type":        "object",
+		"description": "Model Control Protocol server configurations",
+		"additionalProperties": map[string]any{
+			"type":        "object",
+			"description": "MCP server configuration",
+			"properties": map[string]any{
+				"command": map[string]any{
+					"type":        "string",
+					"description": "Command to execute for the MCP server",
+				},
+				"env": map[string]any{
+					"type":        "array",
+					"description": "Environment variables for the MCP server",
+					"items": map[string]any{
+						"type": "string",
+					},
+				},
+				"args": map[string]any{
+					"type":        "array",
+					"description": "Command arguments for the MCP server",
+					"items": map[string]any{
+						"type": "string",
+					},
+				},
+				"type": map[string]any{
+					"type":        "string",
+					"description": "Type of MCP server",
+					"enum":        []string{"stdio", "sse"},
+					"default":     "stdio",
+				},
+				"url": map[string]any{
+					"type":        "string",
+					"description": "URL for SSE type MCP servers",
+				},
+				"headers": map[string]any{
+					"type":        "object",
+					"description": "HTTP headers for SSE type MCP servers",
+					"additionalProperties": map[string]any{
+						"type": "string",
+					},
+				},
+			},
+			"required": []string{"command"},
+		},
+	}
+
+	// Add providers
+	providerSchema := map[string]any{
+		"type":        "object",
+		"description": "LLM provider configurations",
+		"additionalProperties": map[string]any{
+			"type":        "object",
+			"description": "Provider configuration",
+			"properties": map[string]any{
+				"apiKey": map[string]any{
+					"type":        "string",
+					"description": "API key for the provider",
+				},
+				"disabled": map[string]any{
+					"type":        "boolean",
+					"description": "Whether the provider is disabled",
+					"default":     false,
+				},
+			},
+		},
+	}
+
+	// Add known providers
+	knownProviders := []string{
+		string(models.ProviderAnthropic),
+		string(models.ProviderOpenAI),
+		string(models.ProviderGemini),
+		string(models.ProviderGROQ),
+		string(models.ProviderBedrock),
+	}
+
+	providerSchema["additionalProperties"].(map[string]any)["properties"].(map[string]any)["provider"] = map[string]any{
+		"type":        "string",
+		"description": "Provider type",
+		"enum":        knownProviders,
+	}
+
+	schema["properties"].(map[string]any)["providers"] = providerSchema
+
+	// Add agents
+	agentSchema := map[string]any{
+		"type":        "object",
+		"description": "Agent configurations",
+		"additionalProperties": map[string]any{
+			"type":        "object",
+			"description": "Agent configuration",
+			"properties": map[string]any{
+				"model": map[string]any{
+					"type":        "string",
+					"description": "Model ID for the agent",
+				},
+				"maxTokens": map[string]any{
+					"type":        "integer",
+					"description": "Maximum tokens for the agent",
+					"minimum":     1,
+				},
+				"reasoningEffort": map[string]any{
+					"type":        "string",
+					"description": "Reasoning effort for models that support it (OpenAI, Anthropic)",
+					"enum":        []string{"low", "medium", "high"},
+				},
+			},
+			"required": []string{"model"},
+		},
+	}
+
+	// Add model enum
+	modelEnum := []string{}
+	for modelID := range models.SupportedModels {
+		modelEnum = append(modelEnum, string(modelID))
+	}
+	agentSchema["additionalProperties"].(map[string]any)["properties"].(map[string]any)["model"].(map[string]any)["enum"] = modelEnum
+
+	// Add specific agent properties
+	agentProperties := map[string]any{}
+	knownAgents := []string{
+		string(config.AgentCoder),
+		string(config.AgentTask),
+		string(config.AgentTitle),
+	}
+
+	for _, agentName := range knownAgents {
+		agentProperties[agentName] = map[string]any{
+			"$ref": "#/definitions/agent",
+		}
+	}
+
+	// Create a combined schema that allows both specific agents and additional ones
+	combinedAgentSchema := map[string]any{
+		"type":                 "object",
+		"description":          "Agent configurations",
+		"properties":           agentProperties,
+		"additionalProperties": agentSchema["additionalProperties"],
+	}
+
+	schema["properties"].(map[string]any)["agents"] = combinedAgentSchema
+	schema["definitions"] = map[string]any{
+		"agent": agentSchema["additionalProperties"],
+	}
+
+	// Add LSP configuration
+	schema["properties"].(map[string]any)["lsp"] = map[string]any{
+		"type":        "object",
+		"description": "Language Server Protocol configurations",
+		"additionalProperties": map[string]any{
+			"type":        "object",
+			"description": "LSP configuration for a language",
+			"properties": map[string]any{
+				"disabled": map[string]any{
+					"type":        "boolean",
+					"description": "Whether the LSP is disabled",
+					"default":     false,
+				},
+				"command": map[string]any{
+					"type":        "string",
+					"description": "Command to execute for the LSP server",
+				},
+				"args": map[string]any{
+					"type":        "array",
+					"description": "Command arguments for the LSP server",
+					"items": map[string]any{
+						"type": "string",
+					},
+				},
+				"options": map[string]any{
+					"type":        "object",
+					"description": "Additional options for the LSP server",
+				},
+			},
+			"required": []string{"command"},
+		},
+	}
+
+	return schema
+}
+

internal/config/config.go 🔗

@@ -120,13 +120,11 @@ func Load(workingDir string, debug bool) (*Config, error) {
 	}
 
 	applyDefaultValues()
-
 	defaultLevel := slog.LevelInfo
 	if cfg.Debug {
 		defaultLevel = slog.LevelDebug
 	}
-	// if we are in debug mode make the writer a file
-	if cfg.Debug {
+	if os.Getenv("OPENCODE_DEV_DEBUG") == "true" {
 		loggingFile := fmt.Sprintf("%s/%s", cfg.Data.Directory, "debug.log")
 
 		// if file does not exist create it
@@ -156,6 +154,11 @@ func Load(workingDir string, debug bool) (*Config, error) {
 		slog.SetDefault(logger)
 	}
 
+	// Validate configuration
+	if err := Validate(); err != nil {
+		return cfg, fmt.Errorf("config validation failed: %w", err)
+	}
+
 	if cfg.Agents == nil {
 		cfg.Agents = make(map[AgentName]Agent)
 	}
@@ -302,6 +305,273 @@ func applyDefaultValues() {
 	}
 }
 
+// Validate checks if the configuration is valid and applies defaults where needed.
+// It validates model IDs and providers, ensuring they are supported.
+func Validate() error {
+	if cfg == nil {
+		return fmt.Errorf("config not loaded")
+	}
+
+	// Validate agent models
+	for name, agent := range cfg.Agents {
+		// Check if model exists
+		model, modelExists := models.SupportedModels[agent.Model]
+		if !modelExists {
+			logging.Warn("unsupported model configured, reverting to default",
+				"agent", name,
+				"configured_model", agent.Model)
+
+			// Set default model based on available providers
+			if setDefaultModelForAgent(name) {
+				logging.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model)
+			} else {
+				return fmt.Errorf("no valid provider available for agent %s", name)
+			}
+			continue
+		}
+
+		// Check if provider for the model is configured
+		provider := model.Provider
+		providerCfg, providerExists := cfg.Providers[provider]
+
+		if !providerExists {
+			// Provider not configured, check if we have environment variables
+			apiKey := getProviderAPIKey(provider)
+			if apiKey == "" {
+				logging.Warn("provider not configured for model, reverting to default",
+					"agent", name,
+					"model", agent.Model,
+					"provider", provider)
+
+				// Set default model based on available providers
+				if setDefaultModelForAgent(name) {
+					logging.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model)
+				} else {
+					return fmt.Errorf("no valid provider available for agent %s", name)
+				}
+			} else {
+				// Add provider with API key from environment
+				cfg.Providers[provider] = Provider{
+					APIKey: apiKey,
+				}
+				logging.Info("added provider from environment", "provider", provider)
+			}
+		} else if providerCfg.Disabled || providerCfg.APIKey == "" {
+			// Provider is disabled or has no API key
+			logging.Warn("provider is disabled or has no API key, reverting to default",
+				"agent", name,
+				"model", agent.Model,
+				"provider", provider)
+
+			// Set default model based on available providers
+			if setDefaultModelForAgent(name) {
+				logging.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model)
+			} else {
+				return fmt.Errorf("no valid provider available for agent %s", name)
+			}
+		}
+
+		// Validate max tokens
+		if agent.MaxTokens <= 0 {
+			logging.Warn("invalid max tokens, setting to default",
+				"agent", name,
+				"model", agent.Model,
+				"max_tokens", agent.MaxTokens)
+
+			// Update the agent with default max tokens
+			updatedAgent := cfg.Agents[name]
+			if model.DefaultMaxTokens > 0 {
+				updatedAgent.MaxTokens = model.DefaultMaxTokens
+			} else {
+				updatedAgent.MaxTokens = 4096 // Fallback default
+			}
+			cfg.Agents[name] = updatedAgent
+		} else if model.ContextWindow > 0 && agent.MaxTokens > model.ContextWindow/2 {
+			// Ensure max tokens doesn't exceed half the context window (reasonable limit)
+			logging.Warn("max tokens exceeds half the context window, adjusting",
+				"agent", name,
+				"model", agent.Model,
+				"max_tokens", agent.MaxTokens,
+				"context_window", model.ContextWindow)
+
+			// Update the agent with adjusted max tokens
+			updatedAgent := cfg.Agents[name]
+			updatedAgent.MaxTokens = model.ContextWindow / 2
+			cfg.Agents[name] = updatedAgent
+		}
+
+		// Validate reasoning effort for models that support reasoning
+		if model.CanReason && provider == models.ProviderOpenAI {
+			if agent.ReasoningEffort == "" {
+				// Set default reasoning effort for models that support it
+				logging.Info("setting default reasoning effort for model that supports reasoning",
+					"agent", name,
+					"model", agent.Model)
+
+				// Update the agent with default reasoning effort
+				updatedAgent := cfg.Agents[name]
+				updatedAgent.ReasoningEffort = "medium"
+				cfg.Agents[name] = updatedAgent
+			} else {
+				// Check if reasoning effort is valid (low, medium, high)
+				effort := strings.ToLower(agent.ReasoningEffort)
+				if effort != "low" && effort != "medium" && effort != "high" {
+					logging.Warn("invalid reasoning effort, setting to medium",
+						"agent", name,
+						"model", agent.Model,
+						"reasoning_effort", agent.ReasoningEffort)
+
+					// Update the agent with valid reasoning effort
+					updatedAgent := cfg.Agents[name]
+					updatedAgent.ReasoningEffort = "medium"
+					cfg.Agents[name] = updatedAgent
+				}
+			}
+		} else if !model.CanReason && agent.ReasoningEffort != "" {
+			// Model doesn't support reasoning but reasoning effort is set
+			logging.Warn("model doesn't support reasoning but reasoning effort is set, ignoring",
+				"agent", name,
+				"model", agent.Model,
+				"reasoning_effort", agent.ReasoningEffort)
+
+			// Update the agent to remove reasoning effort
+			updatedAgent := cfg.Agents[name]
+			updatedAgent.ReasoningEffort = ""
+			cfg.Agents[name] = updatedAgent
+		}
+	}
+
+	// Validate providers
+	for provider, providerCfg := range cfg.Providers {
+		if providerCfg.APIKey == "" && !providerCfg.Disabled {
+			logging.Warn("provider has no API key, marking as disabled", "provider", provider)
+			providerCfg.Disabled = true
+			cfg.Providers[provider] = providerCfg
+		}
+	}
+
+	// Validate LSP configurations
+	for language, lspConfig := range cfg.LSP {
+		if lspConfig.Command == "" && !lspConfig.Disabled {
+			logging.Warn("LSP configuration has no command, marking as disabled", "language", language)
+			lspConfig.Disabled = true
+			cfg.LSP[language] = lspConfig
+		}
+	}
+
+	return nil
+}
+
+// getProviderAPIKey gets the API key for a provider from environment variables
+func getProviderAPIKey(provider models.ModelProvider) string {
+	switch provider {
+	case models.ProviderAnthropic:
+		return os.Getenv("ANTHROPIC_API_KEY")
+	case models.ProviderOpenAI:
+		return os.Getenv("OPENAI_API_KEY")
+	case models.ProviderGemini:
+		return os.Getenv("GEMINI_API_KEY")
+	case models.ProviderGROQ:
+		return os.Getenv("GROQ_API_KEY")
+	case models.ProviderBedrock:
+		if hasAWSCredentials() {
+			return "aws-credentials-available"
+		}
+	}
+	return ""
+}
+
+// setDefaultModelForAgent sets a default model for an agent based on available providers
+func setDefaultModelForAgent(agent AgentName) bool {
+	// Check providers in order of preference
+	if apiKey := os.Getenv("ANTHROPIC_API_KEY"); apiKey != "" {
+		maxTokens := int64(5000)
+		if agent == AgentTitle {
+			maxTokens = 80
+		}
+		cfg.Agents[agent] = Agent{
+			Model:     models.Claude37Sonnet,
+			MaxTokens: maxTokens,
+		}
+		return true
+	}
+
+	if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" {
+		var model models.ModelID
+		maxTokens := int64(5000)
+		reasoningEffort := ""
+
+		switch agent {
+		case AgentTitle:
+			model = models.GPT41Mini
+			maxTokens = 80
+		case AgentTask:
+			model = models.GPT41Mini
+		default:
+			model = models.GPT41
+		}
+
+		// Check if model supports reasoning
+		if modelInfo, ok := models.SupportedModels[model]; ok && modelInfo.CanReason {
+			reasoningEffort = "medium"
+		}
+
+		cfg.Agents[agent] = Agent{
+			Model:           model,
+			MaxTokens:       maxTokens,
+			ReasoningEffort: reasoningEffort,
+		}
+		return true
+	}
+
+	if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
+		var model models.ModelID
+		maxTokens := int64(5000)
+
+		if agent == AgentTitle {
+			model = models.Gemini25Flash
+			maxTokens = 80
+		} else {
+			model = models.Gemini25
+		}
+
+		cfg.Agents[agent] = Agent{
+			Model:     model,
+			MaxTokens: maxTokens,
+		}
+		return true
+	}
+
+	if apiKey := os.Getenv("GROQ_API_KEY"); apiKey != "" {
+		maxTokens := int64(5000)
+		if agent == AgentTitle {
+			maxTokens = 80
+		}
+
+		cfg.Agents[agent] = Agent{
+			Model:     models.QWENQwq,
+			MaxTokens: maxTokens,
+		}
+		return true
+	}
+
+	if hasAWSCredentials() {
+		maxTokens := int64(5000)
+		if agent == AgentTitle {
+			maxTokens = 80
+		}
+
+		cfg.Agents[agent] = Agent{
+			Model:           models.BedrockClaude37Sonnet,
+			MaxTokens:       maxTokens,
+			ReasoningEffort: "medium", // Claude models support reasoning
+		}
+		return true
+	}
+
+	return false
+}
+
 // Get returns the current configuration.
 // It's safe to call this function multiple times.
 func Get() *Config {

internal/llm/agent/agent.go 🔗

@@ -471,7 +471,7 @@ func createAgentProvider(agentName config.AgentName) (provider.Provider, error)
 				provider.WithReasoningEffort(agentConfig.ReasoningEffort),
 			),
 		)
-	} else if model.Provider == models.ProviderAnthropic && model.CanReason {
+	} else if model.Provider == models.ProviderAnthropic && model.CanReason && agentName == config.AgentCoder {
 		opts = append(
 			opts,
 			provider.WithAnthropicOptions(

internal/llm/tools/edit.go 🔗

@@ -196,11 +196,16 @@ func (e *editTool) createNewFile(ctx context.Context, filePath, content string)
 		content,
 		filePath,
 	)
+	rootDir := config.WorkingDirectory()
+	permissionPath := filepath.Dir(filePath)
+	if strings.HasPrefix(filePath, rootDir) {
+		permissionPath = rootDir
+	}
 	p := e.permissions.Request(
 		permission.CreatePermissionRequest{
-			Path:        filepath.Dir(filePath),
+			Path:        permissionPath,
 			ToolName:    EditToolName,
-			Action:      "create",
+			Action:      "write",
 			Description: fmt.Sprintf("Create file %s", filePath),
 			Params: EditPermissionsParams{
 				FilePath: filePath,
@@ -301,11 +306,16 @@ func (e *editTool) deleteContent(ctx context.Context, filePath, oldString string
 		filePath,
 	)
 
+	rootDir := config.WorkingDirectory()
+	permissionPath := filepath.Dir(filePath)
+	if strings.HasPrefix(filePath, rootDir) {
+		permissionPath = rootDir
+	}
 	p := e.permissions.Request(
 		permission.CreatePermissionRequest{
-			Path:        filepath.Dir(filePath),
+			Path:        permissionPath,
 			ToolName:    EditToolName,
-			Action:      "delete",
+			Action:      "write",
 			Description: fmt.Sprintf("Delete content from file %s", filePath),
 			Params: EditPermissionsParams{
 				FilePath: filePath,
@@ -415,11 +425,16 @@ func (e *editTool) replaceContent(ctx context.Context, filePath, oldString, newS
 		newContent,
 		filePath,
 	)
+	rootDir := config.WorkingDirectory()
+	permissionPath := filepath.Dir(filePath)
+	if strings.HasPrefix(filePath, rootDir) {
+		permissionPath = rootDir
+	}
 	p := e.permissions.Request(
 		permission.CreatePermissionRequest{
-			Path:        filepath.Dir(filePath),
+			Path:        permissionPath,
 			ToolName:    EditToolName,
-			Action:      "replace",
+			Action:      "write",
 			Description: fmt.Sprintf("Replace content in file %s", filePath),
 			Params: EditPermissionsParams{
 				FilePath: filePath,

internal/llm/tools/write.go 🔗

@@ -6,6 +6,7 @@ import (
 	"fmt"
 	"os"
 	"path/filepath"
+	"strings"
 	"time"
 
 	"github.com/kujtimiihoxha/opencode/internal/config"
@@ -159,11 +160,17 @@ func (w *writeTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
 		params.Content,
 		filePath,
 	)
+
+	rootDir := config.WorkingDirectory()
+	permissionPath := filepath.Dir(filePath)
+	if strings.HasPrefix(filePath, rootDir) {
+		permissionPath = rootDir
+	}
 	p := w.permissions.Request(
 		permission.CreatePermissionRequest{
-			Path:        filePath,
+			Path:        permissionPath,
 			ToolName:    WriteToolName,
-			Action:      "create",
+			Action:      "write",
 			Description: fmt.Sprintf("Create file %s", filePath),
 			Params: WritePermissionsParams{
 				FilePath: filePath,

internal/permission/permission.go 🔗

@@ -2,10 +2,12 @@ package permission
 
 import (
 	"errors"
+	"path/filepath"
 	"sync"
 	"time"
 
 	"github.com/google/uuid"
+	"github.com/kujtimiihoxha/opencode/internal/config"
 	"github.com/kujtimiihoxha/opencode/internal/pubsub"
 )
 
@@ -67,9 +69,13 @@ func (s *permissionService) Deny(permission PermissionRequest) {
 }
 
 func (s *permissionService) Request(opts CreatePermissionRequest) bool {
+	dir := filepath.Dir(opts.Path)
+	if dir == "." {
+		dir = config.WorkingDirectory()
+	}
 	permission := PermissionRequest{
 		ID:          uuid.New().String(),
-		Path:        opts.Path,
+		Path:        dir,
 		ToolName:    opts.ToolName,
 		Description: opts.Description,
 		Action:      opts.Action,
@@ -77,7 +83,7 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool {
 	}
 
 	for _, p := range s.sessionPermissions {
-		if p.ToolName == permission.ToolName && p.Action == permission.Action {
+		if p.ToolName == permission.ToolName && p.Action == permission.Action && p.SessionID == permission.SessionID && p.Path == permission.Path {
 			return true
 		}
 	}

internal/tui/tui.go 🔗

@@ -57,8 +57,8 @@ var returnKey = key.NewBinding(
 )
 
 var logsKeyReturnKey = key.NewBinding(
-	key.WithKeys("backspace"),
-	key.WithHelp("backspace", "go back"),
+	key.WithKeys("backspace", "q"),
+	key.WithHelp("backspace/q", "go back"),
 )
 
 type appModel struct {

internal/version/version.go 🔗

@@ -5,7 +5,7 @@ import "runtime/debug"
 // Build-time parameters set via -ldflags
 var Version = "unknown"
 
-// A user may install pug using `go install github.com/leg100/pug@latest`
+// A user may install pug using `go install github.com/kujtimiihoxha/opencode@latest`.
 // without -ldflags, in which case the version above is unset. As a workaround
 // we use the embedded build version that *is* set when using `go install` (and
 // is only set for `go install` and not for `go build`).

opencode-schema.json 🔗

@@ -0,0 +1,269 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "definitions": {
+    "agent": {
+      "description": "Agent configuration",
+      "properties": {
+        "maxTokens": {
+          "description": "Maximum tokens for the agent",
+          "minimum": 1,
+          "type": "integer"
+        },
+        "model": {
+          "description": "Model ID for the agent",
+          "enum": [
+            "gemini-2.0-flash",
+            "bedrock.claude-3.7-sonnet",
+            "claude-3-opus",
+            "claude-3.5-sonnet",
+            "gpt-4o-mini",
+            "o1",
+            "o3-mini",
+            "o1-pro",
+            "o4-mini",
+            "claude-3-haiku",
+            "gpt-4o",
+            "o3",
+            "gpt-4.1-mini",
+            "gpt-4.5-preview",
+            "gemini-2.5-flash",
+            "claude-3.5-haiku",
+            "gpt-4.1",
+            "gemini-2.0-flash-lite",
+            "claude-3.7-sonnet",
+            "o1-mini",
+            "gpt-4.1-nano",
+            "gemini-2.5"
+          ],
+          "type": "string"
+        },
+        "reasoningEffort": {
+          "description": "Reasoning effort for models that support it (OpenAI, Anthropic)",
+          "enum": [
+            "low",
+            "medium",
+            "high"
+          ],
+          "type": "string"
+        }
+      },
+      "required": [
+        "model"
+      ],
+      "type": "object"
+    }
+  },
+  "description": "Configuration schema for the OpenCode application",
+  "properties": {
+    "agents": {
+      "additionalProperties": {
+        "description": "Agent configuration",
+        "properties": {
+          "maxTokens": {
+            "description": "Maximum tokens for the agent",
+            "minimum": 1,
+            "type": "integer"
+          },
+          "model": {
+            "description": "Model ID for the agent",
+            "enum": [
+              "gemini-2.0-flash",
+              "bedrock.claude-3.7-sonnet",
+              "claude-3-opus",
+              "claude-3.5-sonnet",
+              "gpt-4o-mini",
+              "o1",
+              "o3-mini",
+              "o1-pro",
+              "o4-mini",
+              "claude-3-haiku",
+              "gpt-4o",
+              "o3",
+              "gpt-4.1-mini",
+              "gpt-4.5-preview",
+              "gemini-2.5-flash",
+              "claude-3.5-haiku",
+              "gpt-4.1",
+              "gemini-2.0-flash-lite",
+              "claude-3.7-sonnet",
+              "o1-mini",
+              "gpt-4.1-nano",
+              "gemini-2.5"
+            ],
+            "type": "string"
+          },
+          "reasoningEffort": {
+            "description": "Reasoning effort for models that support it (OpenAI, Anthropic)",
+            "enum": [
+              "low",
+              "medium",
+              "high"
+            ],
+            "type": "string"
+          }
+        },
+        "required": [
+          "model"
+        ],
+        "type": "object"
+      },
+      "description": "Agent configurations",
+      "properties": {
+        "coder": {
+          "$ref": "#/definitions/agent"
+        },
+        "task": {
+          "$ref": "#/definitions/agent"
+        },
+        "title": {
+          "$ref": "#/definitions/agent"
+        }
+      },
+      "type": "object"
+    },
+    "data": {
+      "description": "Storage configuration",
+      "properties": {
+        "directory": {
+          "default": ".opencode",
+          "description": "Directory where application data is stored",
+          "type": "string"
+        }
+      },
+      "required": [
+        "directory"
+      ],
+      "type": "object"
+    },
+    "debug": {
+      "default": false,
+      "description": "Enable debug mode",
+      "type": "boolean"
+    },
+    "debugLSP": {
+      "default": false,
+      "description": "Enable LSP debug mode",
+      "type": "boolean"
+    },
+    "lsp": {
+      "additionalProperties": {
+        "description": "LSP configuration for a language",
+        "properties": {
+          "args": {
+            "description": "Command arguments for the LSP server",
+            "items": {
+              "type": "string"
+            },
+            "type": "array"
+          },
+          "command": {
+            "description": "Command to execute for the LSP server",
+            "type": "string"
+          },
+          "disabled": {
+            "default": false,
+            "description": "Whether the LSP is disabled",
+            "type": "boolean"
+          },
+          "options": {
+            "description": "Additional options for the LSP server",
+            "type": "object"
+          }
+        },
+        "required": [
+          "command"
+        ],
+        "type": "object"
+      },
+      "description": "Language Server Protocol configurations",
+      "type": "object"
+    },
+    "mcpServers": {
+      "additionalProperties": {
+        "description": "MCP server configuration",
+        "properties": {
+          "args": {
+            "description": "Command arguments for the MCP server",
+            "items": {
+              "type": "string"
+            },
+            "type": "array"
+          },
+          "command": {
+            "description": "Command to execute for the MCP server",
+            "type": "string"
+          },
+          "env": {
+            "description": "Environment variables for the MCP server",
+            "items": {
+              "type": "string"
+            },
+            "type": "array"
+          },
+          "headers": {
+            "additionalProperties": {
+              "type": "string"
+            },
+            "description": "HTTP headers for SSE type MCP servers",
+            "type": "object"
+          },
+          "type": {
+            "default": "stdio",
+            "description": "Type of MCP server",
+            "enum": [
+              "stdio",
+              "sse"
+            ],
+            "type": "string"
+          },
+          "url": {
+            "description": "URL for SSE type MCP servers",
+            "type": "string"
+          }
+        },
+        "required": [
+          "command"
+        ],
+        "type": "object"
+      },
+      "description": "Model Control Protocol server configurations",
+      "type": "object"
+    },
+    "providers": {
+      "additionalProperties": {
+        "description": "Provider configuration",
+        "properties": {
+          "apiKey": {
+            "description": "API key for the provider",
+            "type": "string"
+          },
+          "disabled": {
+            "default": false,
+            "description": "Whether the provider is disabled",
+            "type": "boolean"
+          },
+          "provider": {
+            "description": "Provider type",
+            "enum": [
+              "anthropic",
+              "openai",
+              "gemini",
+              "groq",
+              "bedrock"
+            ],
+            "type": "string"
+          }
+        },
+        "type": "object"
+      },
+      "description": "LLM provider configurations",
+      "type": "object"
+    },
+    "wd": {
+      "description": "Working directory for the application",
+      "type": "string"
+    }
+  },
+  "title": "OpenCode Configuration",
+  "type": "object"
+}