diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/SkillsViewModel.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/SkillsViewModel.swift index dbe8820..0d964ca 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/SkillsViewModel.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/SkillsViewModel.swift @@ -149,7 +149,12 @@ public final class SkillsViewModel { if source != "all" { args += ["--source", source] } let result = Self.runHermes(executable: bin, args: args, transport: xport, timeout: 30) let parsed = HermesSkillsHubParser.parseHubList(result.output) - await self?.finishBrowse(results: parsed, exitCode: result.exitCode, isSearch: false) + await self?.finishBrowse( + results: parsed, + exitCode: result.exitCode, + rawOutput: result.output, + isSearch: false + ) } } @@ -168,7 +173,12 @@ public final class SkillsViewModel { if source != "all" { args += ["--source", source] } let result = Self.runHermes(executable: bin, args: args, transport: xport, timeout: 30) let parsed = HermesSkillsHubParser.parseHubList(result.output) - await self?.finishBrowse(results: parsed, exitCode: result.exitCode, isSearch: true) + await self?.finishBrowse( + results: parsed, + exitCode: result.exitCode, + rawOutput: result.output, + isSearch: true + ) } } @@ -244,18 +254,54 @@ public final class SkillsViewModel { // about than the prior interleaved `MainActor.run` chains. @MainActor - private func finishBrowse(results: [HermesHubSkill], exitCode: Int32, isSearch: Bool) async { + private func finishBrowse( + results: [HermesHubSkill], + exitCode: Int32, + rawOutput: String, + isSearch: Bool + ) async { isHubLoading = false hubResults = results if results.isEmpty { - hubMessage = isSearch - ? "No matches" - : (exitCode == 0 ? "No results" : "Browse failed") + if exitCode == 0 { + hubMessage = isSearch ? "No matches" : "No results" + } else { + let label = isSearch ? "Search failed" : "Browse failed" + let detail = Self.firstSignificantLine(rawOutput) + hubMessage = detail.isEmpty + ? "\(label) (exit \(exitCode))" + : "\(label): \(detail)" + } } else { hubMessage = nil } } + /// Extract the first non-empty, non-decorative line from CLI output — + /// used to surface the actual error reason in `hubMessage` instead of a + /// canned "Browse failed". Skips Rich box-drawing chrome and ANSI noise + /// so the message stays readable in a one-line banner. + nonisolated private static func firstSignificantLine(_ output: String) -> String { + let stripped = output + .replacingOccurrences( + of: #"\u{001B}\[[0-9;]*m"#, + with: "", + options: .regularExpression + ) + for raw in stripped.components(separatedBy: "\n") { + let line = raw.trimmingCharacters(in: .whitespaces) + guard !line.isEmpty else { continue } + if line.unicodeScalars.allSatisfy({ scalar in + let v = scalar.value + // Skip pure box-drawing rows (U+2500..U+257F) so the + // diagnostic surfaces the actual error text below them. + return (v >= 0x2500 && v <= 0x257F) || scalar == " " + }) { continue } + return String(line.prefix(160)) + } + return "" + } + @MainActor private func finishInstall(identifier: String, exitCode: Int32) async { isHubLoading = false diff --git a/scarf/Packages/ScarfIOS/Sources/ScarfIOS/CitadelServerTransport.swift b/scarf/Packages/ScarfIOS/Sources/ScarfIOS/CitadelServerTransport.swift index b177368..3ec9357 100644 --- a/scarf/Packages/ScarfIOS/Sources/ScarfIOS/CitadelServerTransport.swift +++ b/scarf/Packages/ScarfIOS/Sources/ScarfIOS/CitadelServerTransport.swift @@ -331,29 +331,61 @@ public final class CitadelServerTransport: ServerTransport, @unchecked Sendable timeout: TimeInterval? ) async throws -> ProcessResult { let client = try await connectionHolder.ssh() - let cmd = Self.shellJoin([executable] + args) - // Citadel's executeCommand accumulates stdout into a ByteBuffer. - // stderr isn't separately exposed — we fold it into the output - // via `2>&1` so error paths still give callers something to - // show. Exit code is similarly not directly exposed; on non- - // zero exit Citadel throws, so we map that to a commandFailed - // error with the captured output as stderr. + // Citadel's raw exec channel doesn't source the user's shell rc + // files, so non-interactive SSH sessions land with a stripped + // PATH (typically just `/usr/bin:/bin`). pipx installs `hermes` + // at `~/.local/bin/hermes`, and many of hermes's sub-tools + // (git/curl/python) live in homebrew prefixes that the remote + // sshd would otherwise add via login-shell init. Mac's OpenSSH + // sshd handles this transparently; Citadel does not. We extend + // PATH inline so bare `hermes` resolves AND any subprocess it + // spawns can still find its tools. + let cmd = "PATH=\"$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH\" " + + Self.shellJoin([executable] + args) + // Citadel's `executeCommand` discards captured output when the + // remote exits non-zero (it throws `CommandFailed` and the + // accumulated ByteBuffer is lost). That breaks legitimate cases + // like `hermes skills browse` printing a full table and *then* + // exiting non-zero — callers see nothing and report "Browse + // failed". Drive `executeCommandStream` directly so we can + // collect stdout + stderr regardless of exit code, and surface + // the real exit status. + let stream: AsyncThrowingStream do { - let buffer = try await client.executeCommand(cmd + " 2>&1") - var buf = buffer - let str = buf.readString(length: buf.readableBytes) ?? "" - return ProcessResult( - exitCode: 0, - stdout: Data(str.utf8), - stderr: Data() - ) + stream = try await client.executeCommandStream(cmd) } catch { return ProcessResult( - exitCode: 1, + exitCode: -1, stdout: Data(), stderr: Data(error.localizedDescription.utf8) ) } + var stdout = Data() + var stderr = Data() + var exitCode: Int32 = 0 + do { + for try await chunk in stream { + switch chunk { + case .stdout(var buf): + if let s = buf.readString(length: buf.readableBytes) { + stdout.append(Data(s.utf8)) + } + case .stderr(var buf): + if let s = buf.readString(length: buf.readableBytes) { + stderr.append(Data(s.utf8)) + } + } + } + } catch let failed as SSHClient.CommandFailed { + exitCode = Int32(failed.exitCode) + } catch { + // Network / channel-level failure mid-stream — preserve any + // partial output and report -1 so callers can distinguish + // from a clean non-zero remote exit. + stderr.append(Data(error.localizedDescription.utf8)) + exitCode = -1 + } + return ProcessResult(exitCode: exitCode, stdout: stdout, stderr: stderr) } private func asyncSnapshotSQLite(remotePath: String) async throws -> URL { @@ -506,16 +538,27 @@ private actor ConnectionHolder { if let existing = sshClient, existing.isConnected { return existing } + // Replacing the SSHClient invalidates any cached SFTPClient that + // was bound to the previous (now-dead) connection. Drop it here + // so the next sftp() call re-opens against the new client; without + // this, every SFTP-backed call after a reconnect throws "channel + // closed" until the app is restarted. + if let oldSftp = sftpClient { + try? await oldSftp.close() + sftpClient = nil + } let client = try await openSSH() sshClient = client return client } func sftp() async throws -> SFTPClient { + // Pulling SSH first ensures a stale-after-reconnect cached + // sftpClient is cleared in `ssh()` before we read it here. + let client = try await ssh() if let existing = sftpClient { return existing } - let client = try await ssh() let sftp = try await client.openSFTP() sftpClient = sftp return sftp diff --git a/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-1024x1024@1x.png b/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-1024x1024@1x.png new file mode 100644 index 0000000..13d0ba6 Binary files /dev/null and b/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-1024x1024@1x.png differ diff --git a/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-20x20@2x.png b/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-20x20@2x.png new file mode 100644 index 0000000..a669274 Binary files /dev/null and b/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-20x20@2x.png differ diff --git a/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-20x20@3x.png b/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-20x20@3x.png new file mode 100644 index 0000000..4ead788 Binary files /dev/null and b/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-20x20@3x.png differ diff --git a/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-29x29@2x.png b/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-29x29@2x.png new file mode 100644 index 0000000..4faa950 Binary files /dev/null and b/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-29x29@2x.png differ diff --git a/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-29x29@3x.png b/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-29x29@3x.png new file mode 100644 index 0000000..3105adb Binary files /dev/null and b/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-29x29@3x.png differ diff --git a/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-38x38@2x.png b/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-38x38@2x.png new file mode 100644 index 0000000..7dcbc48 Binary files /dev/null and b/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-38x38@2x.png differ diff --git a/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-38x38@3x.png b/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-38x38@3x.png new file mode 100644 index 0000000..6257586 Binary files /dev/null and b/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-38x38@3x.png differ diff --git a/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-40x40@2x.png b/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-40x40@2x.png new file mode 100644 index 0000000..f0c9218 Binary files /dev/null and b/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-40x40@2x.png differ diff --git a/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-40x40@3x.png b/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-40x40@3x.png new file mode 100644 index 0000000..66a5864 Binary files /dev/null and b/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-40x40@3x.png differ diff --git a/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-60x60@2x.png b/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-60x60@2x.png new file mode 100644 index 0000000..9026b05 Binary files /dev/null and b/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-60x60@2x.png differ diff --git a/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-60x60@3x.png b/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-60x60@3x.png new file mode 100644 index 0000000..a88d4f9 Binary files /dev/null and b/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-60x60@3x.png differ diff --git a/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-64x64@2x.png b/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-64x64@2x.png new file mode 100644 index 0000000..5f193a2 Binary files /dev/null and b/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-64x64@2x.png differ diff --git a/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-64x64@3x.png b/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-64x64@3x.png new file mode 100644 index 0000000..99a82ad Binary files /dev/null and b/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-64x64@3x.png differ diff --git a/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-68x68@2x.png b/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-68x68@2x.png new file mode 100644 index 0000000..03533ce Binary files /dev/null and b/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-68x68@2x.png differ diff --git a/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-76x76@2x.png b/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-76x76@2x.png new file mode 100644 index 0000000..2b2e2c0 Binary files /dev/null and b/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-76x76@2x.png differ diff --git a/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-83.5x83.5@2x.png b/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-83.5x83.5@2x.png new file mode 100644 index 0000000..7ce410e Binary files /dev/null and b/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-83.5x83.5@2x.png differ diff --git a/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png deleted file mode 100644 index 844aecd..0000000 Binary files a/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png and /dev/null differ diff --git a/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/Contents.json b/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/Contents.json index f22e10c..ddabea1 100644 --- a/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,7 +1,112 @@ { "images" : [ { - "filename" : "AppIcon-1024.png", + "filename" : "AW Mac OS Applications-macOS-Default-20x20@2x.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "20x20" + }, + { + "filename" : "AW Mac OS Applications-macOS-Default-20x20@3x.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "3x", + "size" : "20x20" + }, + { + "filename" : "AW Mac OS Applications-macOS-Default-29x29@2x.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "AW Mac OS Applications-macOS-Default-29x29@3x.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "3x", + "size" : "29x29" + }, + { + "filename" : "AW Mac OS Applications-macOS-Default-38x38@2x.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "38x38" + }, + { + "filename" : "AW Mac OS Applications-macOS-Default-38x38@3x.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "3x", + "size" : "38x38" + }, + { + "filename" : "AW Mac OS Applications-macOS-Default-40x40@2x.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "40x40" + }, + { + "filename" : "AW Mac OS Applications-macOS-Default-40x40@3x.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "3x", + "size" : "40x40" + }, + { + "filename" : "AW Mac OS Applications-macOS-Default-60x60@2x.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "60x60" + }, + { + "filename" : "AW Mac OS Applications-macOS-Default-60x60@3x.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "3x", + "size" : "60x60" + }, + { + "filename" : "AW Mac OS Applications-macOS-Default-64x64@2x.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "64x64" + }, + { + "filename" : "AW Mac OS Applications-macOS-Default-64x64@3x.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "3x", + "size" : "64x64" + }, + { + "filename" : "AW Mac OS Applications-macOS-Default-68x68@2x.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "68x68" + }, + { + "filename" : "AW Mac OS Applications-macOS-Default-76x76@2x.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "76x76" + }, + { + "filename" : "AW Mac OS Applications-macOS-Default-83.5x83.5@2x.png", + "idiom" : "universal", + "platform" : "ios", + "scale" : "2x", + "size" : "83.5x83.5" + }, + { + "filename" : "AW Mac OS Applications-macOS-Default-1024x1024@1x.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024"