From 3f8f7ec5881640f40490b2f5d17504c6a390f773 Mon Sep 17 00:00:00 2001 From: adamdottv <2363879+adamdottv@users.noreply.github.com> Date: Tue, 27 May 2025 10:33:01 -0500 Subject: [PATCH 1/3] feat: claude 4 opus --- internal/llm/models/anthropic.go | 15 +++++++++++++++ internal/llm/models/bedrock.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/internal/llm/models/anthropic.go b/internal/llm/models/anthropic.go index f67a748424e2..d8f8c08469fa 100644 --- a/internal/llm/models/anthropic.go +++ b/internal/llm/models/anthropic.go @@ -10,6 +10,7 @@ const ( Claude35Haiku ModelID = "claude-3.5-haiku" Claude3Opus ModelID = "claude-3-opus" Claude4Sonnet ModelID = "claude-4-sonnet" + Claude4Opus ModelID = "claude-4-opus" ) // https://docs.anthropic.com/en/docs/about-claude/models/all-models @@ -68,6 +69,20 @@ var AnthropicModels = map[ModelID]Model{ CanReason: true, SupportsAttachments: true, }, + Claude4Opus: { + ID: Claude4Opus, + Name: "Claude 4 Opus", + Provider: ProviderAnthropic, + APIModel: "claude-opus-4-20250514", + CostPer1MIn: 15.0, + CostPer1MInCached: 18.75, + CostPer1MOutCached: 1.50, + CostPer1MOut: 75.0, + ContextWindow: 200000, + DefaultMaxTokens: 32000, + CanReason: true, + SupportsAttachments: true, + }, Claude35Haiku: { ID: Claude35Haiku, Name: "Claude 3.5 Haiku", diff --git a/internal/llm/models/bedrock.go b/internal/llm/models/bedrock.go index 06f825654137..8386bef9008d 100644 --- a/internal/llm/models/bedrock.go +++ b/internal/llm/models/bedrock.go @@ -5,6 +5,8 @@ const ( // Models BedrockClaude37Sonnet ModelID = "bedrock.claude-3.7-sonnet" + BedrockClaude4Sonnet ModelID = "bedrock.claude-4.0-sonnet" + BedrockClaude4Opus ModelID = "bedrock.claude-4.0-opus" ) var BedrockModels = map[ModelID]Model{ @@ -22,4 +24,32 @@ var BedrockModels = map[ModelID]Model{ CanReason: true, SupportsAttachments: true, }, + BedrockClaude4Sonnet: { + ID: BedrockClaude4Sonnet, + Name: "Bedrock: Claude 4 Sonnet", + Provider: ProviderBedrock, + APIModel: "anthropic.claude-sonnet-4-20250514-v1:0", + CostPer1MIn: 3.0, + CostPer1MInCached: 3.75, + CostPer1MOutCached: 0.30, + CostPer1MOut: 15.0, + ContextWindow: 200_000, + DefaultMaxTokens: 50_000, + CanReason: true, + SupportsAttachments: true, + }, + BedrockClaude4Opus: { + ID: BedrockClaude4Opus, + Name: "Bedrock: Claude 4 Opus", + Provider: ProviderBedrock, + APIModel: "anthropic.claude-opus-4-20250514-v1:0", + CostPer1MIn: 15.0, + CostPer1MInCached: 18.75, + CostPer1MOutCached: 1.50, + CostPer1MOut: 75.0, + ContextWindow: 200_000, + DefaultMaxTokens: 50_000, + CanReason: true, + SupportsAttachments: true, + }, } From c554430c59171e2dbec4b4d311e5f7d5a8107d0b Mon Sep 17 00:00:00 2001 From: James Pozdena Date: Thu, 5 Jun 2025 18:23:01 -0600 Subject: [PATCH 2/3] Nord theme (#64) --- internal/tui/theme/nord.go | 107 +++++++++++++++++++++++++++ internal/tui/theme/theme_test.go | 12 +++ www/src/content/docs/docs/themes.mdx | 1 + 3 files changed, 120 insertions(+) create mode 100644 internal/tui/theme/nord.go diff --git a/internal/tui/theme/nord.go b/internal/tui/theme/nord.go new file mode 100644 index 000000000000..e894f987d692 --- /dev/null +++ b/internal/tui/theme/nord.go @@ -0,0 +1,107 @@ +package theme + +import ( + "github.com/charmbracelet/lipgloss" +) + +// NordTheme implements the Theme interface with Nord colors. +// It provides both list and dark variants based on the Nord palette. +type NordTheme struct { + BaseTheme +} + +// NewNordTheme creates a new instance of the Nord theme. +func NewNordTheme() *NordTheme { + // Nord color palette from https://www.nordtheme.com/docs/colors-and-palettes + polarNight0 := "#2E3440" + polarNight1 := "#3B4252" + polarNight2 := "#434C5E" + polarNight3 := "#4C566A" + snowStorm0 := "#D8DEE9" + snowStorm1 := "#E5E9F0" + snowStorm2 := "#ECEFF4" + frost0 := "#8FBCBB" + frost1 := "#88C0D0" + frost2 := "#81A1C1" + frost3 := "#5E81AC" + aurora0 := "#BF616A" + // aurora1 := "#D08770" + aurora2 := "#EBCB8B" + aurora3 := "#A3BE8C" + aurora4 := "#B48EAD" + + theme := &NordTheme{} + + // Base colors + theme.PrimaryColor = lipgloss.AdaptiveColor{Dark: frost1, Light: frost1} + theme.SecondaryColor = lipgloss.AdaptiveColor{Dark: frost2, Light: frost2} + theme.AccentColor = lipgloss.AdaptiveColor{Dark: frost0, Light: frost0} + + // Status colors + theme.ErrorColor = lipgloss.AdaptiveColor{Dark: aurora0, Light: aurora0} + theme.WarningColor = lipgloss.AdaptiveColor{Dark: aurora2, Light: aurora2} + theme.SuccessColor = lipgloss.AdaptiveColor{Dark: aurora3, Light: aurora3} + theme.InfoColor = lipgloss.AdaptiveColor{Dark: frost0, Light: frost0} + + // Text colors + theme.TextColor = lipgloss.AdaptiveColor{Dark: snowStorm2, Light: polarNight0} + theme.TextMutedColor = lipgloss.AdaptiveColor{Dark: snowStorm0, Light: polarNight3} + theme.TextEmphasizedColor = lipgloss.AdaptiveColor{Dark: aurora2, Light: aurora2} + + // Background colors + theme.BackgroundColor = lipgloss.AdaptiveColor{Dark: polarNight0, Light: snowStorm2} + theme.BackgroundSecondaryColor = lipgloss.AdaptiveColor{Dark: polarNight1, Light: snowStorm1} + theme.BackgroundDarkerColor = lipgloss.AdaptiveColor{Dark: polarNight2, Light: snowStorm0} + + // Border colors + theme.BorderNormalColor = lipgloss.AdaptiveColor{Dark: polarNight3, Light: snowStorm1} + theme.BorderFocusedColor = lipgloss.AdaptiveColor{Dark: frost3, Light: frost3} + theme.BorderDimColor = lipgloss.AdaptiveColor{Dark: polarNight2, Light: snowStorm0} + + // Diff view colors + theme.DiffAddedColor = lipgloss.AdaptiveColor{Dark: aurora3, Light: aurora3} + theme.DiffRemovedColor = lipgloss.AdaptiveColor{Dark: aurora0, Light: aurora0} + theme.DiffContextColor = lipgloss.AdaptiveColor{Dark: polarNight3, Light: snowStorm0} + theme.DiffHunkHeaderColor = lipgloss.AdaptiveColor{Dark: frost3, Light: frost3} + theme.DiffHighlightAddedColor = lipgloss.AdaptiveColor{Dark: frost3, Light: frost3} + theme.DiffHighlightRemovedColor = lipgloss.AdaptiveColor{Dark: aurora0, Light: aurora0} + theme.DiffAddedBgColor = lipgloss.AdaptiveColor{Dark: polarNight2, Light: snowStorm0} + theme.DiffRemovedBgColor = lipgloss.AdaptiveColor{Dark: polarNight2, Light: snowStorm0} + theme.DiffContextBgColor = lipgloss.AdaptiveColor{Dark: polarNight1, Light: snowStorm1} + theme.DiffLineNumberColor = lipgloss.AdaptiveColor{Dark: polarNight3, Light: snowStorm1} + theme.DiffAddedLineNumberBgColor = lipgloss.AdaptiveColor{Dark: polarNight2, Light: snowStorm0} + theme.DiffRemovedLineNumberBgColor = lipgloss.AdaptiveColor{Dark: polarNight2, Light: snowStorm0} + + // Markdown colors + theme.MarkdownTextColor = lipgloss.AdaptiveColor{Dark: snowStorm2, Light: polarNight0} + theme.MarkdownHeadingColor = lipgloss.AdaptiveColor{Dark: frost3, Light: frost3} + theme.MarkdownLinkColor = lipgloss.AdaptiveColor{Dark: frost0, Light: frost0} + theme.MarkdownLinkTextColor = lipgloss.AdaptiveColor{Dark: frost1, Light: frost1} + theme.MarkdownCodeColor = lipgloss.AdaptiveColor{Dark: aurora3, Light: aurora3} + theme.MarkdownBlockQuoteColor = lipgloss.AdaptiveColor{Dark: aurora2, Light: aurora2} + theme.MarkdownEmphColor = lipgloss.AdaptiveColor{Dark: aurora2, Light: aurora2} + theme.MarkdownStrongColor = lipgloss.AdaptiveColor{Dark: aurora0, Light: aurora0} + theme.MarkdownHorizontalRuleColor = lipgloss.AdaptiveColor{Dark: polarNight3, Light: snowStorm1} + theme.MarkdownListItemColor = lipgloss.AdaptiveColor{Dark: polarNight2, Light: snowStorm1} + theme.MarkdownListEnumerationColor = lipgloss.AdaptiveColor{Dark: polarNight2, Light: snowStorm1} + theme.MarkdownImageColor = lipgloss.AdaptiveColor{Dark: frost1, Light: frost1} + theme.MarkdownImageTextColor = lipgloss.AdaptiveColor{Dark: frost1, Light: frost1} + theme.MarkdownCodeBlockColor = lipgloss.AdaptiveColor{Dark: polarNight2, Light: snowStorm0} + + // Syntax highsnowStorming colors + theme.SyntaxCommentColor = lipgloss.AdaptiveColor{Dark: polarNight3, Light: snowStorm2} + theme.SyntaxKeywordColor = lipgloss.AdaptiveColor{Dark: aurora4, Light: aurora4} + theme.SyntaxFunctionColor = lipgloss.AdaptiveColor{Dark: frost1, Light: frost1} + theme.SyntaxVariableColor = lipgloss.AdaptiveColor{Dark: frost0, Light: frost0} + theme.SyntaxStringColor = lipgloss.AdaptiveColor{Dark: aurora2, Light: aurora2} + theme.SyntaxNumberColor = lipgloss.AdaptiveColor{Dark: aurora2, Light: aurora2} + theme.SyntaxTypeColor = lipgloss.AdaptiveColor{Dark: aurora3, Light: aurora3} + theme.SyntaxOperatorColor = lipgloss.AdaptiveColor{Dark: aurora4, Light: aurora4} + theme.SyntaxPunctuationColor = lipgloss.AdaptiveColor{Dark: polarNight2, Light: snowStorm2} + + return theme +} + +func init() { + RegisterTheme("nord", NewNordTheme()) +} diff --git a/internal/tui/theme/theme_test.go b/internal/tui/theme/theme_test.go index 790ee3aa8a37..f64ba080d99b 100644 --- a/internal/tui/theme/theme_test.go +++ b/internal/tui/theme/theme_test.go @@ -47,6 +47,18 @@ func TestThemeRegistration(t *testing.T) { t.Errorf("Monokai theme is not registered") } + // Check if "nord" theme is registered + nordFound := false + for _, themeName := range availableThemes { + if themeName == "nord" { + nordFound = true + break + } + } + if !nordFound { + t.Errorf("Nord theme is not registered") + } + // Try to get the themes and make sure they're not nil catppuccin := GetTheme("catppuccin") if catppuccin == nil { diff --git a/www/src/content/docs/docs/themes.mdx b/www/src/content/docs/docs/themes.mdx index e691a22e7f08..e8e7b594bd8d 100644 --- a/www/src/content/docs/docs/themes.mdx +++ b/www/src/content/docs/docs/themes.mdx @@ -14,6 +14,7 @@ The following predefined themes are available: - `flexoki` - `gruvbox` - `monokai` +- `nord` - `onedark` - `tokyonight` - `tron` From 37cc1a477b69bba4a2cfce1b22897aefd8c62fb6 Mon Sep 17 00:00:00 2001 From: Allison Durham Date: Mon, 9 Jun 2025 14:27:44 -0700 Subject: [PATCH 3/3] initial `--permission-prompt-tool` commit --- cmd/non_interactive_mode.go | 130 ++++++++++++++++++++---- cmd/root.go | 4 +- internal/permission/permission.go | 158 ++++++++++++++++++++++++++++-- 3 files changed, 264 insertions(+), 28 deletions(-) diff --git a/cmd/non_interactive_mode.go b/cmd/non_interactive_mode.go index 5023839c3cb7..4542f3bdf574 100644 --- a/cmd/non_interactive_mode.go +++ b/cmd/non_interactive_mode.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "os" + "strings" "sync" "time" @@ -80,11 +81,84 @@ func filterTools(allTools []tools.BaseTool, allowedTools, excludedTools []string return filteredTools } +// toolWrapper wraps tools.BaseTool to implement permission.Tool interface +type toolWrapper struct { + tool tools.BaseTool +} + +func (tw *toolWrapper) Info() permission.ToolInfo { + info := tw.tool.Info() + return permission.ToolInfo{ + Name: info.Name, + Description: info.Description, + Parameters: info.Parameters, + Required: info.Required, + } +} + +func (tw *toolWrapper) Run(ctx context.Context, params permission.ToolCall) (permission.ToolResponse, error) { + toolsParams := tools.ToolCall{ + ID: params.ID, + Name: params.Name, + Input: params.Input, + } + + result, err := tw.tool.Run(ctx, toolsParams) + if err != nil { + return permission.ToolResponse{}, err + } + + return permission.ToolResponse{ + Type: string(result.Type), + Content: result.Content, + Metadata: result.Metadata, + IsError: result.IsError, + }, nil +} + +// findPermissionTool finds the specified permission prompt tool in the tools list +func findPermissionTool(allTools []tools.BaseTool, permissionToolName string) (tools.BaseTool, string, error) { + // Parse the claude-code format mcp__{server}__{tool} to OpenCode format {server}_{tool} + if !strings.HasPrefix(permissionToolName, "mcp__") { + return nil, "", fmt.Errorf("invalid permission prompt tool format: %s (expected: mcp__{server}__{tool})", permissionToolName) + } + + // Remove "mcp__" prefix and convert "__" to "_" + parsed := strings.TrimPrefix(permissionToolName, "mcp__") + openCodeToolName := strings.Replace(parsed, "__", "_", 1) + + // Find the permission tool + var permissionTool tools.BaseTool + var availableMCPTools []string + + for _, tool := range allTools { + toolInfo := tool.Info() + // Check if this is an MCP tool (contains underscore indicating server_tool format) + if strings.Contains(toolInfo.Name, "_") { + availableMCPTools = append(availableMCPTools, "mcp__"+strings.Replace(toolInfo.Name, "_", "__", 1)) + if toolInfo.Name == openCodeToolName { + permissionTool = tool + } + } + } + + if permissionTool == nil { + if len(availableMCPTools) == 0 { + return nil, "", fmt.Errorf("MCP tool %s (passed via --permission-prompt-tool) not found. Available MCP tools: none", permissionToolName) + } + return nil, "", fmt.Errorf("MCP tool %s (passed via --permission-prompt-tool) not found. Available MCP tools: %s", + permissionToolName, strings.Join(availableMCPTools, ", ")) + } + + slog.Info("Found permission prompt tool", "tool", permissionTool.Info().Name) + return permissionTool, openCodeToolName, nil +} + // 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 { +func handleNonInteractiveMode(ctx context.Context, prompt string, outputFormat format.OutputFormat, quiet bool, verbose bool, allowedTools, excludedTools []string, permissionPromptTool 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) + "allowedTools", allowedTools, "excludedTools", excludedTools, "permissionPromptTool", permissionPromptTool) // Sanity check for mutually exclusive flags if quiet && verbose { @@ -161,8 +235,40 @@ func handleNonInteractiveMode(ctx context.Context, prompt string, outputFormat f // Set the session as current app.CurrentSession = &session - // Auto-approve all permissions for this session - permission.AutoApproveSession(ctx, session.ID) + // Initialize MCP tools synchronously in non-interactive mode (if any are configured) + mcpServers := config.Get().MCPServers + if len(mcpServers) > 0 { + mcpCtx, mcpCancel := context.WithTimeout(ctx, 10*time.Second) + agent.GetMcpTools(mcpCtx, app.Permissions) + mcpCancel() + } + + // Get all tools including MCP tools + allTools := agent.PrimaryAgentTools( + app.Permissions, + app.Sessions, + app.Messages, + app.History, + app.LSPClients, + ) + + // Handle permission prompt tool setup + if permissionPromptTool != "" { + // Find the permission tool + permissionTool, openCodeToolName, err := findPermissionTool(allTools, permissionPromptTool) + if err != nil { + return err + } + + // Store the permission tool for this session (wrapped to match interface) + permission.SetPermissionPromptTool(ctx, session.ID, &toolWrapper{tool: permissionTool}) + + // Add permission tool to excluded tools so it gets filtered out of LLM tools + excludedTools = append(excludedTools, openCodeToolName) + } else { + // Auto-approve all permissions for this session (current behavior) + permission.AutoApproveSession(ctx, session.ID) + } // Create the user message _, err = app.Messages.Create(ctx, session.ID, message.CreateMessageParams{ @@ -175,21 +281,7 @@ func handleNonInteractiveMode(ctx context.Context, prompt string, outputFormat f // 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 + // Filter tools based on allowed/excluded lists (permission tool automatically excluded if present) filteredTools := filterTools(allTools, allowedTools, excludedTools) // Log the filtered tools for debugging diff --git a/cmd/root.go b/cmd/root.go index ab102afe65cf..e273b732d446 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -115,8 +115,9 @@ to assist developers in writing, debugging, and understanding code directly from // Get tool restriction flags allowedTools, _ := cmd.Flags().GetStringSlice("allowedTools") excludedTools, _ := cmd.Flags().GetStringSlice("excludedTools") + permissionPromptTool, _ := cmd.Flags().GetString("permission-prompt-tool") - return handleNonInteractiveMode(cmd.Context(), prompt, outputFormat, quiet, verbose, allowedTools, excludedTools) + return handleNonInteractiveMode(cmd.Context(), prompt, outputFormat, quiet, verbose, allowedTools, excludedTools, permissionPromptTool) } // Run LSP auto-discovery @@ -352,6 +353,7 @@ func init() { 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)") + rootCmd.Flags().String("permission-prompt-tool", "", "MCP tool for handling permission prompts in non-interactive mode") // Make allowedTools and excludedTools mutually exclusive rootCmd.MarkFlagsMutuallyExclusive("allowedTools", "excludedTools") diff --git a/internal/permission/permission.go b/internal/permission/permission.go index 4fa39a061d92..b649fd39b1f0 100644 --- a/internal/permission/permission.go +++ b/internal/permission/permission.go @@ -2,6 +2,7 @@ package permission import ( "context" + "encoding/json" "errors" "fmt" "path/filepath" @@ -17,6 +18,35 @@ import ( var ErrorPermissionDenied = errors.New("permission denied") +// Tool represents a tool that can be executed (avoiding import cycle with tools package) +type Tool interface { + Info() ToolInfo + Run(ctx context.Context, params ToolCall) (ToolResponse, error) +} + +// ToolInfo represents tool information +type ToolInfo struct { + Name string + Description string + Parameters map[string]any + Required []string +} + +// ToolCall represents a tool call +type ToolCall struct { + ID string `json:"id"` + Name string `json:"name"` + Input string `json:"input"` +} + +// ToolResponse represents a tool response +type ToolResponse struct { + Type string `json:"type"` + Content string `json:"content"` + Metadata string `json:"metadata,omitempty"` + IsError bool `json:"is_error"` +} + type CreatePermissionRequest struct { SessionID string `json:"session_id"` ToolName string `json:"tool_name"` @@ -58,16 +88,20 @@ type Service interface { Request(ctx context.Context, opts CreatePermissionRequest) bool AutoApproveSession(ctx context.Context, sessionID string) IsAutoApproved(ctx context.Context, sessionID string) bool + SetPermissionPromptTool(ctx context.Context, sessionID string, tool Tool) + GetPermissionPromptTool(ctx context.Context, sessionID string) (Tool, bool) + ParseMCPResponse(ctx context.Context, response string) bool } type permissionService struct { broker *pubsub.Broker[PermissionRequest] responseBroker *pubsub.Broker[PermissionResponse] - sessionPermissions map[string][]PermissionRequest - pendingRequests sync.Map - autoApproveSessions map[string]bool - mu sync.RWMutex + sessionPermissions map[string][]PermissionRequest + pendingRequests sync.Map + autoApproveSessions map[string]bool + permissionPromptTool map[string]Tool // sessionID -> actual tool instance + mu sync.RWMutex } var globalPermissionService *permissionService @@ -77,10 +111,11 @@ func InitService() error { return fmt.Errorf("permission service already initialized") } globalPermissionService = &permissionService{ - broker: pubsub.NewBroker[PermissionRequest](), - responseBroker: pubsub.NewBroker[PermissionResponse](), - sessionPermissions: make(map[string][]PermissionRequest), - autoApproveSessions: make(map[string]bool), + broker: pubsub.NewBroker[PermissionRequest](), + responseBroker: pubsub.NewBroker[PermissionResponse](), + sessionPermissions: make(map[string][]PermissionRequest), + autoApproveSessions: make(map[string]bool), + permissionPromptTool: make(map[string]Tool), } return nil } @@ -139,6 +174,13 @@ func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRe return true } + // Check if we have a permission prompt tool configured for this session + if tool, hasTool := s.permissionPromptTool[opts.SessionID]; hasTool { + s.mu.RUnlock() + return s.callPermissionPromptTool(ctx, opts, tool) + } + s.mu.RUnlock() + requestPath := opts.Path if !filepath.IsAbs(requestPath) { requestPath = filepath.Join(config.WorkingDirectory(), requestPath) @@ -205,6 +247,94 @@ func (s *permissionService) IsAutoApproved(ctx context.Context, sessionID string return s.autoApproveSessions[sessionID] } +func (s *permissionService) SetPermissionPromptTool(ctx context.Context, sessionID string, tool Tool) { + s.mu.Lock() + defer s.mu.Unlock() + s.permissionPromptTool[sessionID] = tool + + slog.Info("Set permission prompt tool for session", "sessionID", sessionID, "tool", tool.Info().Name) +} + +func (s *permissionService) GetPermissionPromptTool(ctx context.Context, sessionID string) (Tool, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + tool, exists := s.permissionPromptTool[sessionID] + return tool, exists +} + +func (s *permissionService) ParseMCPResponse(ctx context.Context, response string) bool { + // Parse MCP tool response according to claude-code spec + var responseObj map[string]interface{} + if err := json.Unmarshal([]byte(response), &responseObj); err != nil { + slog.Error("Failed to parse MCP permission response as JSON", "response", response, "error", err) + return false + } + + // Check the behavior field according to claude-code spec + behavior, ok := responseObj["behavior"].(string) + if !ok { + slog.Error("Missing or invalid 'behavior' field in MCP permission response", "response", response) + return false + } + + switch behavior { + case "allow": + slog.Debug("Permission granted by MCP tool", "response", response) + return true + case "deny": + if message, ok := responseObj["message"].(string); ok { + slog.Info("Permission denied by MCP tool", "reason", message) + } else { + slog.Info("Permission denied by MCP tool") + } + return false + default: + slog.Error("Invalid behavior in MCP permission response", "behavior", behavior) + return false + } +} + +func (s *permissionService) callPermissionPromptTool(ctx context.Context, opts CreatePermissionRequest, tool Tool) bool { + // Create the permission request payload matching claude-code format + requestPayload := map[string]interface{}{ + "id": opts.SessionID + "_" + opts.ToolName + "_" + opts.Action, + "session_id": opts.SessionID, + "tool_name": opts.ToolName, + "description": opts.Description, + "action": opts.Action, + "params": opts.Params, + "path": opts.Path, + } + + // Convert payload to JSON string (same format as existing MCP tools expect) + payloadJSON, err := json.Marshal(requestPayload) + if err != nil { + slog.Error("Failed to marshal permission request payload", "error", err) + return false + } + + // Call the tool using the existing tool interface + toolCall := ToolCall{ + ID: opts.SessionID + "_permission_check", + Name: tool.Info().Name, + Input: string(payloadJSON), + } + + result, err := tool.Run(ctx, toolCall) + if err != nil { + slog.Error("MCP permission tool execution failed", "error", err, "tool", tool.Info().Name) + return false + } + + if result.IsError { + slog.Error("MCP permission tool returned error", "error", result.Content, "tool", tool.Info().Name) + return false + } + + // Parse the response using our existing parser + return s.ParseMCPResponse(ctx, result.Content) +} + func (s *permissionService) Subscribe(ctx context.Context) <-chan pubsub.Event[PermissionRequest] { return s.broker.Subscribe(ctx) } @@ -244,3 +374,15 @@ func SubscribeToRequests(ctx context.Context) <-chan pubsub.Event[PermissionRequ func SubscribeToResponses(ctx context.Context) <-chan pubsub.Event[PermissionResponse] { return GetService().SubscribeToResponseEvents(ctx) } + +func SetPermissionPromptTool(ctx context.Context, sessionID string, tool Tool) { + GetService().SetPermissionPromptTool(ctx, sessionID, tool) +} + +func GetPermissionPromptTool(ctx context.Context, sessionID string) (Tool, bool) { + return GetService().GetPermissionPromptTool(ctx, sessionID) +} + +func ParseMCPResponse(ctx context.Context, response string) bool { + return GetService().ParseMCPResponse(ctx, response) +}