package claude import ( "bufio" "context" "encoding/json" "fmt" "io" "log" "os" "path/filepath" "strings" "time" goujson "github.com/yaoapp/gou/json" agentContext "github.com/yaoapp/yao/agent/context" "github.com/yaoapp/yao/agent/i18n" "github.com/yaoapp/yao/agent/output/message" infraSandbox "github.com/yaoapp/yao/sandbox" "github.com/yaoapp/yao/sandbox/ipc" ) // Options for Claude executor (copied from parent package to avoid import cycle) type Options struct { Command string Image string MaxMemory string MaxCPU float64 Timeout time.Duration Arguments map[string]interface{} UserID string ChatID string MCPConfig []byte MCPTools map[string]*ipc.MCPTool // MCP tools to expose via IPC SkillsDir string SystemPrompt string // System prompt from assistant prompts.yml ConnectorHost string ConnectorKey string Model string ConnectorOptions map[string]interface{} // Extra connector options (e.g., thinking, max_tokens) Secrets map[string]string // Secrets to pass to container (e.g., GITHUB_TOKEN) } // Executor implements the sandbox.Executor interface for Claude CLI type Executor struct { manager *infraSandbox.Manager containerName string opts *Options workDir string loadingMsgID string // Loading message ID for tool execution updates } // NewExecutor creates a new Claude executor func NewExecutor(manager *infraSandbox.Manager, opts interface{}) (*Executor, error) { if manager == nil { return nil, fmt.Errorf("manager is required") } // Type assertion to get options var execOpts *Options switch o := opts.(type) { case *Options: execOpts = o default: // Try to convert from map or other struct return nil, fmt.Errorf("invalid options type: %T", opts) } if execOpts == nil { return nil, fmt.Errorf("options is required") } if execOpts.UserID == "" { return nil, fmt.Errorf("UserID is required") } if execOpts.ChatID == "" { return nil, fmt.Errorf("ChatID is required") } // Create or get container // Note: IPC session is created by manager.createContainer, socket is already bind mounted ctx := context.Background() container, err := manager.GetOrCreate(ctx, execOpts.UserID, execOpts.ChatID) if err != nil { return nil, fmt.Errorf("failed to create container: %w", err) } // Get workspace directory from config config := manager.GetConfig() workDir := config.ContainerWorkDir if workDir == "" { workDir = "/workspace" } return &Executor{ manager: manager, containerName: container.Name, opts: execOpts, workDir: workDir, }, nil } // SetLoadingMsgID sets the loading message ID for tool execution updates func (e *Executor) SetLoadingMsgID(id string) { e.loadingMsgID = id } // Stream runs the Claude CLI with streaming output func (e *Executor) Stream(ctx *agentContext.Context, messages []agentContext.Message, handler message.StreamFunc) (*agentContext.CompletionResponse, error) { // Create a cancellable context for this stream operation // We need to handle both: // 1. HTTP context cancellation (client disconnect) // 2. InterruptController cancellation (user clicks "stop" button) // // Note on InterruptController: // - ctx.Interrupt.Context() is only cancelled when InterruptForce && len(Messages) == 0 // - When user sends messages with the interrupt, the context is NOT cancelled // - We use ctx.Interrupt.IsInterrupted() to check for any interrupt signal stdCtx, cancelFunc := context.WithCancel(context.Background()) defer cancelFunc() // Start a goroutine to monitor for interrupts and HTTP context cancellation go func() { ticker := time.NewTicker(500 * time.Millisecond) defer ticker.Stop() for { select { case <-stdCtx.Done(): // Already cancelled, exit return case <-ticker.C: // Check if there's a pending interrupt signal using Peek() // This works even when Messages are included (which doesn't cancel the context) if ctx != nil && ctx.Interrupt != nil { if signal := ctx.Interrupt.Peek(); signal != nil { cancelFunc() return } } // Check InterruptController.IsInterrupted() (for context-cancelled interrupts) if ctx != nil && ctx.Interrupt != nil && ctx.Interrupt.IsInterrupted() { cancelFunc() return } // Check HTTP context if ctx != nil && ctx.Context != nil { select { case <-ctx.Context.Done(): cancelFunc() return default: } } } } }() // Set MCP tools for this request (dynamic, runtime configuration) if len(e.opts.MCPTools) > 0 { ipcManager := e.manager.GetIPCManager() if ipcManager != nil { if session, ok := ipcManager.Get(e.opts.ChatID); ok { session.SetMCPTools(e.opts.MCPTools) } } } // Prepare environment: write configs and copy skills if err := e.prepareEnvironment(stdCtx); err != nil { return nil, fmt.Errorf("failed to prepare environment: %w", err) } // Check if we should skip Claude CLI execution // Skip if no prompts, no skills, and no MCP config if e.shouldSkipClaudeCLI() { // Return empty response - hooks can use sandbox API to do their work return &agentContext.CompletionResponse{ ID: fmt.Sprintf("sandbox-skip-%d", time.Now().UnixNano()), Model: "sandbox", Created: time.Now().Unix(), Role: "assistant", Content: "", FinishReason: agentContext.FinishReasonStop, }, nil } // Check if this is a continuation (Claude CLI session exists in workspace) isContinuation := e.hasExistingSession(stdCtx) // Build Claude CLI command using stored options cmd, env, err := BuildCommandWithContinuation(messages, e.opts, isContinuation) if err != nil { return nil, fmt.Errorf("failed to build command: %w", err) } // Prepare execution options execOpts := &infraSandbox.ExecOptions{ WorkDir: e.workDir, Env: env, } if e.opts != nil && e.opts.Timeout > 0 { execOpts.Timeout = e.opts.Timeout } reader, err := e.manager.Stream(stdCtx, e.containerName, cmd, execOpts) if err != nil { return nil, fmt.Errorf("failed to execute command: %w", err) } // Ensure reader is closed when context is cancelled or function returns // This is important for cleanup when user clicks "stop" done := make(chan struct{}) defer func() { close(done) reader.Close() }() // Monitor for context cancellation and forcefully kill Claude CLI process go func() { // Also start a ticker to periodically check context status for debugging ticker := time.NewTicker(10 * time.Second) defer ticker.Stop() for { select { case <-stdCtx.Done(): // First, kill the Claude CLI process inside the container // This is important because closing the reader/connection alone may not stop the process // Use a background context since stdCtx is already cancelled killCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() // Kill claude process (the Claude CLI binary) e.manager.KillProcess(killCtx, e.containerName, "claude") // Also close the reader to unblock any pending reads reader.Close() return case <-done: // Normal completion, nothing to do return case <-ticker.C: // Periodic check - no action needed } } }() // DEBUG: Tee the reader to write raw output to a log file for debugging debugLogPath := e.workDir + "/claude-cli-raw.log" debugReader := e.createDebugReader(stdCtx, reader, debugLogPath) // Parse streaming output (uses e.loadingMsgID set via SetLoadingMsgID) return e.parseStream(ctx, debugReader, handler) } // shouldSkipClaudeCLI checks if Claude CLI execution should be skipped // Skip when: no system prompt, no skills, and no MCP config func (e *Executor) shouldSkipClaudeCLI() bool { hasPrompts := e.opts.SystemPrompt != "" hasSkills := e.opts.SkillsDir != "" hasMCP := len(e.opts.MCPConfig) > 0 // If any of these are present, execute Claude CLI return !hasPrompts && !hasSkills && !hasMCP } // hasExistingSession checks if Claude CLI has an existing session in the workspace // Claude CLI stores session data in $HOME/.claude/projects/ (which is /workspace/.claude/projects/) // If session data exists, we should use --continue to resume the session func (e *Executor) hasExistingSession(ctx context.Context) bool { // Check if /workspace/.claude/projects/ directory has any content // This indicates a previous session exists sessionDir := e.workDir + "/.claude/projects" files, err := e.manager.ListDir(ctx, e.containerName, sessionDir) if err != nil { // Directory doesn't exist or error reading - no existing session return false } // If there are any files/directories in the projects folder, session exists return len(files) > 0 } // prepareEnvironment prepares the container environment before execution // This includes: claude-proxy config, MCP config, and Skills directory func (e *Executor) prepareEnvironment(ctx context.Context) error { // 1. Write claude-proxy config and start the proxy if err := e.startClaudeProxy(ctx); err != nil { return fmt.Errorf("failed to start claude-proxy: %w", err) } // 2. Write MCP config if provided if len(e.opts.MCPConfig) > 0 { if err := e.writeMCPConfig(ctx); err != nil { return fmt.Errorf("failed to write MCP config: %w", err) } } // 3. Copy Skills directory if provided if e.opts.SkillsDir != "" { if err := e.copySkillsDirectory(ctx); err != nil { // Non-fatal: log warning but continue // Skills might not exist or be optional _ = err // Ignore error, skills are optional } } return nil } // startClaudeProxy writes proxy config and starts claude-proxy func (e *Executor) startClaudeProxy(ctx context.Context) error { // Skip if no connector configured (e.g., test containers without claude-proxy) if e.opts.ConnectorHost == "" || e.opts.ConnectorKey == "" { return nil } // Build proxy config configJSON, err := BuildProxyConfig(e.opts) if err != nil { return fmt.Errorf("failed to build proxy config: %w", err) } // Create config directory (outside workspace for security - user can't see api_key/secrets) // /tmp/.yao/ is not visible to user's file manager configDir := "/tmp/.yao" if _, err := e.manager.Exec(ctx, e.containerName, []string{"mkdir", "-p", configDir}, nil); err != nil { return fmt.Errorf("failed to create config directory %s: %w", configDir, err) } // Write config to secure location (not in /workspace/) configPath := configDir + "/proxy.json" if err := e.manager.WriteFile(ctx, e.containerName, configPath, configJSON); err != nil { return fmt.Errorf("failed to write config to %s: %w", configPath, err) } // Start the proxy (only if start-claude-proxy exists in the image) result, err := e.manager.Exec(ctx, e.containerName, []string{"which", "start-claude-proxy"}, &infraSandbox.ExecOptions{ WorkDir: e.workDir, }) if err != nil || result.ExitCode != 0 { // start-claude-proxy not available (e.g., alpine test image), skip return nil } // Start the proxy result, err = e.manager.Exec(ctx, e.containerName, []string{"start-claude-proxy"}, &infraSandbox.ExecOptions{ WorkDir: e.workDir, Env: map[string]string{ "WORKSPACE": e.workDir, }, }) if err != nil { return fmt.Errorf("failed to start claude-proxy: %w", err) } if result.ExitCode != 0 { return fmt.Errorf("claude-proxy failed to start: %s", result.Stderr) } return nil } // writeMCPConfig writes the MCP configuration file to the container workspace func (e *Executor) writeMCPConfig(ctx context.Context) error { if len(e.opts.MCPConfig) == 0 { return nil } // Write MCP config to workspace (.mcp.json) mcpPath := e.workDir + "/.mcp.json" if err := e.manager.WriteFile(ctx, e.containerName, mcpPath, e.opts.MCPConfig); err != nil { return fmt.Errorf("failed to write MCP config to %s: %w", mcpPath, err) } return nil } // copySkillsDirectory copies the skills directory to the container func (e *Executor) copySkillsDirectory(ctx context.Context) error { if e.opts.SkillsDir == "" { return nil } // Target path in container: /workspace/.claude/skills/ // This follows Claude CLI's expected skills location claudeDir := e.workDir + "/.claude" // Create .claude directory first if _, err := e.manager.Exec(ctx, e.containerName, []string{"mkdir", "-p", claudeDir}, nil); err != nil { return fmt.Errorf("failed to create .claude directory: %w", err) } // Copy skills from host to container // CopyToContainer extracts tar to containerPath, and createTarFromPath uses // filepath.Dir(hostPath) as base, so if hostPath is /path/to/skills, // tar entries are like "skills/skill-name/SKILL.md" // Extracting to /workspace/.claude/ gives us /workspace/.claude/skills/skill-name/SKILL.md if err := e.manager.CopyToContainer(ctx, e.containerName, e.opts.SkillsDir, claudeDir); err != nil { return fmt.Errorf("failed to copy skills to container: %w", err) } return nil } // Execute runs the Claude CLI and returns the response func (e *Executor) Execute(ctx *agentContext.Context, messages []agentContext.Message) (*agentContext.CompletionResponse, error) { return e.Stream(ctx, messages, nil) } // debugWriter wraps an io.Reader to write all data to a debug log file type debugWriter struct { reader io.Reader logFile *os.File buffer []byte } func (d *debugWriter) Read(p []byte) (n int, err error) { n, err = d.reader.Read(p) if n > 0 && d.logFile != nil { // Write raw bytes to log file d.logFile.Write(p[:n]) d.logFile.Sync() } return n, err } func (d *debugWriter) Close() error { if d.logFile != nil { d.logFile.Close() } return nil } // createDebugReader creates a tee reader that writes to a debug log file // The log file is written to the container's workspace for inspection func (e *Executor) createDebugReader(ctx context.Context, reader io.ReadCloser, logPath string) io.Reader { // Create a local temp file for debug logging // We write to a local file first, then copy to container when done localLogPath := "/tmp/claude-cli-debug-" + e.containerName + ".log" logFile, err := os.Create(localLogPath) if err != nil { return reader } // Write header logFile.WriteString("=== Claude CLI Raw Output Debug Log ===\n") logFile.WriteString(fmt.Sprintf("Container: %s\n", e.containerName)) logFile.WriteString(fmt.Sprintf("Time: %s\n", time.Now().Format(time.RFC3339))) logFile.WriteString(fmt.Sprintf("WorkDir: %s\n", e.workDir)) logFile.WriteString("=== BEGIN OUTPUT ===\n") logFile.Sync() return &debugWriter{ reader: reader, logFile: logFile, } } // parseStream parses Claude CLI streaming output (stream-json format) // Claude CLI output format with --include-partial-messages: // - {"type":"system","subtype":"init",...} - initialization // - {"type":"stream_event","event":{"delta":{"type":"text_delta","text":"..."}}} - real-time text deltas // - {"type":"assistant","message":{...,"content":[{"type":"text","text":"..."}],...}} - complete messages // - {"type":"result","subtype":"success",...,"result":"..."} - final result func (e *Executor) parseStream(ctx *agentContext.Context, reader io.Reader, handler message.StreamFunc) (*agentContext.CompletionResponse, error) { scanner := bufio.NewScanner(reader) // Increase buffer size for potentially large outputs buf := make([]byte, 0, 64*1024) scanner.Buffer(buf, 1024*1024) var textContent strings.Builder var toolCalls []agentContext.ToolCall var model string var usage *message.UsageInfo var finalResult string messageStarted := false // Track if we've sent ChunkMessageStart prepLoadingClosed := false // Track if "preparing sandbox" loading has been closed // Tool input accumulation state type toolState struct { name string index int inputJSON strings.Builder loadingID string // Each tool has its own loading message } var currentTool *toolState var lastToolLoadingID string // Track the last tool loading ID to close it // Helper function to close "preparing sandbox" loading on first output closePrepLoading := func() { if !prepLoadingClosed && e.loadingMsgID != "" && ctx != nil { doneMsg := &message.Message{ MessageID: e.loadingMsgID, Delta: true, DeltaAction: message.DeltaReplace, Type: message.TypeLoading, Props: map[string]interface{}{ "message": "", "done": true, }, } ctx.Send(doneMsg) prepLoadingClosed = true } } lineCount := 0 // Get the underlying context for cancellation checks var stdCtx context.Context if ctx != nil && ctx.Context != nil { stdCtx = ctx.Context } else { stdCtx = context.Background() } for scanner.Scan() { // Check for context cancellation on each iteration select { case <-stdCtx.Done(): return nil, stdCtx.Err() default: // Continue processing } line := scanner.Text() lineCount++ if line == "" { continue } // Try to parse as JSON (Claude CLI --output-format stream-json) var msg map[string]interface{} if err := json.Unmarshal([]byte(line), &msg); err != nil { // Not JSON, might be plain text output textContent.WriteString(line) textContent.WriteString("\n") continue } msgType, _ := msg["type"].(string) // Process Claude CLI stream-json message types switch msgType { case "system": // Initialization message - extract model if available if m, ok := msg["model"].(string); ok { model = m } case "stream_event": // Real-time streaming event (from --include-partial-messages) // Format: {"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"..."}}} if event, ok := msg["event"].(map[string]interface{}); ok { eventType, _ := event["type"].(string) switch eventType { case "content_block_start": // Check if this is a tool_use block starting // Format: {"event":{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"...","name":"Write","input":{}}}} if contentBlock, ok := event["content_block"].(map[string]interface{}); ok { blockType, _ := contentBlock["type"].(string) if blockType == "tool_use" { toolName, _ := contentBlock["name"].(string) blockIndex := 0 if idx, ok := event["index"].(float64); ok { blockIndex = int(idx) } if toolName != "" && ctx != nil { // Close "preparing sandbox" loading on first tool closePrepLoading() // Close previous tool loading if exists if lastToolLoadingID != "" { doneMsg := &message.Message{ MessageID: lastToolLoadingID, Delta: true, DeltaAction: message.DeltaReplace, Type: message.TypeLoading, Props: map[string]interface{}{ "message": "", "done": true, }, } ctx.Send(doneMsg) } // Create new loading message for this tool locale := ctx.Locale toolLoadingMsg := &message.Message{ Type: message.TypeLoading, Props: map[string]interface{}{ "message": getToolDescription(toolName, locale), }, } newLoadingID, _ := ctx.SendStream(toolLoadingMsg) // Initialize tool state for input accumulation currentTool = &toolState{ name: toolName, index: blockIndex, loadingID: newLoadingID, } lastToolLoadingID = newLoadingID log.Printf("[Sandbox] Tool started: %s", toolName) } } } case "content_block_delta": if delta, ok := event["delta"].(map[string]interface{}); ok { deltaType, _ := delta["type"].(string) switch deltaType { case "text_delta": if text, ok := delta["text"].(string); ok && text != "" { // Close "preparing sandbox" loading on first text output closePrepLoading() // Send to stream handler for real-time output if handler != nil { // Send ChunkMessageStart first if not already started if !messageStarted { startData := message.EventMessageStartData{ MessageID: fmt.Sprintf("sandbox-%d", time.Now().UnixNano()), Type: "text", Timestamp: time.Now().UnixMilli(), } startDataJSON, _ := json.Marshal(startData) handler(message.ChunkMessageStart, startDataJSON) messageStarted = true } handler(message.ChunkText, []byte(text)) } // Also accumulate for final response textContent.WriteString(text) } case "input_json_delta": // Accumulate tool input JSON fragments if currentTool != nil { if partialJSON, ok := delta["partial_json"].(string); ok { currentTool.inputJSON.WriteString(partialJSON) } } } } case "content_block_stop": // Tool input complete - parse and update loading with detailed info if currentTool != nil && currentTool.loadingID != "" && ctx != nil { inputStr := currentTool.inputJSON.String() if inputStr != "" { // Use gou/json.Parse for fault-tolerant parsing locale := ctx.Locale detailedMsg := getToolDetailedDescription(currentTool.name, inputStr, locale) if detailedMsg != "" { toolMsg := &message.Message{ MessageID: currentTool.loadingID, Delta: true, DeltaAction: message.DeltaReplace, Type: message.TypeLoading, Props: map[string]interface{}{ "message": detailedMsg, }, } ctx.Send(toolMsg) log.Printf("[Sandbox] Tool: %s -> %s", currentTool.name, detailedMsg) } } // Note: Don't close loading here - it will be closed when next tool starts or at end // Reset tool state but keep lastToolLoadingID to close it later currentTool = nil } } } case "assistant": // Assistant message - extract content // With --include-partial-messages, we receive real-time text via stream_event // The assistant message contains the full accumulated content if msgData, ok := msg["message"].(map[string]interface{}); ok { // Get model from message if m, ok := msgData["model"].(string); ok && model == "" { model = m } // Check if this is the final message (has stop_reason) stopReason, hasStopReason := msgData["stop_reason"].(string) isFinalMessage := hasStopReason && stopReason != "" // Extract content from final message // This serves as a fallback if stream_event wasn't received if isFinalMessage { if contentArr, ok := msgData["content"].([]interface{}); ok { for _, item := range contentArr { if contentItem, ok := item.(map[string]interface{}); ok { itemType, _ := contentItem["type"].(string) switch itemType { case "text": // Only use this if we haven't already accumulated text from stream_event if textContent.Len() == 0 { if text, ok := contentItem["text"].(string); ok && text != "" { textContent.WriteString(text) // Send to stream handler if available if handler != nil { if !messageStarted { startData := message.EventMessageStartData{ MessageID: fmt.Sprintf("sandbox-%d", time.Now().UnixNano()), Type: "text", Timestamp: time.Now().UnixMilli(), } startDataJSON, _ := json.Marshal(startData) handler(message.ChunkMessageStart, startDataJSON) messageStarted = true } handler(message.ChunkText, []byte(text)) } } } case "tool_use": toolName := getString(contentItem, "name") toolCall := agentContext.ToolCall{ ID: getString(contentItem, "id"), Type: agentContext.ToolTypeFunction, Function: agentContext.Function{ Name: toolName, }, } // Get input as JSON string var inputJSONStr string if input, ok := contentItem["input"]; ok { if inputJSON, err := json.Marshal(input); err == nil { inputJSONStr = string(inputJSON) toolCall.Function.Arguments = inputJSONStr } } toolCalls = append(toolCalls, toolCall) // Create tool loading message (from complete assistant message) // This is a fallback for when stream_event wasn't received if toolName != "" && ctx != nil { // Close previous tool loading if exists if lastToolLoadingID != "" { doneMsg := &message.Message{ MessageID: lastToolLoadingID, Delta: true, DeltaAction: message.DeltaReplace, Type: message.TypeLoading, Props: map[string]interface{}{ "message": "", "done": true, }, } ctx.Send(doneMsg) } // Create new loading for this tool locale := ctx.Locale detailedMsg := getToolDetailedDescription(toolName, inputJSONStr, locale) if detailedMsg != "" { toolLoadingMsg := &message.Message{ Type: message.TypeLoading, Props: map[string]interface{}{ "message": detailedMsg, }, } newLoadingID, _ := ctx.SendStream(toolLoadingMsg) lastToolLoadingID = newLoadingID log.Printf("[Sandbox] Tool: %s -> %s", toolName, detailedMsg) } } } } } } } // Extract usage (from any message that has it) if usageData, ok := msgData["usage"].(map[string]interface{}); ok { usage = &message.UsageInfo{} if v, ok := usageData["input_tokens"].(float64); ok { usage.PromptTokens = int(v) } if v, ok := usageData["output_tokens"].(float64); ok { usage.CompletionTokens = int(v) } usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens } } case "result": // Final result message // Check if this is an error result (is_error: true) isError, _ := msg["is_error"].(bool) if result, ok := msg["result"].(string); ok { if isError { // This is an error - return it as an error return nil, fmt.Errorf("Claude CLI error: %s", result) } finalResult = result } // Send done signal to handler (only if message was started and not an error) if handler != nil && messageStarted && !isError { handler(message.ChunkMessageEnd, nil) } case "error": // Error message if errMsg, ok := msg["error"].(string); ok { return nil, fmt.Errorf("Claude CLI error: %s", errMsg) } if errObj, ok := msg["error"].(map[string]interface{}); ok { if errMsg, ok := errObj["message"].(string); ok { return nil, fmt.Errorf("Claude CLI error: %s", errMsg) } } } } if err := scanner.Err(); err != nil { return nil, fmt.Errorf("error reading stream: %w", err) } // Close the last tool loading message if exists if lastToolLoadingID != "" && ctx != nil { doneMsg := &message.Message{ MessageID: lastToolLoadingID, Delta: true, DeltaAction: message.DeltaReplace, Type: message.TypeLoading, Props: map[string]interface{}{ "message": "", "done": true, }, } ctx.Send(doneMsg) } // Use final result if available, otherwise use accumulated text content content := textContent.String() if finalResult != "" && content == "" { content = finalResult } // Build response response := &agentContext.CompletionResponse{ ID: fmt.Sprintf("sandbox-%d", time.Now().UnixNano()), Model: model, Created: time.Now().Unix(), Role: "assistant", Content: content, FinishReason: agentContext.FinishReasonStop, } // Add tool calls if any if len(toolCalls) > 0 { response.ToolCalls = toolCalls response.FinishReason = agentContext.FinishReasonToolCalls } // Add usage if available if usage != nil { response.Usage = usage } return response, nil } // truncateStr truncates a string to maxLen characters func truncateStr(s string, maxLen int) string { if len(s) <= maxLen { return s } return s[:maxLen] + "..." } // getToolDescription returns a human-readable, localized description for a Claude CLI tool func getToolDescription(toolName string, locale string) string { // Map tool names to i18n keys toolKeys := map[string]string{ "Read": "sandbox.tool.read", "Write": "sandbox.tool.write", "Edit": "sandbox.tool.edit", "StrReplace": "sandbox.tool.edit", "Bash": "sandbox.tool.bash", "Shell": "sandbox.tool.bash", "Glob": "sandbox.tool.glob", "Grep": "sandbox.tool.grep", "LS": "sandbox.tool.ls", "Task": "sandbox.tool.task", "WebSearch": "sandbox.tool.web_search", "WebFetch": "sandbox.tool.web_fetch", "TodoWrite": "sandbox.tool.todo_write", "AskQuestion": "sandbox.tool.ask_question", "SwitchMode": "sandbox.tool.switch_mode", "ReadLints": "sandbox.tool.read_lints", "EditNotebook": "sandbox.tool.edit_notebook", } if key, ok := toolKeys[toolName]; ok { return i18n.T(locale, key) } // For unknown tools, use the unknown key and replace {{name}} manually template := i18n.T(locale, "sandbox.tool.unknown") return strings.Replace(template, "{{name}}", toolName, 1) } // getToolDetailedDescription returns a detailed description with specific parameters // It parses the tool input JSON and extracts key information to show users func getToolDetailedDescription(toolName string, inputJSON string, locale string) string { // Parse the input JSON using fault-tolerant parser parsed, err := goujson.Parse(inputJSON) if err != nil { // Fall back to basic description if parsing fails return getToolDescription(toolName, locale) } input, ok := parsed.(map[string]interface{}) if !ok { return getToolDescription(toolName, locale) } // Extract key information based on tool type var detail string switch toolName { case "Bash", "Shell": // Show the command being executed if cmd, ok := input["command"].(string); ok && cmd != "" { // Truncate long commands if len(cmd) > 50 { cmd = cmd[:47] + "..." } detail = cmd } case "Read": // Show the file being read if path, ok := input["path"].(string); ok && path != "" { detail = filepath.Base(path) } case "Write": // Show the file being written // Note: Claude CLI uses "file_path" for Write tool, not "path" if path, ok := input["file_path"].(string); ok && path != "" { detail = filepath.Base(path) } else if path, ok := input["path"].(string); ok && path != "" { detail = filepath.Base(path) } case "Edit", "StrReplace": // Show the file being edited if path, ok := input["path"].(string); ok && path != "" { detail = filepath.Base(path) } case "Glob": // Show the glob pattern if pattern, ok := input["glob_pattern"].(string); ok && pattern != "" { detail = pattern } else if pattern, ok := input["pattern"].(string); ok && pattern != "" { detail = pattern } case "Grep": // Show the search pattern if pattern, ok := input["pattern"].(string); ok && pattern != "" { if len(pattern) > 30 { pattern = pattern[:27] + "..." } detail = pattern } case "LS": // Show the directory if path, ok := input["target_directory"].(string); ok && path != "" { detail = filepath.Base(path) } else if path, ok := input["path"].(string); ok && path != "" { detail = filepath.Base(path) } case "WebSearch": // Show the search query if query, ok := input["search_term"].(string); ok && query != "" { if len(query) > 40 { query = query[:37] + "..." } detail = query } else if query, ok := input["query"].(string); ok && query != "" { if len(query) > 40 { query = query[:37] + "..." } detail = query } case "WebFetch": // Show the URL if url, ok := input["url"].(string); ok && url != "" { // Extract domain from URL if len(url) > 50 { url = url[:47] + "..." } detail = url } case "Task": // Show the task description if desc, ok := input["description"].(string); ok && desc != "" { if len(desc) > 40 { desc = desc[:37] + "..." } detail = desc } } // Build the message with detail baseMsg := getToolDescription(toolName, locale) if detail != "" { return baseMsg + ": " + detail } return baseMsg } // ReadFile reads a file from the container func (e *Executor) ReadFile(ctx context.Context, path string) ([]byte, error) { // Make path absolute if not if !strings.HasPrefix(path, "/") { path = e.workDir + "/" + path } return e.manager.ReadFile(ctx, e.containerName, path) } // WriteFile writes content to a file in the container func (e *Executor) WriteFile(ctx context.Context, path string, content []byte) error { // Make path absolute if not if !strings.HasPrefix(path, "/") { path = e.workDir + "/" + path } return e.manager.WriteFile(ctx, e.containerName, path, content) } // ListDir lists directory contents in the container func (e *Executor) ListDir(ctx context.Context, path string) ([]infraSandbox.FileInfo, error) { // Make path absolute if not if !strings.HasPrefix(path, "/") { path = e.workDir + "/" + path } return e.manager.ListDir(ctx, e.containerName, path) } // Exec executes a command in the container func (e *Executor) Exec(ctx context.Context, cmd []string) (string, error) { result, err := e.manager.Exec(ctx, e.containerName, cmd, &infraSandbox.ExecOptions{ WorkDir: e.workDir, }) if err != nil { return "", err } if result.ExitCode != 0 { return result.Stdout, fmt.Errorf("command exited with code %d: %s", result.ExitCode, result.Stderr) } return result.Stdout, nil } // GetWorkDir returns the container workspace directory func (e *Executor) GetWorkDir() string { return e.workDir } // Close releases the executor resources and removes the container // Note: IPC session is managed by sandbox.Manager.Remove() func (e *Executor) Close() error { if e.manager != nil && e.containerName != "" { ctx := context.Background() return e.manager.Remove(ctx, e.containerName) } return nil } // Helper function to get string from map func getString(m map[string]interface{}, key string) string { if v, ok := m[key].(string); ok { return v } return "" }