Skip to content
Merged
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
44 changes: 37 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,6 @@ You can configure OpenCode using environment variables:
| `AZURE_OPENAI_ENDPOINT` | For Azure OpenAI models |
| `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 @@ -196,7 +195,7 @@ OpenCode supports a variety of AI models from different providers:
- Gemini 2.5
- Gemini 2.5 Flash

## Usage
## Interactive Mode Usage

```bash
# Start OpenCode
Expand All @@ -209,13 +208,44 @@ 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

# Run without showing the spinner
opencode -p "Explain the use of context in Go" -q
```

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) |
| `--quiet` | `-q` | Hide spinner in non-interactive mode |

## Keyboard Shortcuts

Expand Down
111 changes: 111 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,15 @@ import (
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/db"
"github.com/sst/opencode/internal/format"
"github.com/sst/opencode/internal/llm/agent"
"github.com/sst/opencode/internal/logging"
"github.com/sst/opencode/internal/lsp/discovery"
"github.com/sst/opencode/internal/message"
"github.com/sst/opencode/internal/permission"
"github.com/sst/opencode/internal/pubsub"
"github.com/sst/opencode/internal/tui"
"github.com/sst/opencode/internal/tui/components/spinner"
"github.com/sst/opencode/internal/version"
)

Expand Down Expand Up @@ -88,6 +92,19 @@ to assist developers in writing, debugging, and understanding code directly from
return err
}

// Check if we're in non-interactive mode
prompt, _ := cmd.Flags().GetString("prompt")
if prompt != "" {
outputFormatStr, _ := cmd.Flags().GetString("output-format")
outputFormat := format.OutputFormat(outputFormatStr)
if !outputFormat.IsValid() {
return fmt.Errorf("invalid output format: %s", outputFormatStr)
}

quiet, _ := cmd.Flags().GetBool("quiet")
return handleNonInteractiveMode(cmd.Context(), prompt, outputFormat, quiet)
}

// Run LSP auto-discovery
if err := discovery.IntegrateLSPServers(cwd); err != nil {
slog.Warn("Failed to auto-discover LSP servers", "error", err)
Expand Down Expand Up @@ -205,6 +222,97 @@ func initMCPTools(ctx context.Context, app *app.App) {
}()
}

// handleNonInteractiveMode processes a single prompt in non-interactive mode
func handleNonInteractiveMode(ctx context.Context, prompt string, outputFormat format.OutputFormat, quiet bool) error {
slog.Info("Running in non-interactive mode", "prompt", prompt, "format", outputFormat, "quiet", quiet)

// Start spinner if not in quiet mode
var s *spinner.Spinner
if !quiet {
s = spinner.NewSpinner("Thinking...")
s.Start()
defer s.Stop()
}

// Connect DB, this will also run migrations
conn, err := db.Connect()
if err != nil {
return err
}

// Create a context with cancellation
ctx, cancel := context.WithCancel(ctx)
defer cancel()

// Create the app
app, err := app.New(ctx, conn)
if err != nil {
slog.Error("Failed to create app", "error", err)
return err
}

// Auto-approve all permissions for non-interactive mode
permission.AutoApproveSession(ctx, "non-interactive")

// Create a new session for this prompt
session, err := app.Sessions.Create(ctx, "Non-interactive prompt")
if err != nil {
return fmt.Errorf("failed to create session: %w", err)
}

// Set the session as current
app.CurrentSession = &session

// Create the user message
_, err = app.Messages.Create(ctx, session.ID, message.CreateMessageParams{
Role: message.User,
Parts: []message.ContentPart{message.TextContent{Text: prompt}},
})
if err != nil {
return fmt.Errorf("failed to create message: %w", err)
}

// Run the agent to get a response
eventCh, err := app.PrimaryAgent.Run(ctx, session.ID, prompt)
if err != nil {
return fmt.Errorf("failed to run agent: %w", err)
}

// Wait for the response
var response message.Message
for event := range eventCh {
if event.Err() != nil {
return fmt.Errorf("agent error: %w", event.Err())
}
response = event.Response()
}

// Get the text content from the response
content := ""
if textContent := response.Content(); textContent != nil {
content = textContent.Text
}

// Format the output according to the specified format
formattedOutput, err := format.FormatOutput(content, outputFormat)
if err != nil {
return fmt.Errorf("failed to format output: %w", err)
}

// Stop spinner before printing output
if !quiet && s != nil {
s.Stop()
}

// Print the formatted output to stdout
fmt.Println(formattedOutput)

// Shutdown the app
app.Shutdown()

return nil
}

func setupSubscriber[T any](
ctx context.Context,
wg *sync.WaitGroup,
Expand Down Expand Up @@ -296,4 +404,7 @@ 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", "", "Run a single prompt in non-interactive mode")
rootCmd.Flags().StringP("output-format", "f", "text", "Output format for non-interactive mode (text, json)")
rootCmd.Flags().BoolP("quiet", "q", false, "Hide spinner in non-interactive mode")
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ 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/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.4
github.com/charmbracelet/glamour v0.9.1
github.com/charmbracelet/lipgloss v1.1.0
Expand Down
3 changes: 3 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ 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/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.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
Expand All @@ -84,6 +86,7 @@ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0G
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/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
Expand Down
46 changes: 46 additions & 0 deletions internal/format/format.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package format

import (
"encoding/json"
"fmt"
)

// OutputFormat represents the format for non-interactive mode output
type OutputFormat string

const (
// TextFormat is plain text output (default)
TextFormat OutputFormat = "text"

// JSONFormat is output wrapped in a JSON object
JSONFormat OutputFormat = "json"
)

// IsValid checks if the output format is valid
func (f OutputFormat) IsValid() bool {
return f == TextFormat || f == JSONFormat
}

// String returns the string representation of the output format
func (f OutputFormat) String() string {
return string(f)
}

// FormatOutput formats the given content according to the specified format
func FormatOutput(content string, format OutputFormat) (string, error) {
switch format {
case TextFormat:
return content, nil
case JSONFormat:
jsonData := map[string]string{
"response": content,
}
jsonBytes, err := json.MarshalIndent(jsonData, "", " ")
if err != nil {
return "", fmt.Errorf("failed to marshal JSON: %w", err)
}
return string(jsonBytes), nil
default:
return "", fmt.Errorf("unsupported output format: %s", format)
}
}
90 changes: 90 additions & 0 deletions internal/format/format_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package format

import (
"testing"
)

func TestOutputFormat_IsValid(t *testing.T) {
t.Parallel()

tests := []struct {
name string
format OutputFormat
want bool
}{
{
name: "text format",
format: TextFormat,
want: true,
},
{
name: "json format",
format: JSONFormat,
want: true,
},
{
name: "invalid format",
format: "invalid",
want: false,
},
}

for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := tt.format.IsValid(); got != tt.want {
t.Errorf("OutputFormat.IsValid() = %v, want %v", got, tt.want)
}
})
}
}

func TestFormatOutput(t *testing.T) {
t.Parallel()

tests := []struct {
name string
content string
format OutputFormat
want string
wantErr bool
}{
{
name: "text format",
content: "test content",
format: TextFormat,
want: "test content",
wantErr: false,
},
{
name: "json format",
content: "test content",
format: JSONFormat,
want: "{\n \"response\": \"test content\"\n}",
wantErr: false,
},
{
name: "invalid format",
content: "test content",
format: "invalid",
want: "",
wantErr: true,
},
}

for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := FormatOutput(tt.content, tt.format)
if (err != nil) != tt.wantErr {
t.Errorf("FormatOutput() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("FormatOutput() = %v, want %v", got, tt.want)
}
})
}
}
Loading