Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
bc81475
Add Doctor debugging command to debug section
jamesrochabrun Oct 3, 2025
5f64dd9
Fix Doctor command execution by using file redirection
jamesrochabrun Oct 3, 2025
164fda8
Fix Doctor to use interactive session instead of one-shot prompt
jamesrochabrun Oct 3, 2025
0ab71be
Add initial message to Doctor session to auto-start conversation
jamesrochabrun Oct 3, 2025
f6ec675
Fix raw mode error by removing stdin pipe
jamesrochabrun Oct 3, 2025
1fb702e
Doctor: Run diagnostics first, then create plan
jamesrochabrun Oct 3, 2025
c17aa97
Redesign Doctor with test-first approach
jamesrochabrun Oct 3, 2025
39eac8a
Implement Doctor with command execution and session resume
jamesrochabrun Oct 3, 2025
3c12600
Add separate Doctor function to avoid breaking existing code
jamesrochabrun Oct 3, 2025
52d3d65
Remove unused doctor launch functions
jamesrochabrun Oct 3, 2025
748386e
Fix: Source shell profile before executing doctor command
jamesrochabrun Oct 3, 2025
7fcd356
Doctor: run reproduction via Process, capture session_id, resume in T…
jamesrochabrun Oct 3, 2025
240bfed
Add captured output context to doctor session
jamesrochabrun Oct 3, 2025
2c4e27a
Fix: Preserve echo stdin prefix when resolving claude path
jamesrochabrun Oct 3, 2025
148798e
Fix: Add PTY retry for Ink raw mode error
jamesrochabrun Oct 3, 2025
f85135a
Fix: Use CI env vars and unbuffer for TTY-free execution
jamesrochabrun Oct 3, 2025
ae6ac1a
Refactor: Run doctor command in Terminal, not headless
jamesrochabrun Oct 3, 2025
46c4c5a
Fix: Avoid bash interpretation errors and resolve claude path
jamesrochabrun Oct 3, 2025
4d4857a
Fix: Swift multiline string indentation for heredoc content
jamesrochabrun Oct 3, 2025
5e3a295
Fix: Remove stdin piping to avoid Ink raw mode error on resume
jamesrochabrun Oct 3, 2025
7d58aa4
Fix: Pass debug report + output as first message when resuming
jamesrochabrun Oct 3, 2025
bc4a155
Fix: Remove heredoc to avoid Swift indentation errors
jamesrochabrun Oct 3, 2025
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
222 changes: 221 additions & 1 deletion Sources/ClaudeCodeCore/Services/TerminalLauncher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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 "<path>" && <rest>
// Find the first '&&' separator safely
if let rangeOfAnd = reproductionCommand.range(of: " && ") {
let cdPart = String(reproductionCommand[..<rangeOfAnd.lowerBound])
remaining = String(reproductionCommand[rangeOfAnd.upperBound...])

// cdPart should be: cd "<path>"
if let firstQuote = cdPart.firstIndex(of: "\"") {
let afterFirst = cdPart.index(after: firstQuote)
if let secondQuote = cdPart[afterFirst...].firstIndex(of: "\"") {
workingDir = String(cdPart[afterFirst..<secondQuote])
}
}
}
}

// At this point, `remaining` is either the original command or the part after the leading cd &&
// If the command is of the form: echo "..." | <cmd> ..., 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[..<pipeRange.upperBound]) // includes the trailing pipe+space
commandPortion = String(commandPortion[pipeRange.upperBound...])
}
}

// Identify the command token (first whitespace-delimited token)
let trimmed = commandPortion.trimmingCharacters(in: .whitespacesAndNewlines)
guard let endOfCmd = trimmed.firstIndex(where: { $0.isWhitespace }) ?? trimmed.endIndex as String.Index?, !trimmed.isEmpty else {
return (reproductionCommand, workingDir, NSError(domain: "TerminalLauncher", code: 3, userInfo: [NSLocalizedDescriptionKey: "Unable to parse command token from reproduction command"]))
}
let cmdToken = String(trimmed[..<endOfCmd])

// Resolve the executable path
guard let resolvedPath = findClaudeExecutable(command: cmdToken, additionalPaths: nil) else {
return (reproductionCommand, workingDir, NSError(domain: "TerminalLauncher", code: 1, userInfo: [NSLocalizedDescriptionKey: "Could not find '\(cmdToken)' command. Please ensure Claude Code CLI is installed or configure its path in preferences. "]))
}

// Replace only the first occurrence of cmdToken in the commandPortion
guard let tokenRangeInPortion = commandPortion.range(of: cmdToken) else {
return (reproductionCommand, workingDir, NSError(domain: "TerminalLauncher", code: 3, userInfo: [NSLocalizedDescriptionKey: "Failed to locate command token in reproduction command"]))
}
var replacedPortion = commandPortion
replacedPortion.replaceSubrange(tokenRangeInPortion, with: "\"\(resolvedPath)\"")

// Rebuild the full command with any leading parts preserved
let prepared: String
if reproductionCommand.hasPrefix("cd "), let rangeOfAnd = reproductionCommand.range(of: " && ") {
let prefix = String(reproductionCommand[..<rangeOfAnd.lowerBound])
prepared = prefix + " && " + leadingPrefix + replacedPortion
} else {
prepared = leadingPrefix + replacedPortion
}

return (prepared, workingDir, nil)
}
}
54 changes: 53 additions & 1 deletion Sources/ClaudeCodeCore/UI/GlobalSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -392,8 +392,22 @@ struct GlobalSettingsView: View {
.stroke(hasCommandInfo ? Color.secondary.opacity(0.2) : Color.secondary.opacity(0.15), lineWidth: 1)
)

HStack {
HStack(spacing: 8) {
Spacer()

Button(action: {
runDoctor()
}) {
HStack(spacing: 4) {
Image(systemName: "stethoscope")
Text("Run Doctor")
}
}
.buttonStyle(.bordered)
.disabled(!hasCommandInfo)
.help(hasCommandInfo ? "Launch debugging session in Terminal" : "Send a message first to generate debug info")
.padding(.top, 8)

Button(action: {
copyFullReportToClipboard()
}) {
Expand Down Expand Up @@ -762,6 +776,44 @@ struct GlobalSettingsView: View {
reportCopied = false
}
}

private func runDoctor() {
guard let viewModel = chatViewModel,
let report = viewModel.fullDebugReport,
let reproCommand = viewModel.terminalReproductionCommand else { return }

// Create doctor system prompt (instructions only, no data)
let doctorPrompt = """
You are a ClaudeCodeUI Debug Doctor.

Your task:
1. Review the debug report and command execution output in the first message
2. Compare the command output with what's expected in the debug report
3. Look for discrepancies (PATH differences, executable location, errors, etc.)
4. If you find issues, create a plan to fix them in priority order
5. If everything works fine, explain that the app is working correctly

Work in plan mode - propose fixes before executing them.
"""

// Launch Terminal with doctor session
Task {
if let error = await TerminalLauncher.launchDoctorByExecutingCommand(
reproductionCommand: reproCommand,
debugReport: report,
systemPrompt: doctorPrompt
) {
await MainActor.run {
let alert = NSAlert()
alert.messageText = "Failed to Launch Doctor"
alert.informativeText = error.localizedDescription
alert.alertStyle = .warning
alert.addButton(withTitle: "OK")
alert.runModal()
}
}
}
}
}


Expand Down