From 48c4b8a07f3b3269309c1be557192f8b71251a72 Mon Sep 17 00:00:00 2001 From: Oliver Drobnik Date: Sun, 18 Oct 2026 18:29:02 +0200 Subject: [PATCH] CommandFailure: a command's error prints as the command words it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bridged ArgumentParser command that fails the way a real tool does (git's `fatal: …`, exit 128) had its text run through ArgumentParser's formatter: `Error: ` in front, and exit 1. An error conforming to the new CommandFailure protocol now has its diagnostic written to stderr as is, and its exit status returned. Co-Authored-By: Claude Opus 5.5 --- .../ParsableCommandBridge.swift | 7 +++++ Sources/ShellKit/Command/CommandFailure.swift | 16 ++++++++++++ Tests/ShellKitTests/ShellTests.swift | 26 +++++++++++++++++++ 3 files changed, 49 insertions(+) create mode 100644 Sources/ShellKit/Command/CommandFailure.swift diff --git a/Sources/ShellCommandKit/ParsableCommandBridge.swift b/Sources/ShellCommandKit/ParsableCommandBridge.swift index b6963b9..b96f4fd 100644 --- a/Sources/ShellCommandKit/ParsableCommandBridge.swift +++ b/Sources/ShellCommandKit/ParsableCommandBridge.swift @@ -22,6 +22,8 @@ import ShellKit /// `run()`; otherwise calls the synchronous `run()`. /// 4. Translates ArgumentParser's exit conventions to ``ExitStatus``: /// - `ExitCode` thrown from `run()` → matching `ExitStatus`. +/// - A ``CommandFailure`` thrown from `run()` → its diagnostic on +/// `stderr` as is, and its exit status. /// - `--help` / `--version` (clean exits with code 0) print the /// message to ``Shell/current``'s `stdout` and return success. /// - Usage errors (parse failures, validation) print to @@ -66,6 +68,11 @@ struct ParsableCommandBridge: Command { throw CancellationError() } catch let exit as ExitCode { return ExitStatus(exit.rawValue) + } catch let failure as CommandFailure { + // The command's own words and status, as it would print them + // standalone. + Shell.current.stderr(failure.diagnostic) + return failure.exitStatus } catch let denial as Sandbox.Denial { // Render only the human-readable reason — never the sandbox root // or the requested path that `Sandbox.Denial` also carries — so an diff --git a/Sources/ShellKit/Command/CommandFailure.swift b/Sources/ShellKit/Command/CommandFailure.swift new file mode 100644 index 0000000..303cc24 --- /dev/null +++ b/Sources/ShellKit/Command/CommandFailure.swift @@ -0,0 +1,16 @@ +import Foundation + +/// An error that says exactly what its command prints and how it exits. +/// +/// A command that fails the way a real tool does (`fatal: …` on stderr, +/// exit 128) throws one of these from `run()`. A host shell prints +/// ``diagnostic`` to the command's stderr as it is and returns +/// ``exitStatus``, with no formatting of its own. ArgumentParser's +/// formatter, by contrast, would prefix the text with `Error: ` and exit +/// with 1. +public protocol CommandFailure: Error { + /// What the command writes to stderr, line endings included. + var diagnostic: String { get } + /// The status the command exits with. + var exitStatus: ExitStatus { get } +} diff --git a/Tests/ShellKitTests/ShellTests.swift b/Tests/ShellKitTests/ShellTests.swift index 2d78561..8946b47 100644 --- a/Tests/ShellKitTests/ShellTests.swift +++ b/Tests/ShellKitTests/ShellTests.swift @@ -329,6 +329,32 @@ import ShellCommandKit // Shell.register(_:) bridge } } + @Test func commandFailurePrintsItsOwnDiagnosticAndStatus() async throws { + struct Fatal: CommandFailure { + var diagnostic: String { "fatal: something git-like\n" } + var exitStatus: ShellKit.ExitStatus { ShellKit.ExitStatus(128) } + } + struct Failer: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "shellkit-test-fatal") + func run() async throws { + throw Fatal() + } + } + + let stderr = OutputSink() + let shell = Shell(stderr: stderr) + shell.register(Failer.self) + + try await shell.withCurrent { + let status = try await shell.commands["shellkit-test-fatal"]!.run(["shellkit-test-fatal"]) + #expect(status.code == 128) + } + stderr.finish() + // As written: no `Error: ` prefix, no extra newline. + #expect(await stderr.readAllString() == "fatal: something git-like\n") + } + @Test func parsableCommandVendsNamedInstanceWithoutRegistering() async throws { // The producer-side API: obtain an installable Command instance // (named from configuration.commandName) WITHOUT touching any shell's