diff options
| author | Kujtim Hoxha <[email protected]> | 2025-04-21 19:59:35 +0200 |
|---|---|---|
| committer | GitHub <[email protected]> | 2025-04-21 19:59:35 +0200 |
| commit | f33dff87725764af0b675b5e5b2e011b21c14c90 (patch) | |
| tree | 4fe2c022305f13775f2cab3cdd80cd808259765b /cmd | |
| parent | 6b1c64bcc75b89c530294b6a2d4404682b435d56 (diff) | |
| parent | 3a6a26981a8074b6ab0eaadb520db986e04799ff (diff) | |
| download | opencode-f33dff87725764af0b675b5e5b2e011b21c14c90.tar.gz opencode-f33dff87725764af0b675b5e5b2e011b21c14c90.zip | |
Merge pull request #27 from kujtimiihoxha/opencode
OpenCode - Initial Implementation
Diffstat (limited to 'cmd')
| -rw-r--r-- | cmd/root.go | 273 | ||||
| -rw-r--r-- | cmd/schema/README.md | 64 | ||||
| -rw-r--r-- | cmd/schema/main.go | 262 |
3 files changed, 520 insertions, 79 deletions
diff --git a/cmd/root.go b/cmd/root.go index bdab53e14..8777acb82 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,126 +2,240 @@ package cmd import ( "context" - "log/slog" + "fmt" "os" "sync" + "time" tea "github.com/charmbracelet/bubbletea" - "github.com/kujtimiihoxha/termai/internal/app" - "github.com/kujtimiihoxha/termai/internal/config" - "github.com/kujtimiihoxha/termai/internal/db" - "github.com/kujtimiihoxha/termai/internal/llm/agent" - "github.com/kujtimiihoxha/termai/internal/logging" - "github.com/kujtimiihoxha/termai/internal/tui" + "github.com/kujtimiihoxha/opencode/internal/app" + "github.com/kujtimiihoxha/opencode/internal/config" + "github.com/kujtimiihoxha/opencode/internal/db" + "github.com/kujtimiihoxha/opencode/internal/llm/agent" + "github.com/kujtimiihoxha/opencode/internal/logging" + "github.com/kujtimiihoxha/opencode/internal/pubsub" + "github.com/kujtimiihoxha/opencode/internal/tui" zone "github.com/lrstanley/bubblezone" "github.com/spf13/cobra" ) var rootCmd = &cobra.Command{ - Use: "termai", - Short: "A terminal ai assistant", - Long: `A terminal ai assistant`, + Use: "OpenCode", + Short: "A terminal AI assistant for software development", + Long: `OpenCode is a powerful terminal-based AI assistant that helps with software development tasks. +It provides an interactive chat interface with AI capabilities, code analysis, and LSP integration +to assist developers in writing, debugging, and understanding code directly from the terminal.`, RunE: func(cmd *cobra.Command, args []string) error { + // If the help flag is set, show the help message if cmd.Flag("help").Changed { cmd.Help() return nil } + + // Load the config debug, _ := cmd.Flags().GetBool("debug") - err := config.Load(debug) - cfg := config.Get() - defaultLevel := slog.LevelInfo - if cfg.Debug { - defaultLevel = slog.LevelDebug + cwd, _ := cmd.Flags().GetString("cwd") + if cwd != "" { + err := os.Chdir(cwd) + if err != nil { + return fmt.Errorf("failed to change directory: %v", err) + } } - logger := slog.New(slog.NewTextHandler(logging.NewWriter(), &slog.HandlerOptions{ - Level: defaultLevel, - })) - slog.SetDefault(logger) - + if cwd == "" { + c, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get current working directory: %v", err) + } + cwd = c + } + _, err := config.Load(cwd, debug) if err != nil { return err } + + // Connect DB, this will also run migrations conn, err := db.Connect() if err != nil { return err } - ctx := context.Background() - app := app.New(ctx, conn) - logging.Info("Starting termai...") + // Create main context for the application + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + app, err := app.New(ctx, conn) + if err != nil { + logging.Error("Failed to create app: %v", err) + return err + } + + // Set up the TUI zone.NewGlobal() - tui := tea.NewProgram( + program := tea.NewProgram( tui.New(app), tea.WithAltScreen(), tea.WithMouseCellMotion(), ) - logging.Info("Setting up subscriptions...") - ch, unsub := setupSubscriptions(app) - defer unsub() + // Initialize MCP tools in the background + initMCPTools(ctx, app) + + // Setup the subscriptions, this will send services events to the TUI + ch, cancelSubs := setupSubscriptions(app, ctx) + + // Create a context for the TUI message handler + tuiCtx, tuiCancel := context.WithCancel(ctx) + var tuiWg sync.WaitGroup + tuiWg.Add(1) + + // Set up message handling for the TUI go func() { - // Set this up once - agent.GetMcpTools(ctx, app.Permissions) - for msg := range ch { - tui.Send(msg) + defer tuiWg.Done() + defer logging.RecoverPanic("TUI-message-handler", func() { + attemptTUIRecovery(program) + }) + + for { + select { + case <-tuiCtx.Done(): + logging.Info("TUI message handler shutting down") + return + case msg, ok := <-ch: + if !ok { + logging.Info("TUI message channel closed") + return + } + program.Send(msg) + } } }() - if _, err := tui.Run(); err != nil { - return err + + // Cleanup function for when the program exits + cleanup := func() { + // Shutdown the app + app.Shutdown() + + // Cancel subscriptions first + cancelSubs() + + // Then cancel TUI message handler + tuiCancel() + + // Wait for TUI message handler to finish + tuiWg.Wait() + + logging.Info("All goroutines cleaned up") } + + // Run the TUI + result, err := program.Run() + cleanup() + + if err != nil { + logging.Error("TUI error: %v", err) + return fmt.Errorf("TUI error: %v", err) + } + + logging.Info("TUI exited with result: %v", result) return nil }, } -func setupSubscriptions(app *app.App) (chan tea.Msg, func()) { - ch := make(chan tea.Msg) - wg := sync.WaitGroup{} - ctx, cancel := context.WithCancel(app.Context) - { - sub := logging.Subscribe(ctx) - wg.Add(1) - go func() { - for ev := range sub { - ch <- ev - } - wg.Done() - }() - } - { - sub := app.Sessions.Subscribe(ctx) - wg.Add(1) - go func() { - for ev := range sub { - ch <- ev - } - wg.Done() - }() - } - { - sub := app.Messages.Subscribe(ctx) - wg.Add(1) - go func() { - for ev := range sub { - ch <- ev +// attemptTUIRecovery tries to recover the TUI after a panic +func attemptTUIRecovery(program *tea.Program) { + logging.Info("Attempting to recover TUI after panic") + + // We could try to restart the TUI or gracefully exit + // For now, we'll just quit the program to avoid further issues + program.Quit() +} + +func initMCPTools(ctx context.Context, app *app.App) { + go func() { + defer logging.RecoverPanic("MCP-goroutine", nil) + + // Create a context with timeout for the initial MCP tools fetch + ctxWithTimeout, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + // Set this up once with proper error handling + agent.GetMcpTools(ctxWithTimeout, app.Permissions) + logging.Info("MCP message handling goroutine exiting") + }() +} + +func setupSubscriber[T any]( + ctx context.Context, + wg *sync.WaitGroup, + name string, + subscriber func(context.Context) <-chan pubsub.Event[T], + outputCh chan<- tea.Msg, +) { + wg.Add(1) + go func() { + defer wg.Done() + defer logging.RecoverPanic(fmt.Sprintf("subscription-%s", name), nil) + + subCh := subscriber(ctx) + + for { + select { + case event, ok := <-subCh: + if !ok { + logging.Info("subscription channel closed", "name", name) + return + } + + var msg tea.Msg = event + + select { + case outputCh <- msg: + case <-time.After(2 * time.Second): + logging.Warn("message dropped due to slow consumer", "name", name) + case <-ctx.Done(): + logging.Info("subscription cancelled", "name", name) + return + } + case <-ctx.Done(): + logging.Info("subscription cancelled", "name", name) + return } - wg.Done() - }() - } - { - sub := app.Permissions.Subscribe(ctx) - wg.Add(1) + } + }() +} + +func setupSubscriptions(app *app.App, parentCtx context.Context) (chan tea.Msg, func()) { + ch := make(chan tea.Msg, 100) + + wg := sync.WaitGroup{} + ctx, cancel := context.WithCancel(parentCtx) // Inherit from parent context + + setupSubscriber(ctx, &wg, "logging", logging.Subscribe, ch) + setupSubscriber(ctx, &wg, "sessions", app.Sessions.Subscribe, ch) + setupSubscriber(ctx, &wg, "messages", app.Messages.Subscribe, ch) + setupSubscriber(ctx, &wg, "permissions", app.Permissions.Subscribe, ch) + + cleanupFunc := func() { + logging.Info("Cancelling all subscriptions") + cancel() // Signal all goroutines to stop + + waitCh := make(chan struct{}) go func() { - for ev := range sub { - ch <- ev - } - wg.Done() + defer logging.RecoverPanic("subscription-cleanup", nil) + wg.Wait() + close(waitCh) }() + + select { + case <-waitCh: + logging.Info("All subscription goroutines completed successfully") + close(ch) // Only close after all writers are confirmed done + case <-time.After(5 * time.Second): + logging.Warn("Timed out waiting for some subscription goroutines to complete") + close(ch) + } } - return ch, func() { - cancel() - wg.Wait() - close(ch) - } + return ch, cleanupFunc } func Execute() { @@ -133,5 +247,6 @@ func Execute() { func init() { rootCmd.Flags().BoolP("help", "h", false, "Help") - rootCmd.Flags().BoolP("debug", "d", false, "Help") + rootCmd.Flags().BoolP("debug", "d", false, "Debug") + rootCmd.Flags().StringP("cwd", "c", "", "Current working directory") } diff --git a/cmd/schema/README.md b/cmd/schema/README.md new file mode 100644 index 000000000..93ebe9f03 --- /dev/null +++ b/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 + } + } +} +```
\ No newline at end of file diff --git a/cmd/schema/main.go b/cmd/schema/main.go new file mode 100644 index 000000000..030c0907e --- /dev/null +++ b/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 +} + |
