|
| 1 | +package cmd |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "fmt" |
| 6 | + "io" |
| 7 | + "os" |
| 8 | + "sync" |
| 9 | + "time" |
| 10 | + |
| 11 | + "log/slog" |
| 12 | + |
| 13 | + charmlog "github.com/charmbracelet/log" |
| 14 | + "github.com/sst/opencode/internal/app" |
| 15 | + "github.com/sst/opencode/internal/config" |
| 16 | + "github.com/sst/opencode/internal/db" |
| 17 | + "github.com/sst/opencode/internal/format" |
| 18 | + "github.com/sst/opencode/internal/llm/agent" |
| 19 | + "github.com/sst/opencode/internal/llm/tools" |
| 20 | + "github.com/sst/opencode/internal/message" |
| 21 | + "github.com/sst/opencode/internal/permission" |
| 22 | + "github.com/sst/opencode/internal/tui/components/spinner" |
| 23 | + "github.com/sst/opencode/internal/tui/theme" |
| 24 | +) |
| 25 | + |
| 26 | +// syncWriter is a thread-safe writer that prevents interleaved output |
| 27 | +type syncWriter struct { |
| 28 | + w io.Writer |
| 29 | + mu sync.Mutex |
| 30 | +} |
| 31 | + |
| 32 | +// Write implements io.Writer |
| 33 | +func (sw *syncWriter) Write(p []byte) (n int, err error) { |
| 34 | + sw.mu.Lock() |
| 35 | + defer sw.mu.Unlock() |
| 36 | + return sw.w.Write(p) |
| 37 | +} |
| 38 | + |
| 39 | +// newSyncWriter creates a new synchronized writer |
| 40 | +func newSyncWriter(w io.Writer) io.Writer { |
| 41 | + return &syncWriter{w: w} |
| 42 | +} |
| 43 | + |
| 44 | +// filterTools filters the provided tools based on allowed or excluded tool names |
| 45 | +func filterTools(allTools []tools.BaseTool, allowedTools, excludedTools []string) []tools.BaseTool { |
| 46 | + // If neither allowed nor excluded tools are specified, return all tools |
| 47 | + if len(allowedTools) == 0 && len(excludedTools) == 0 { |
| 48 | + return allTools |
| 49 | + } |
| 50 | + |
| 51 | + // Create a map for faster lookups |
| 52 | + allowedMap := make(map[string]bool) |
| 53 | + for _, name := range allowedTools { |
| 54 | + allowedMap[name] = true |
| 55 | + } |
| 56 | + |
| 57 | + excludedMap := make(map[string]bool) |
| 58 | + for _, name := range excludedTools { |
| 59 | + excludedMap[name] = true |
| 60 | + } |
| 61 | + |
| 62 | + var filteredTools []tools.BaseTool |
| 63 | + |
| 64 | + for _, tool := range allTools { |
| 65 | + toolName := tool.Info().Name |
| 66 | + |
| 67 | + // If we have an allowed list, only include tools in that list |
| 68 | + if len(allowedTools) > 0 { |
| 69 | + if allowedMap[toolName] { |
| 70 | + filteredTools = append(filteredTools, tool) |
| 71 | + } |
| 72 | + } else if len(excludedTools) > 0 { |
| 73 | + // If we have an excluded list, include all tools except those in the list |
| 74 | + if !excludedMap[toolName] { |
| 75 | + filteredTools = append(filteredTools, tool) |
| 76 | + } |
| 77 | + } |
| 78 | + } |
| 79 | + |
| 80 | + return filteredTools |
| 81 | +} |
| 82 | + |
| 83 | +// handleNonInteractiveMode processes a single prompt in non-interactive mode |
| 84 | +func handleNonInteractiveMode(ctx context.Context, prompt string, outputFormat format.OutputFormat, quiet bool, verbose bool, allowedTools, excludedTools []string) error { |
| 85 | + // Initial log message using standard slog |
| 86 | + slog.Info("Running in non-interactive mode", "prompt", prompt, "format", outputFormat, "quiet", quiet, "verbose", verbose, |
| 87 | + "allowedTools", allowedTools, "excludedTools", excludedTools) |
| 88 | + |
| 89 | + // Sanity check for mutually exclusive flags |
| 90 | + if quiet && verbose { |
| 91 | + return fmt.Errorf("--quiet and --verbose flags cannot be used together") |
| 92 | + } |
| 93 | + |
| 94 | + // Set up logging to stderr if verbose mode is enabled |
| 95 | + if verbose { |
| 96 | + // Create a synchronized writer to prevent interleaved output |
| 97 | + syncWriter := newSyncWriter(os.Stderr) |
| 98 | + |
| 99 | + // Create a charmbracelet/log logger that writes to the synchronized writer |
| 100 | + charmLogger := charmlog.NewWithOptions(syncWriter, charmlog.Options{ |
| 101 | + Level: charmlog.DebugLevel, |
| 102 | + ReportCaller: true, |
| 103 | + ReportTimestamp: true, |
| 104 | + TimeFormat: time.RFC3339, |
| 105 | + Prefix: "OpenCode", |
| 106 | + }) |
| 107 | + |
| 108 | + // Set the global logger for charmbracelet/log |
| 109 | + charmlog.SetDefault(charmLogger) |
| 110 | + |
| 111 | + // Create a slog handler that uses charmbracelet/log |
| 112 | + // This will forward all slog logs to charmbracelet/log |
| 113 | + slog.SetDefault(slog.New(charmLogger)) |
| 114 | + |
| 115 | + // Log a message to confirm verbose logging is enabled |
| 116 | + charmLogger.Info("Verbose logging enabled") |
| 117 | + } |
| 118 | + |
| 119 | + // Start spinner if not in quiet mode |
| 120 | + var s *spinner.Spinner |
| 121 | + if !quiet { |
| 122 | + // Get the current theme to style the spinner |
| 123 | + currentTheme := theme.CurrentTheme() |
| 124 | + |
| 125 | + // Create a themed spinner |
| 126 | + if currentTheme != nil { |
| 127 | + // Use the primary color from the theme |
| 128 | + s = spinner.NewThemedSpinner("Thinking...", currentTheme.Primary()) |
| 129 | + } else { |
| 130 | + // Fallback to default spinner if no theme is available |
| 131 | + s = spinner.NewSpinner("Thinking...") |
| 132 | + } |
| 133 | + |
| 134 | + s.Start() |
| 135 | + defer s.Stop() |
| 136 | + } |
| 137 | + |
| 138 | + // Connect DB, this will also run migrations |
| 139 | + conn, err := db.Connect() |
| 140 | + if err != nil { |
| 141 | + return err |
| 142 | + } |
| 143 | + |
| 144 | + // Create a context with cancellation |
| 145 | + ctx, cancel := context.WithCancel(ctx) |
| 146 | + defer cancel() |
| 147 | + |
| 148 | + // Create the app |
| 149 | + app, err := app.New(ctx, conn) |
| 150 | + if err != nil { |
| 151 | + slog.Error("Failed to create app", "error", err) |
| 152 | + return err |
| 153 | + } |
| 154 | + |
| 155 | + // Create a new session for this prompt |
| 156 | + session, err := app.Sessions.Create(ctx, "Non-interactive prompt") |
| 157 | + if err != nil { |
| 158 | + return fmt.Errorf("failed to create session: %w", err) |
| 159 | + } |
| 160 | + |
| 161 | + // Set the session as current |
| 162 | + app.CurrentSession = &session |
| 163 | + |
| 164 | + // Auto-approve all permissions for this session |
| 165 | + permission.AutoApproveSession(ctx, session.ID) |
| 166 | + |
| 167 | + // Create the user message |
| 168 | + _, err = app.Messages.Create(ctx, session.ID, message.CreateMessageParams{ |
| 169 | + Role: message.User, |
| 170 | + Parts: []message.ContentPart{message.TextContent{Text: prompt}}, |
| 171 | + }) |
| 172 | + if err != nil { |
| 173 | + return fmt.Errorf("failed to create message: %w", err) |
| 174 | + } |
| 175 | + |
| 176 | + // If tool restrictions are specified, create a new agent with filtered tools |
| 177 | + if len(allowedTools) > 0 || len(excludedTools) > 0 { |
| 178 | + // Initialize MCP tools synchronously to ensure they're included in filtering |
| 179 | + mcpCtx, mcpCancel := context.WithTimeout(ctx, 10*time.Second) |
| 180 | + agent.GetMcpTools(mcpCtx, app.Permissions) |
| 181 | + mcpCancel() |
| 182 | + |
| 183 | + // Get all available tools including MCP tools |
| 184 | + allTools := agent.PrimaryAgentTools( |
| 185 | + app.Permissions, |
| 186 | + app.Sessions, |
| 187 | + app.Messages, |
| 188 | + app.History, |
| 189 | + app.LSPClients, |
| 190 | + ) |
| 191 | + |
| 192 | + // Filter tools based on allowed/excluded lists |
| 193 | + filteredTools := filterTools(allTools, allowedTools, excludedTools) |
| 194 | + |
| 195 | + // Log the filtered tools for debugging |
| 196 | + var toolNames []string |
| 197 | + for _, tool := range filteredTools { |
| 198 | + toolNames = append(toolNames, tool.Info().Name) |
| 199 | + } |
| 200 | + slog.Debug("Using filtered tools", "count", len(filteredTools), "tools", toolNames) |
| 201 | + |
| 202 | + // Create a new agent with the filtered tools |
| 203 | + restrictedAgent, err := agent.NewAgent( |
| 204 | + config.AgentPrimary, |
| 205 | + app.Sessions, |
| 206 | + app.Messages, |
| 207 | + filteredTools, |
| 208 | + ) |
| 209 | + if err != nil { |
| 210 | + return fmt.Errorf("failed to create restricted agent: %w", err) |
| 211 | + } |
| 212 | + |
| 213 | + // Use the restricted agent for this request |
| 214 | + eventCh, err := restrictedAgent.Run(ctx, session.ID, prompt) |
| 215 | + if err != nil { |
| 216 | + return fmt.Errorf("failed to run restricted agent: %w", err) |
| 217 | + } |
| 218 | + |
| 219 | + // Wait for the response |
| 220 | + var response message.Message |
| 221 | + for event := range eventCh { |
| 222 | + if event.Err() != nil { |
| 223 | + return fmt.Errorf("agent error: %w", event.Err()) |
| 224 | + } |
| 225 | + response = event.Response() |
| 226 | + } |
| 227 | + |
| 228 | + // Format and print the output |
| 229 | + content := "" |
| 230 | + if textContent := response.Content(); textContent != nil { |
| 231 | + content = textContent.Text |
| 232 | + } |
| 233 | + |
| 234 | + formattedOutput, err := format.FormatOutput(content, outputFormat) |
| 235 | + if err != nil { |
| 236 | + return fmt.Errorf("failed to format output: %w", err) |
| 237 | + } |
| 238 | + |
| 239 | + // Stop spinner before printing output |
| 240 | + if !quiet && s != nil { |
| 241 | + s.Stop() |
| 242 | + } |
| 243 | + |
| 244 | + // Print the formatted output to stdout |
| 245 | + fmt.Println(formattedOutput) |
| 246 | + |
| 247 | + // Shutdown the app |
| 248 | + app.Shutdown() |
| 249 | + |
| 250 | + return nil |
| 251 | + } |
| 252 | + |
| 253 | + // Run the default agent if no tool restrictions |
| 254 | + eventCh, err := app.PrimaryAgent.Run(ctx, session.ID, prompt) |
| 255 | + if err != nil { |
| 256 | + return fmt.Errorf("failed to run agent: %w", err) |
| 257 | + } |
| 258 | + |
| 259 | + // Wait for the response |
| 260 | + var response message.Message |
| 261 | + for event := range eventCh { |
| 262 | + if event.Err() != nil { |
| 263 | + return fmt.Errorf("agent error: %w", event.Err()) |
| 264 | + } |
| 265 | + response = event.Response() |
| 266 | + } |
| 267 | + |
| 268 | + // Get the text content from the response |
| 269 | + content := "" |
| 270 | + if textContent := response.Content(); textContent != nil { |
| 271 | + content = textContent.Text |
| 272 | + } |
| 273 | + |
| 274 | + // Format the output according to the specified format |
| 275 | + formattedOutput, err := format.FormatOutput(content, outputFormat) |
| 276 | + if err != nil { |
| 277 | + return fmt.Errorf("failed to format output: %w", err) |
| 278 | + } |
| 279 | + |
| 280 | + // Stop spinner before printing output |
| 281 | + if !quiet && s != nil { |
| 282 | + s.Stop() |
| 283 | + } |
| 284 | + |
| 285 | + // Print the formatted output to stdout |
| 286 | + fmt.Println(formattedOutput) |
| 287 | + |
| 288 | + // Shutdown the app |
| 289 | + app.Shutdown() |
| 290 | + |
| 291 | + return nil |
| 292 | +} |
0 commit comments