Skip to content

feat: non-interactive mode #130

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,4 @@ Thumbs.db

.opencode/

opencode
40 changes: 33 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
57 changes: 52 additions & 5 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -73,17 +101,26 @@ 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(
tui.New(app),
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)

Expand Down Expand Up @@ -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
})
}
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 6 additions & 6 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
Expand Down
64 changes: 64 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
99 changes: 99 additions & 0 deletions internal/format/format.go
Original file line number Diff line number Diff line change
@@ -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)
}