summaryrefslogtreecommitdiffhomepage
path: root/cmd
diff options
context:
space:
mode:
authorKujtim Hoxha <[email protected]>2025-04-21 19:59:35 +0200
committerGitHub <[email protected]>2025-04-21 19:59:35 +0200
commitf33dff87725764af0b675b5e5b2e011b21c14c90 (patch)
tree4fe2c022305f13775f2cab3cdd80cd808259765b /cmd
parent6b1c64bcc75b89c530294b6a2d4404682b435d56 (diff)
parent3a6a26981a8074b6ab0eaadb520db986e04799ff (diff)
downloadopencode-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.go273
-rw-r--r--cmd/schema/README.md64
-rw-r--r--cmd/schema/main.go262
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
+}
+