summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorEd Zynda <[email protected]>2025-05-16 14:06:28 +0300
committerGitHub <[email protected]>2025-05-16 06:06:28 -0500
commit623d132772b9c69dd6d99ed4004b26c46dbe43a4 (patch)
tree1547bc8b7f7b8487dbdc34c76998416a37db7618
parentd127a1c4ebe326344dc77fe3d136c033da6031fd (diff)
downloadopencode-623d132772b9c69dd6d99ed4004b26c46dbe43a4.tar.gz
opencode-623d132772b9c69dd6d99ed4004b26c46dbe43a4.zip
feat: Add non-interactive mode (#18)
-rw-r--r--README.md44
-rw-r--r--cmd/root.go111
-rw-r--r--go.mod2
-rw-r--r--go.sum3
-rw-r--r--internal/format/format.go46
-rw-r--r--internal/format/format_test.go90
-rw-r--r--internal/tui/components/spinner/spinner.go102
-rw-r--r--internal/tui/components/spinner/spinner_test.go24
8 files changed, 414 insertions, 8 deletions
diff --git a/README.md b/README.md
index f5cf6fe10..57da47289 100644
--- a/README.md
+++ b/README.md
@@ -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
@@ -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
@@ -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
diff --git a/cmd/root.go b/cmd/root.go
index 1e96e20c4..65da66e69 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -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"
)
@@ -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)
@@ -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,
@@ -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")
}
diff --git a/go.mod b/go.mod
index 6bc9b150a..777ba525b 100644
--- a/go.mod
+++ b/go.mod
@@ -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
diff --git a/go.sum b/go.sum
index c6a79ab16..ffadfcd11 100644
--- a/go.sum
+++ b/go.sum
@@ -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=
@@ -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=
diff --git a/internal/format/format.go b/internal/format/format.go
new file mode 100644
index 000000000..321f5c102
--- /dev/null
+++ b/internal/format/format.go
@@ -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)
+ }
+}
diff --git a/internal/format/format_test.go b/internal/format/format_test.go
new file mode 100644
index 000000000..04054a7c4
--- /dev/null
+++ b/internal/format/format_test.go
@@ -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)
+ }
+ })
+ }
+}
diff --git a/internal/tui/components/spinner/spinner.go b/internal/tui/components/spinner/spinner.go
new file mode 100644
index 000000000..42b98810a
--- /dev/null
+++ b/internal/tui/components/spinner/spinner.go
@@ -0,0 +1,102 @@
+package spinner
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/charmbracelet/bubbles/spinner"
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+// Spinner wraps the bubbles spinner for both interactive and non-interactive mode
+type Spinner struct {
+ model spinner.Model
+ done chan struct{}
+ prog *tea.Program
+ ctx context.Context
+ cancel context.CancelFunc
+}
+
+// spinnerModel is the tea.Model for the spinner
+type spinnerModel struct {
+ spinner spinner.Model
+ message string
+ quitting bool
+}
+
+func (m spinnerModel) Init() tea.Cmd {
+ return m.spinner.Tick
+}
+
+func (m spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.KeyMsg:
+ m.quitting = true
+ return m, tea.Quit
+ case spinner.TickMsg:
+ var cmd tea.Cmd
+ m.spinner, cmd = m.spinner.Update(msg)
+ return m, cmd
+ case quitMsg:
+ m.quitting = true
+ return m, tea.Quit
+ default:
+ return m, nil
+ }
+}
+
+func (m spinnerModel) View() string {
+ if m.quitting {
+ return ""
+ }
+ return fmt.Sprintf("%s %s", m.spinner.View(), m.message)
+}
+
+// quitMsg is sent when we want to quit the spinner
+type quitMsg struct{}
+
+// NewSpinner creates a new spinner with the given message
+func NewSpinner(message string) *Spinner {
+ s := spinner.New()
+ s.Spinner = spinner.Dot
+ s.Style = s.Style.Foreground(s.Style.GetForeground())
+
+ ctx, cancel := context.WithCancel(context.Background())
+
+ model := spinnerModel{
+ spinner: s,
+ message: message,
+ }
+
+ prog := tea.NewProgram(model, tea.WithOutput(os.Stderr), tea.WithoutCatchPanics())
+
+ return &Spinner{
+ model: s,
+ done: make(chan struct{}),
+ prog: prog,
+ ctx: ctx,
+ cancel: cancel,
+ }
+}
+
+// Start begins the spinner animation
+func (s *Spinner) Start() {
+ go func() {
+ defer close(s.done)
+ go func() {
+ <-s.ctx.Done()
+ s.prog.Send(quitMsg{})
+ }()
+ _, err := s.prog.Run()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error running spinner: %v\n", err)
+ }
+ }()
+}
+
+// Stop ends the spinner animation
+func (s *Spinner) Stop() {
+ s.cancel()
+ <-s.done
+} \ No newline at end of file
diff --git a/internal/tui/components/spinner/spinner_test.go b/internal/tui/components/spinner/spinner_test.go
new file mode 100644
index 000000000..065726e91
--- /dev/null
+++ b/internal/tui/components/spinner/spinner_test.go
@@ -0,0 +1,24 @@
+package spinner
+
+import (
+ "testing"
+ "time"
+)
+
+func TestSpinner(t *testing.T) {
+ t.Parallel()
+
+ // Create a spinner
+ s := NewSpinner("Test spinner")
+
+ // Start the spinner
+ s.Start()
+
+ // Wait a bit to let it run
+ time.Sleep(100 * time.Millisecond)
+
+ // Stop the spinner
+ s.Stop()
+
+ // If we got here without panicking, the test passes
+} \ No newline at end of file