diff --git a/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift b/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift index 010a3f5..cb7aec5 100644 --- a/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift +++ b/Sources/ClaudeCodeCore/Services/TerminalLauncher.swift @@ -90,7 +90,8 @@ public struct TerminalLauncher { ) } } - + + /// Finds the full path to the Claude executable /// - Parameters: /// - command: The command name to search for (e.g., "claude") @@ -152,4 +153,223 @@ public struct TerminalLauncher { return nil } + + /// NEW: Launches Doctor by executing reproduction command (headless), capturing session, then resuming in Terminal + /// - Parameters: + /// - reproductionCommand: The full command from terminalReproductionCommand + /// - debugReport: The full debug report to send as context + /// - systemPrompt: The doctor system prompt (instructions only) + /// - Returns: An error if launching fails, nil on success + public static func launchDoctorByExecutingCommand( + reproductionCommand: String, + debugReport: String, + systemPrompt: String + ) async -> Error? { + // Resolve the claude executable path and extract working directory + let (preparedCommand, workingDir, resolveError) = prepareCommandWithResolvedClaudePath(reproductionCommand) + if let err = resolveError { + return err + } + + // Launch Terminal with a script that executes command, captures output, and auto-resumes with context + // This runs everything in Terminal (has TTY) - no headless execution needed + return launchTerminalWithCaptureAndResume( + command: preparedCommand, + workingDir: workingDir, + originalCommand: reproductionCommand, + debugReport: debugReport, + systemPrompt: systemPrompt + ) + } + + // MARK: - Private helpers for Doctor flow + + /// Launches Terminal with a script that runs command, captures output, and auto-resumes with context + private static func launchTerminalWithCaptureAndResume( + command: String, + workingDir: String?, + originalCommand: String, + debugReport: String, + systemPrompt: String + ) -> Error? { + // Extract claude executable path from prepared command for use in resume + // The command may be like: echo "..." | "/path/to/claude" args... + let claudePath: String + if let match = command.range(of: #"\"([^\"]+/claude[^\"]*)\"|\s(/[^\s]+/claude)"#, options: .regularExpression) { + let matched = String(command[match]) + claudePath = matched.replacingOccurrences(of: "\"", with: "").trimmingCharacters(in: .whitespaces) + } else { + // Fallback to finding claude executable + claudePath = findClaudeExecutable(command: "claude", additionalPaths: nil) ?? "claude" + } + + // Write files to avoid quoting hell + let tempDir = NSTemporaryDirectory() + let promptPath = (tempDir as NSString).appendingPathComponent("doctor_prompt_\(UUID().uuidString).txt") + let originalCmdPath = (tempDir as NSString).appendingPathComponent("doctor_original_cmd_\(UUID().uuidString).txt") + let debugReportPath = (tempDir as NSString).appendingPathComponent("doctor_debug_report_\(UUID().uuidString).txt") + + do { + try systemPrompt.write(toFile: promptPath, atomically: true, encoding: .utf8) + try originalCommand.write(toFile: originalCmdPath, atomically: true, encoding: .utf8) + try debugReport.write(toFile: debugReportPath, atomically: true, encoding: .utf8) + } catch { + return NSError(domain: "TerminalLauncher", code: 3, userInfo: [NSLocalizedDescriptionKey: "Failed to write temp files: \(error.localizedDescription)"]) + } + + // Escape paths for shell + let escapedPromptPath = promptPath.replacingOccurrences(of: "'", with: "'\\''") + let escapedOriginalCmdPath = originalCmdPath.replacingOccurrences(of: "'", with: "'\\''") + let escapedDebugReportPath = debugReportPath.replacingOccurrences(of: "'", with: "'\\''") + let escapedClaudePath = claudePath.replacingOccurrences(of: "'", with: "'\\''") + + // Build cd prefix if needed + let cdPrefix: String + if let dir = workingDir, !dir.isEmpty { + let escapedDir = dir.replacingOccurrences(of: "'", with: "'\\''") + cdPrefix = "cd '\(escapedDir)'\n" + } else { + cdPrefix = "" + } + + // Create Terminal script that captures command output and auto-resumes + let scriptPath = (tempDir as NSString).appendingPathComponent("doctor_\(UUID().uuidString).command") + let scriptContent = """ + #!/bin/bash -l + + echo "═══════════════════════════════════════" + echo "ClaudeCodeUI Doctor - Executing Command" + echo "═══════════════════════════════════════" + echo "" + + \(cdPrefix) + # Execute reproduction command and capture all output + echo "Running: $(cat '\(escapedOriginalCmdPath)')" + echo "" + OUTPUT=$(\(command) 2>&1) + EXIT_CODE=$? + + echo "" + echo "═══════════════════════════════════════" + echo "Command Completed (exit code: $EXIT_CODE)" + echo "═══════════════════════════════════════" + echo "" + + # Extract session ID from output (first line should be JSON with session_id) + SESSION_ID=$(echo "$OUTPUT" | head -20 | grep -o '"session_id":"[^"]*"' | head -1 | cut -d'"' -f4) + + if [ -z "$SESSION_ID" ]; then + echo "❌ ERROR: Could not extract session_id from command output" + echo "" + echo "Output preview:" + echo "$OUTPUT" | head -20 + echo "" + echo "Press Enter to close..." + read + exit 1 + fi + + echo "✅ Captured session: $SESSION_ID" + echo "" + echo "═══════════════════════════════════════" + echo "Launching Doctor Session..." + echo "═══════════════════════════════════════" + echo "" + + # Build context message with debug report + command output + CONTEXT_MSG="Debug Report:"$'\n'"$(cat '\(escapedDebugReportPath)')"$'\n\nCommand Output:\n'"$OUTPUT" + + # Resume session with context as first message + '\(escapedClaudePath)' -r "$SESSION_ID" -p "$CONTEXT_MSG" --append-system-prompt "$(cat '\(escapedPromptPath)')" --permission-mode plan + + # Cleanup + rm -f '\(escapedPromptPath)' '\(escapedOriginalCmdPath)' '\(escapedDebugReportPath)' + """ + + do { + try scriptContent.write(toFile: scriptPath, atomically: true, encoding: .utf8) + let attributes = [FileAttributeKey.posixPermissions: 0o755] + try FileManager.default.setAttributes(attributes, ofItemAtPath: scriptPath) + + let url = URL(fileURLWithPath: scriptPath) + NSWorkspace.shared.open(url) + + // Cleanup script after delay + DispatchQueue.main.asyncAfter(deadline: .now() + 10) { + try? FileManager.default.removeItem(atPath: scriptPath) + } + + return nil + } catch { + return NSError(domain: "TerminalLauncher", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to launch Terminal: \(error.localizedDescription)"]) + } + } + + /// Returns a tuple of (preparedCommand, workingDir, error) + /// Replaces the first occurrence of the claude command with its resolved absolute path, + /// preserving all quoting and piping. Also extracts the working directory from an initial `cd` if present. + private static func prepareCommandWithResolvedClaudePath(_ reproductionCommand: String) -> (String, String?, Error?) { + // Extract working directory if the command starts with: cd "..." && ... + var workingDir: String? + var remaining = reproductionCommand + + if reproductionCommand.hasPrefix("cd ") { + // Expect format: cd "" && + // Find the first '&&' separator safely + if let rangeOfAnd = reproductionCommand.range(of: " && ") { + let cdPart = String(reproductionCommand[.." + if let firstQuote = cdPart.firstIndex(of: "\"") { + let afterFirst = cdPart.index(after: firstQuote) + if let secondQuote = cdPart[afterFirst...].firstIndex(of: "\"") { + workingDir = String(cdPart[afterFirst.. ..., we must preserve the echo prefix for execution, + // but identify the CLI token from the part after the pipe. + var leadingPrefix = "" + var commandPortion = remaining + if commandPortion.hasPrefix("echo ") { + if let pipeRange = commandPortion.range(of: " | ") { + leadingPrefix = String(commandPortion[..