Просмотр исходного кода

feat: Add non-interactive mode (#18)

Ed Zynda 9 месяцев назад
Родитель
Сommit
623d132772

+ 37 - 7
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
 

+ 111 - 0
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")
 }

+ 1 - 1
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

+ 3 - 0
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=

+ 46 - 0
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)
+	}
+}

+ 90 - 0
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)
+			}
+		})
+	}
+}

+ 102 - 0
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
+}

+ 24 - 0
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
+}