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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,5 @@ Thumbs.db
.env.local

.opencode/

# ignore locally built binary
opencode*
37 changes: 29 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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

Expand Down
292 changes: 292 additions & 0 deletions cmd/non_interactive_mode.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading