fix(ios): preserve hermes output on non-zero exit + extend remote PATH

Two related fixes that together restore Skills hub Browse / Search on
iOS over Citadel SSH.

CitadelServerTransport.asyncRunProcess was using `executeCommand`,
which throws `CommandFailed` and discards the captured ByteBuffer when
the remote process exits non-zero. `hermes skills browse` happens to
print its full table and then exit non-zero on some hosts, so iOS got
nothing while Mac (Foundation Process) got the full output with
exitCode=1. Drive `executeCommandStream` directly so stdout + stderr
are drained regardless of outcome, then catch `SSHClient.CommandFailed`
to recover the actual exit code. Network/channel-level failures still
report -1 so callers can distinguish them from a clean non-zero remote
exit.

Citadel's raw exec channel also doesn't source the user's shell rc
files, so non-interactive 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. Inline
PATH=$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH on every
runProcess invocation so bare `hermes` resolves AND any subprocess it
spawns can still find its tools.

SkillsViewModel.finishBrowse now surfaces the actual stderr/stdout
snippet when the CLI exits non-zero, instead of a canned "Browse failed"
banner. ANSI-stripped + box-drawing-stripped so the message stays
readable in the one-line banner. Made diagnosing the underlying PATH
issue much easier and is a net UX improvement going forward.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-25 16:17:25 +02:00
parent 21e3cc9361
commit 850fa7a697
20 changed files with 218 additions and 24 deletions
@@ -149,7 +149,12 @@ public final class SkillsViewModel {
if source != "all" { args += ["--source", source] } if source != "all" { args += ["--source", source] }
let result = Self.runHermes(executable: bin, args: args, transport: xport, timeout: 30) let result = Self.runHermes(executable: bin, args: args, transport: xport, timeout: 30)
let parsed = HermesSkillsHubParser.parseHubList(result.output) 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] } if source != "all" { args += ["--source", source] }
let result = Self.runHermes(executable: bin, args: args, transport: xport, timeout: 30) let result = Self.runHermes(executable: bin, args: args, transport: xport, timeout: 30)
let parsed = HermesSkillsHubParser.parseHubList(result.output) 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. // about than the prior interleaved `MainActor.run` chains.
@MainActor @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 isHubLoading = false
hubResults = results hubResults = results
if results.isEmpty { if results.isEmpty {
hubMessage = isSearch if exitCode == 0 {
? "No matches" hubMessage = isSearch ? "No matches" : "No results"
: (exitCode == 0 ? "No results" : "Browse failed") } else {
let label = isSearch ? "Search failed" : "Browse failed"
let detail = Self.firstSignificantLine(rawOutput)
hubMessage = detail.isEmpty
? "\(label) (exit \(exitCode))"
: "\(label): \(detail)"
}
} else { } else {
hubMessage = nil 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 @MainActor
private func finishInstall(identifier: String, exitCode: Int32) async { private func finishInstall(identifier: String, exitCode: Int32) async {
isHubLoading = false isHubLoading = false
@@ -331,29 +331,61 @@ public final class CitadelServerTransport: ServerTransport, @unchecked Sendable
timeout: TimeInterval? timeout: TimeInterval?
) async throws -> ProcessResult { ) async throws -> ProcessResult {
let client = try await connectionHolder.ssh() let client = try await connectionHolder.ssh()
let cmd = Self.shellJoin([executable] + args) // Citadel's raw exec channel doesn't source the user's shell rc
// Citadel's executeCommand accumulates stdout into a ByteBuffer. // files, so non-interactive SSH sessions land with a stripped
// stderr isn't separately exposed we fold it into the output // PATH (typically just `/usr/bin:/bin`). pipx installs `hermes`
// via `2>&1` so error paths still give callers something to // at `~/.local/bin/hermes`, and many of hermes's sub-tools
// show. Exit code is similarly not directly exposed; on non- // (git/curl/python) live in homebrew prefixes that the remote
// zero exit Citadel throws, so we map that to a commandFailed // sshd would otherwise add via login-shell init. Mac's OpenSSH
// error with the captured output as stderr. // 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<ExecCommandOutput, Error>
do { do {
let buffer = try await client.executeCommand(cmd + " 2>&1") stream = try await client.executeCommandStream(cmd)
var buf = buffer
let str = buf.readString(length: buf.readableBytes) ?? ""
return ProcessResult(
exitCode: 0,
stdout: Data(str.utf8),
stderr: Data()
)
} catch { } catch {
return ProcessResult( return ProcessResult(
exitCode: 1, exitCode: -1,
stdout: Data(), stdout: Data(),
stderr: Data(error.localizedDescription.utf8) 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 { private func asyncSnapshotSQLite(remotePath: String) async throws -> URL {
@@ -506,16 +538,27 @@ private actor ConnectionHolder {
if let existing = sshClient, existing.isConnected { if let existing = sshClient, existing.isConnected {
return existing 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() let client = try await openSSH()
sshClient = client sshClient = client
return client return client
} }
func sftp() async throws -> SFTPClient { 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 { if let existing = sftpClient {
return existing return existing
} }
let client = try await ssh()
let sftp = try await client.openSFTP() let sftp = try await client.openSFTP()
sftpClient = sftp sftpClient = sftp
return sftp return sftp
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 MiB

@@ -1,7 +1,112 @@
{ {
"images" : [ "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", "idiom" : "universal",
"platform" : "ios", "platform" : "ios",
"size" : "1024x1024" "size" : "1024x1024"