From 93230bf100c157ca4ec18c0234e1fa62f62c9629 Mon Sep 17 00:00:00 2001 From: Radu Topala Date: Wed, 30 Apr 2025 12:42:41 +0300 Subject: [PATCH] feat: non-interactive mode --- .gitignore | 1 + README.md | 40 +++++++++++++--- cmd/root.go | 57 ++++++++++++++++++++-- go.mod | 4 +- go.sum | 12 ++--- internal/app/app.go | 64 +++++++++++++++++++++++++ internal/format/format.go | 99 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 257 insertions(+), 20 deletions(-) create mode 100644 internal/format/format.go diff --git a/.gitignore b/.gitignore index 2603e63..36ff9c7 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,4 @@ Thumbs.db .opencode/ +opencode diff --git a/README.md b/README.md index e94c6cb..931bb41 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,6 @@ You can configure OpenCode using environment variables: | `AZURE_OPENAI_API_KEY` | For Azure OpenAI models (optional when using Entra ID) | | `AZURE_OPENAI_API_VERSION` | For Azure OpenAI models | - ### Configuration File Structure ```json @@ -187,7 +186,7 @@ OpenCode supports a variety of AI models from different providers: - O3 family (o3, o3-mini) - O4 Mini -## Usage +## Interactive Mode Usage ```bash # Start OpenCode @@ -200,13 +199,40 @@ opencode -d opencode -c /path/to/project ``` +## Non-interactive Prompt Mode + +You can run OpenCode in non-interactive mode by passing a prompt directly as a command-line argument. This is useful for scripting, automation, or when you want a quick answer without launching the full TUI. + +```bash +# Run a single prompt and print the AI's response to the terminal +opencode -p "Explain the use of context in Go" + +# Get response in JSON format +opencode -p "Explain the use of context in Go" -f json +``` + +In this mode, OpenCode will process your prompt, print the result to standard output, and then exit. All permissions are auto-approved for the session. + +### Output Formats + +OpenCode supports the following output formats in non-interactive mode: + +| Format | Description | +| ------ | -------------------------------------- | +| `text` | Plain text output (default) | +| `json` | Output wrapped in a JSON object | + +The output format is implemented as a strongly-typed `OutputFormat` in the codebase, ensuring type safety and validation when processing outputs. + ## Command-line Flags -| Flag | Short | Description | -| --------- | ----- | ----------------------------- | -| `--help` | `-h` | Display help information | -| `--debug` | `-d` | Enable debug mode | -| `--cwd` | `-c` | Set current working directory | +| Flag | Short | Description | +| ----------------- | ----- | ------------------------------------------------------ | +| `--help` | `-h` | Display help information | +| `--debug` | `-d` | Enable debug mode | +| `--cwd` | `-c` | Set current working directory | +| `--prompt` | `-p` | Run a single prompt in non-interactive mode | +| `--output-format` | `-f` | Output format for non-interactive mode (text, json) | ## Keyboard Shortcuts diff --git a/cmd/root.go b/cmd/root.go index ab81f71..a50caaf 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -12,6 +12,7 @@ import ( "github.com/opencode-ai/opencode/internal/app" "github.com/opencode-ai/opencode/internal/config" "github.com/opencode-ai/opencode/internal/db" + "github.com/opencode-ai/opencode/internal/format" "github.com/opencode-ai/opencode/internal/llm/agent" "github.com/opencode-ai/opencode/internal/logging" "github.com/opencode-ai/opencode/internal/pubsub" @@ -21,11 +22,30 @@ import ( ) var rootCmd = &cobra.Command{ - Use: "OpenCode", - Short: "A terminal AI assistant for software development", + Use: "opencode", + Short: "Terminal-based 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.`, + Example: ` + # Run in interactive mode + opencode + + # Run with debug logging + opencode -d + + # Run with debug logging in a specific directory + opencode -d -c /path/to/project + + # Print version + opencode -v + + # Run a single non-interactive prompt + opencode -p "Explain the use of context in Go" + + # Run a single non-interactive prompt with JSON output format + opencode -p "Explain the use of context in Go" -f json + `, RunE: func(cmd *cobra.Command, args []string) error { // If the help flag is set, show the help message if cmd.Flag("help").Changed { @@ -40,6 +60,14 @@ to assist developers in writing, debugging, and understanding code directly from // Load the config debug, _ := cmd.Flags().GetBool("debug") cwd, _ := cmd.Flags().GetString("cwd") + prompt, _ := cmd.Flags().GetString("prompt") + outputFormat, _ := cmd.Flags().GetString("output-format") + + // Validate format option + if !format.IsValid(outputFormat) { + return fmt.Errorf("invalid format option: %s\n%s", outputFormat, format.GetHelpText()) + } + if cwd != "" { err := os.Chdir(cwd) if err != nil { @@ -73,7 +101,19 @@ to assist developers in writing, debugging, and understanding code directly from logging.Error("Failed to create app: %v", err) return err } + // Defer shutdown here so it runs for both interactive and non-interactive modes + defer app.Shutdown() + // Initialize MCP tools early for both modes + initMCPTools(ctx, app) + + // Non-interactive mode + if prompt != "" { + // Run non-interactive flow using the App method + return app.RunNonInteractive(ctx, prompt, outputFormat) + } + + // Interactive mode // Set up the TUI zone.NewGlobal() program := tea.NewProgram( @@ -81,9 +121,6 @@ to assist developers in writing, debugging, and understanding code directly from tea.WithAltScreen(), ) - // 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) @@ -254,4 +291,14 @@ func init() { rootCmd.Flags().BoolP("version", "v", false, "Version") rootCmd.Flags().BoolP("debug", "d", false, "Debug") rootCmd.Flags().StringP("cwd", "c", "", "Current working directory") + rootCmd.Flags().StringP("prompt", "p", "", "Prompt to run in non-interactive mode") + + // Add format flag with validation logic + rootCmd.Flags().StringP("output-format", "f", format.Text.String(), + "Output format for non-interactive mode (text, json)") + + // Register custom validation for the format flag + rootCmd.RegisterFlagCompletionFunc("output-format", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return format.SupportedFormats, cobra.ShellCompDirectiveNoFileComp + }) } diff --git a/go.mod b/go.mod index 52c5e81..f280443 100644 --- a/go.mod +++ b/go.mod @@ -13,8 +13,8 @@ require ( github.com/aymanbagabas/go-udiff v0.2.0 github.com/bmatcuk/doublestar/v4 v4.8.1 github.com/catppuccin/go v0.3.0 - github.com/charmbracelet/bubbles v0.20.0 - github.com/charmbracelet/bubbletea v1.3.4 + github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbletea v1.3.5 github.com/charmbracelet/glamour v0.9.1 github.com/charmbracelet/huh v0.6.0 github.com/charmbracelet/lipgloss v1.1.0 diff --git a/go.sum b/go.sum index c41acf6..f5660be 100644 --- a/go.sum +++ b/go.sum @@ -74,10 +74,10 @@ github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= -github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= -github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= -github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI= -github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc= +github.com/charmbracelet/bubbletea v1.3.5/go.mod h1:TkCnmH+aBd4LrXhXcqrKiYwRs7qyQx5rBgH5fVY3v54= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= github.com/charmbracelet/glamour v0.9.1 h1:11dEfiGP8q1BEqvGoIjivuc2rBk+5qEXdPtaQ2WoiCM= @@ -90,8 +90,8 @@ github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2ll github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q= -github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= diff --git a/internal/app/app.go b/internal/app/app.go index db2ce7d..be549d3 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -3,12 +3,16 @@ package app import ( "context" "database/sql" + "errors" + "fmt" "maps" + "strings" "sync" "time" "github.com/opencode-ai/opencode/internal/config" "github.com/opencode-ai/opencode/internal/db" + "github.com/opencode-ai/opencode/internal/format" "github.com/opencode-ai/opencode/internal/history" "github.com/opencode-ai/opencode/internal/llm/agent" "github.com/opencode-ai/opencode/internal/logging" @@ -93,6 +97,66 @@ func (app *App) initTheme() { } } +// RunNonInteractive handles the execution flow when a prompt is provided via CLI flag. +func (a *App) RunNonInteractive(ctx context.Context, prompt string, outputFormat string) error { + logging.Info("Running in non-interactive mode") + + const maxPromptLengthForTitle = 100 + titlePrefix := "Non-interactive: " + var titleSuffix string + + if len(prompt) > maxPromptLengthForTitle { + titleSuffix = prompt[:maxPromptLengthForTitle] + "..." + } else { + titleSuffix = prompt + } + title := titlePrefix + titleSuffix + + sess, err := a.Sessions.Create(ctx, title) + if err != nil { + return fmt.Errorf("failed to create session for non-interactive mode: %w", err) + } + logging.Info("Created session for non-interactive run", "session_id", sess.ID) + + // Automatically approve all permission requests for this non-interactive session + a.Permissions.AutoApproveSession(sess.ID) + + done, err := a.CoderAgent.Run(ctx, sess.ID, prompt) + if err != nil { + return fmt.Errorf("failed to start agent processing stream: %w", err) + } + + result := <-done + if result.Err() != nil { + if errors.Is(result.Err(), context.Canceled) || errors.Is(result.Err(), agent.ErrRequestCancelled) { + logging.Info("Agent processing cancelled", "session_id", sess.ID) + return nil + } + return fmt.Errorf("agent processing failed: %w", result.Err()) + } + + response := result.Response() + + // Use a strings.Builder to accumulate the text parts + var builder strings.Builder + for _, part := range response.Parts { + if textPart, ok := part.(message.TextContent); ok { + builder.WriteString(textPart.Text) + } + } + + // Format and print the final accumulated text + if builder.Len() > 0 { + content := builder.String() + formattedOutput := format.FormatOutput(content, outputFormat) + fmt.Println(formattedOutput) + } + + logging.Info("Non-interactive run completed", "session_id", sess.ID) + + return nil +} + // Shutdown performs a clean shutdown of the application func (app *App) Shutdown() { // Cancel all watcher goroutines diff --git a/internal/format/format.go b/internal/format/format.go new file mode 100644 index 0000000..3d91ba0 --- /dev/null +++ b/internal/format/format.go @@ -0,0 +1,99 @@ +package format + +import ( + "encoding/json" + "fmt" + "strings" +) + +// OutputFormat represents the output format type for non-interactive mode +type OutputFormat string + +const ( + // Text format outputs the AI response as plain text. + Text OutputFormat = "text" + + // JSON format outputs the AI response wrapped in a JSON object. + JSON OutputFormat = "json" +) + +// String returns the string representation of the OutputFormat +func (f OutputFormat) String() string { + return string(f) +} + +// SupportedFormats is a list of all supported output formats as strings +var SupportedFormats = []string{ + string(Text), + string(JSON), +} + +// Parse converts a string to an OutputFormat +func Parse(s string) (OutputFormat, error) { + s = strings.ToLower(strings.TrimSpace(s)) + + switch s { + case string(Text): + return Text, nil + case string(JSON): + return JSON, nil + default: + return "", fmt.Errorf("invalid format: %s", s) + } +} + +// IsValid checks if the provided format string is supported +func IsValid(s string) bool { + _, err := Parse(s) + return err == nil +} + +// GetHelpText returns a formatted string describing all supported formats +func GetHelpText() string { + return fmt.Sprintf(`Supported output formats: +- %s: Plain text output (default) +- %s: Output wrapped in a JSON object`, + Text, JSON) +} + +// FormatOutput formats the AI response according to the specified format +func FormatOutput(content string, formatStr string) string { + format, err := Parse(formatStr) + if err != nil { + // Default to text format on error + return content + } + + switch format { + case JSON: + return formatAsJSON(content) + case Text: + fallthrough + default: + return content + } +} + +// formatAsJSON wraps the content in a simple JSON object +func formatAsJSON(content string) string { + // Use the JSON package to properly escape the content + response := struct { + Response string `json:"response"` + }{ + Response: content, + } + + jsonBytes, err := json.MarshalIndent(response, "", " ") + if err != nil { + // In case of an error, return a manually formatted JSON + jsonEscaped := strings.Replace(content, "\\", "\\\\", -1) + jsonEscaped = strings.Replace(jsonEscaped, "\"", "\\\"", -1) + jsonEscaped = strings.Replace(jsonEscaped, "\n", "\\n", -1) + jsonEscaped = strings.Replace(jsonEscaped, "\r", "\\r", -1) + jsonEscaped = strings.Replace(jsonEscaped, "\t", "\\t", -1) + + return fmt.Sprintf("{\n \"response\": \"%s\"\n}", jsonEscaped) + } + + return string(jsonBytes) +}