From 87237b6462b9dfd379b22e69712e8dc516afad9d Mon Sep 17 00:00:00 2001 From: mineo Date: Fri, 16 May 2025 03:25:21 +0900 Subject: feat: support VertexAI provider (#153) * support: vertexai fix fix set default for vertexai added comment fix fix * create schema * fix README.md * fix order * added pupularity * set tools if tools is exists restore commentout * fix comment * set summarizer model --- cmd/schema/main.go | 1 + 1 file changed, 1 insertion(+) (limited to 'cmd') diff --git a/cmd/schema/main.go b/cmd/schema/main.go index be2626292..261c703df 100644 --- a/cmd/schema/main.go +++ b/cmd/schema/main.go @@ -227,6 +227,7 @@ func generateSchema() map[string]any { string(models.ProviderOpenRouter), string(models.ProviderBedrock), string(models.ProviderAzure), + string(models.ProviderVertexAI), } providerSchema["additionalProperties"].(map[string]any)["properties"].(map[string]any)["provider"] = map[string]any{ -- cgit v1.2.3 From 623d132772b9c69dd6d99ed4004b26c46dbe43a4 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Fri, 16 May 2025 14:06:28 +0300 Subject: feat: Add non-interactive mode (#18) --- README.md | 44 ++++++++-- cmd/root.go | 111 ++++++++++++++++++++++++ go.mod | 2 +- go.sum | 3 + internal/format/format.go | 46 ++++++++++ internal/format/format_test.go | 90 +++++++++++++++++++ internal/tui/components/spinner/spinner.go | 102 ++++++++++++++++++++++ internal/tui/components/spinner/spinner_test.go | 24 +++++ 8 files changed, 414 insertions(+), 8 deletions(-) create mode 100644 internal/format/format.go create mode 100644 internal/format/format_test.go create mode 100644 internal/tui/components/spinner/spinner.go create mode 100644 internal/tui/components/spinner/spinner_test.go (limited to 'cmd') 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 -- cgit v1.2.3 From 307982a0990f597608258b7de053fbf852d8dee3 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Sat, 17 May 2025 16:23:13 +0300 Subject: feat: Add tool restriction flags for non-interactive mode (#29) --- .gitignore | 3 +- README.md | 37 +++- cmd/non_interactive_mode.go | 292 +++++++++++++++++++++++++++++ cmd/root.go | 113 ++--------- go.mod | 5 + go.sum | 2 + internal/tui/components/spinner/spinner.go | 25 +++ 7 files changed, 372 insertions(+), 105 deletions(-) create mode 100644 cmd/non_interactive_mode.go (limited to 'cmd') diff --git a/.gitignore b/.gitignore index 2603e630d..e51e0598a 100644 --- a/.gitignore +++ b/.gitignore @@ -42,4 +42,5 @@ Thumbs.db .env.local .opencode/ - +# ignore locally built binary +opencode* diff --git a/README.md b/README.md index 10be21d5c..4cf83d465 100644 --- a/README.md +++ b/README.md @@ -225,10 +225,28 @@ 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 + +# Enable verbose logging to stderr +opencode -p "Explain the use of context in Go" --verbose + +# Restrict the agent to only use specific tools +opencode -p "Explain the use of context in Go" --allowedTools=view,ls,glob + +# Prevent the agent from using specific tools +opencode -p "Explain the use of context in Go" --excludedTools=bash,edit ``` 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. +### Tool Restrictions + +You can control which tools the AI assistant has access to in non-interactive mode: + +- `--allowedTools`: Comma-separated list of tools that the agent is allowed to use. Only these tools will be available. +- `--excludedTools`: Comma-separated list of tools that the agent is not allowed to use. All other tools will be available. + +These flags are mutually exclusive - you can use either `--allowedTools` or `--excludedTools`, but not both at the same time. + ### Output Formats OpenCode supports the following output formats in non-interactive mode: @@ -242,14 +260,17 @@ The output format is implemented as a strongly-typed `OutputFormat` in the codeb ## Command-line Flags -| 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 | +| 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 | +| `--verbose` | | Display logs to stderr in non-interactive mode | +| `--allowedTools` | | Restrict the agent to only use specified tools | +| `--excludedTools` | | Prevent the agent from using specified tools | ## Keyboard Shortcuts diff --git a/cmd/non_interactive_mode.go b/cmd/non_interactive_mode.go new file mode 100644 index 000000000..5023839c3 --- /dev/null +++ b/cmd/non_interactive_mode.go @@ -0,0 +1,292 @@ +package cmd + +import ( + "context" + "fmt" + "io" + "os" + "sync" + "time" + + "log/slog" + + charmlog "github.com/charmbracelet/log" + "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/llm/tools" + "github.com/sst/opencode/internal/message" + "github.com/sst/opencode/internal/permission" + "github.com/sst/opencode/internal/tui/components/spinner" + "github.com/sst/opencode/internal/tui/theme" +) + +// syncWriter is a thread-safe writer that prevents interleaved output +type syncWriter struct { + w io.Writer + mu sync.Mutex +} + +// Write implements io.Writer +func (sw *syncWriter) Write(p []byte) (n int, err error) { + sw.mu.Lock() + defer sw.mu.Unlock() + return sw.w.Write(p) +} + +// newSyncWriter creates a new synchronized writer +func newSyncWriter(w io.Writer) io.Writer { + return &syncWriter{w: w} +} + +// filterTools filters the provided tools based on allowed or excluded tool names +func filterTools(allTools []tools.BaseTool, allowedTools, excludedTools []string) []tools.BaseTool { + // If neither allowed nor excluded tools are specified, return all tools + if len(allowedTools) == 0 && len(excludedTools) == 0 { + return allTools + } + + // Create a map for faster lookups + allowedMap := make(map[string]bool) + for _, name := range allowedTools { + allowedMap[name] = true + } + + excludedMap := make(map[string]bool) + for _, name := range excludedTools { + excludedMap[name] = true + } + + var filteredTools []tools.BaseTool + + for _, tool := range allTools { + toolName := tool.Info().Name + + // If we have an allowed list, only include tools in that list + if len(allowedTools) > 0 { + if allowedMap[toolName] { + filteredTools = append(filteredTools, tool) + } + } else if len(excludedTools) > 0 { + // If we have an excluded list, include all tools except those in the list + if !excludedMap[toolName] { + filteredTools = append(filteredTools, tool) + } + } + } + + return filteredTools +} + +// handleNonInteractiveMode processes a single prompt in non-interactive mode +func handleNonInteractiveMode(ctx context.Context, prompt string, outputFormat format.OutputFormat, quiet bool, verbose bool, allowedTools, excludedTools []string) error { + // Initial log message using standard slog + slog.Info("Running in non-interactive mode", "prompt", prompt, "format", outputFormat, "quiet", quiet, "verbose", verbose, + "allowedTools", allowedTools, "excludedTools", excludedTools) + + // Sanity check for mutually exclusive flags + if quiet && verbose { + return fmt.Errorf("--quiet and --verbose flags cannot be used together") + } + + // Set up logging to stderr if verbose mode is enabled + if verbose { + // Create a synchronized writer to prevent interleaved output + syncWriter := newSyncWriter(os.Stderr) + + // Create a charmbracelet/log logger that writes to the synchronized writer + charmLogger := charmlog.NewWithOptions(syncWriter, charmlog.Options{ + Level: charmlog.DebugLevel, + ReportCaller: true, + ReportTimestamp: true, + TimeFormat: time.RFC3339, + Prefix: "OpenCode", + }) + + // Set the global logger for charmbracelet/log + charmlog.SetDefault(charmLogger) + + // Create a slog handler that uses charmbracelet/log + // This will forward all slog logs to charmbracelet/log + slog.SetDefault(slog.New(charmLogger)) + + // Log a message to confirm verbose logging is enabled + charmLogger.Info("Verbose logging enabled") + } + + // Start spinner if not in quiet mode + var s *spinner.Spinner + if !quiet { + // Get the current theme to style the spinner + currentTheme := theme.CurrentTheme() + + // Create a themed spinner + if currentTheme != nil { + // Use the primary color from the theme + s = spinner.NewThemedSpinner("Thinking...", currentTheme.Primary()) + } else { + // Fallback to default spinner if no theme is available + 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 + } + + // 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 + + // Auto-approve all permissions for this session + permission.AutoApproveSession(ctx, session.ID) + + // 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) + } + + // If tool restrictions are specified, create a new agent with filtered tools + if len(allowedTools) > 0 || len(excludedTools) > 0 { + // Initialize MCP tools synchronously to ensure they're included in filtering + mcpCtx, mcpCancel := context.WithTimeout(ctx, 10*time.Second) + agent.GetMcpTools(mcpCtx, app.Permissions) + mcpCancel() + + // Get all available tools including MCP tools + allTools := agent.PrimaryAgentTools( + app.Permissions, + app.Sessions, + app.Messages, + app.History, + app.LSPClients, + ) + + // Filter tools based on allowed/excluded lists + filteredTools := filterTools(allTools, allowedTools, excludedTools) + + // Log the filtered tools for debugging + var toolNames []string + for _, tool := range filteredTools { + toolNames = append(toolNames, tool.Info().Name) + } + slog.Debug("Using filtered tools", "count", len(filteredTools), "tools", toolNames) + + // Create a new agent with the filtered tools + restrictedAgent, err := agent.NewAgent( + config.AgentPrimary, + app.Sessions, + app.Messages, + filteredTools, + ) + if err != nil { + return fmt.Errorf("failed to create restricted agent: %w", err) + } + + // Use the restricted agent for this request + eventCh, err := restrictedAgent.Run(ctx, session.ID, prompt) + if err != nil { + return fmt.Errorf("failed to run restricted 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() + } + + // Format and print the output + content := "" + if textContent := response.Content(); textContent != nil { + content = textContent.Text + } + + 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 + } + + // Run the default agent if no tool restrictions + 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 +} diff --git a/cmd/root.go b/cmd/root.go index 65da66e69..39d58eab6 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -19,11 +19,8 @@ import ( "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" ) @@ -100,9 +97,15 @@ to assist developers in writing, debugging, and understanding code directly from if !outputFormat.IsValid() { return fmt.Errorf("invalid output format: %s", outputFormatStr) } - + quiet, _ := cmd.Flags().GetBool("quiet") - return handleNonInteractiveMode(cmd.Context(), prompt, outputFormat, quiet) + verbose, _ := cmd.Flags().GetBool("verbose") + + // Get tool restriction flags + allowedTools, _ := cmd.Flags().GetStringSlice("allowedTools") + excludedTools, _ := cmd.Flags().GetStringSlice("excludedTools") + + return handleNonInteractiveMode(cmd.Context(), prompt, outputFormat, quiet, verbose, allowedTools, excludedTools) } // Run LSP auto-discovery @@ -222,97 +225,6 @@ 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, @@ -407,4 +319,13 @@ func init() { 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") + rootCmd.Flags().BoolP("verbose", "", false, "Display logs to stderr in non-interactive mode") + rootCmd.Flags().StringSlice("allowedTools", nil, "Restrict the agent to only use the specified tools in non-interactive mode (comma-separated list)") + rootCmd.Flags().StringSlice("excludedTools", nil, "Prevent the agent from using the specified tools in non-interactive mode (comma-separated list)") + + // Make allowedTools and excludedTools mutually exclusive + rootCmd.MarkFlagsMutuallyExclusive("allowedTools", "excludedTools") + + // Make quiet and verbose mutually exclusive + rootCmd.MarkFlagsMutuallyExclusive("quiet", "verbose") } diff --git a/go.mod b/go.mod index 7136d8784..ae1a7f09d 100644 --- a/go.mod +++ b/go.mod @@ -34,6 +34,11 @@ require ( github.com/stretchr/testify v1.10.0 ) +require ( + github.com/charmbracelet/log v0.4.2 // indirect + golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect +) + require ( cloud.google.com/go v0.116.0 // indirect cloud.google.com/go/auth v0.13.0 // indirect diff --git a/go.sum b/go.sum index ffadfcd11..9c758aefe 100644 --- a/go.sum +++ b/go.sum @@ -80,6 +80,8 @@ github.com/charmbracelet/glamour v0.9.1 h1:11dEfiGP8q1BEqvGoIjivuc2rBk+5qEXdPtaQ github.com/charmbracelet/glamour v0.9.1/go.mod h1:+SHvIS8qnwhgTpVMiXwn7OfGomSqff1cHBCI8jLOetk= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= +github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= 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= diff --git a/internal/tui/components/spinner/spinner.go b/internal/tui/components/spinner/spinner.go index 42b98810a..5e1af8771 100644 --- a/internal/tui/components/spinner/spinner.go +++ b/internal/tui/components/spinner/spinner.go @@ -7,6 +7,7 @@ import ( "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" ) // Spinner wraps the bubbles spinner for both interactive and non-interactive mode @@ -80,6 +81,30 @@ func NewSpinner(message string) *Spinner { } } +// NewThemedSpinner creates a new spinner with the given message and color +func NewThemedSpinner(message string, color lipgloss.AdaptiveColor) *Spinner { + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = s.Style.Foreground(color) + + 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() { -- cgit v1.2.3