diff --git a/scarf/scarf/Core/Services/HermesFileService.swift b/scarf/scarf/Core/Services/HermesFileService.swift index 57e139a..2d21c23 100644 --- a/scarf/scarf/Core/Services/HermesFileService.swift +++ b/scarf/scarf/Core/Services/HermesFileService.swift @@ -1577,6 +1577,39 @@ struct HermesFileService: Sendable { } } + /// Split-stream variant of `runHermesCLI`. Use this when you need to + /// parse stdout (e.g. JSON output) without stderr contamination, and + /// surface stderr separately as a user-facing error message. Transport + /// failures land in `stderr` with an empty `stdout`. + @discardableResult + nonisolated func runHermesCLISplit(args: [String], timeout: TimeInterval = 60, stdinInput: String? = nil) -> (exitCode: Int32, stdout: String, stderr: String) { + let binary: String + if context.isRemote { + binary = context.paths.hermesBinary + } else { + guard let local = hermesBinaryPath() else { return (-1, "", "hermes binary not found") } + binary = local + } + + let stdinData = stdinInput?.data(using: .utf8) + do { + let result = try transport.runProcess( + executable: binary, + args: args, + stdin: stdinData, + timeout: timeout + ) + return (result.exitCode, result.stdoutString, result.stderrString) + } catch let error as TransportError { + let message = error.diagnosticStderr.isEmpty + ? (error.errorDescription ?? "transport error") + : error.diagnosticStderr + return (-1, "", message) + } catch { + return (-1, "", error.localizedDescription) + } + } + // MARK: - File I/O /// Read a UTF-8 text file through the transport. Missing files and any diff --git a/scarf/scarf/Features/Kanban/ViewModels/KanbanViewModel.swift b/scarf/scarf/Features/Kanban/ViewModels/KanbanViewModel.swift index f6ae882..52dadfc 100644 --- a/scarf/scarf/Features/Kanban/ViewModels/KanbanViewModel.swift +++ b/scarf/scarf/Features/Kanban/ViewModels/KanbanViewModel.swift @@ -71,18 +71,17 @@ final class KanbanViewModel { isLoading = true let svc = fileService let filter = statusFilter - let result = await Task.detached { () -> (data: Data?, exitCode: Int32, stderr: String) in + let result = await Task.detached { () -> (exitCode: Int32, stdout: String, stderr: String) in var args = ["kanban", "list", "--json"] if filter != .all { args.append(contentsOf: ["--status", filter.rawValue]) } - let r = svc.runHermesCLI(args: args, timeout: 15) - return (r.output.data(using: .utf8), r.exitCode, r.output) + return svc.runHermesCLISplit(args: args, timeout: 15) }.value defer { isLoading = false } - guard result.exitCode == 0, let data = result.data else { + guard result.exitCode == 0 else { lastError = result.stderr.isEmpty ? "kanban list failed (\(result.exitCode))" : result.stderr @@ -90,6 +89,12 @@ final class KanbanViewModel { return } + guard let data = result.stdout.data(using: .utf8) else { + lastError = "kanban list returned non-UTF8 output" + tasks = [] + return + } + do { let decoded = try JSONDecoder().decode([HermesKanbanTask].self, from: data) tasks = decoded @@ -98,7 +103,7 @@ final class KanbanViewModel { // Hermes may print a "no matching tasks" line as text instead of // empty JSON; handle gracefully so the UI shows an empty list // without raising an error banner. - if String(data: data, encoding: .utf8)?.contains("no matching tasks") == true { + if result.stdout.contains("no matching tasks") { tasks = [] lastError = nil return