From 6fc59e7bec815ad9f29004777e640c0b2aecbd64 Mon Sep 17 00:00:00 2001 From: Oliver Drobnik Date: Tue, 9 Jun 2026 20:43:43 +0200 Subject: [PATCH 1/2] ShellCommandKit: vend bridged Commands as instances (Shell.parsableCommand) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ShellCommandKit` could bridge an ArgumentParser command into a ShellKit `Command` only via `Shell.register(_:)`, which stores the bridge in a shell's in-memory `commands` map. An external installer that maintains its own registry — e.g. a filesystem-backed bin catalog that places commands at `/usr/bin/…` so they're PATH-resolvable — had no way to obtain the bridged `Command` instance to install. Add `Shell.parsableCommand(_:)`, a public static factory that builds (but does not register) a bridged `Command`, deriving its name from `configuration.commandName` exactly as `register(_:)` does. `register(_:)` now delegates to it, so there's a single bridging path. The bridge type itself stays internal. Purely additive and backward-compatible. This lets producer packages (e.g. SwiftPorts) vend ready-to-install ShellKit `Command`s built on ShellKit's own bridge — no duplicate bridge in each shell. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ParsableCommandBridge.swift | 34 +++++++++++++++++-- Tests/ShellKitTests/ShellTests.swift | 23 +++++++++++++ 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/Sources/ShellCommandKit/ParsableCommandBridge.swift b/Sources/ShellCommandKit/ParsableCommandBridge.swift index 7d710c6..cbf6ccd 100644 --- a/Sources/ShellCommandKit/ParsableCommandBridge.swift +++ b/Sources/ShellCommandKit/ParsableCommandBridge.swift @@ -91,6 +91,35 @@ struct ParsableCommandBridge: Command { extension Shell { + /// Build — but do **not** register — a ``Command`` that bridges an + /// `ArgumentParser` command type to ShellKit's command protocol. + /// + /// Use this when you maintain your own command registry rather than this + /// shell's in-memory `commands` map — for example an installer that places + /// commands into a filesystem-backed bin catalog (`/usr/bin/…`) so they're + /// `PATH`-resolvable. ``register(_:)`` is the convenience built on top of + /// this: it makes a bridge and stores it under the command's name. + /// + /// The command name is taken from `configuration.commandName` if set, + /// otherwise the lowercased Swift type name — the same rule + /// ``register(_:)`` uses, so a bridged instance carries the name the shell + /// would have registered it under. + /// + /// ```swift + /// // In a producer package that vends ready-to-install commands: + /// let jq = Shell.parsableCommand(Jq.self) // jq.name == "jq" + /// + /// // In a standalone executable target the bridge isn't involved: + /// @main struct Entry { + /// static func main() async { await Jq.main() } // ArgumentParser + /// } + /// ``` + public static func parsableCommand(_ type: P.Type) -> Command { + let name = type.configuration.commandName + ?? String(describing: type).lowercased() + return ParsableCommandBridge

(name: name) + } + /// Register an `AsyncParsableCommand` (or plain `ParsableCommand`) /// type with this Shell. Once registered, `commands[name].run(argv)` /// dispatches through ArgumentParser's parser and into the @@ -117,8 +146,7 @@ extension Shell { /// } /// ``` public func register(_ type: P.Type) { - let name = type.configuration.commandName - ?? String(describing: type).lowercased() - commands[name] = ParsableCommandBridge

(name: name) + let command = Shell.parsableCommand(type) + commands[command.name] = command } } diff --git a/Tests/ShellKitTests/ShellTests.swift b/Tests/ShellKitTests/ShellTests.swift index 70ff8fc..6f58044 100644 --- a/Tests/ShellKitTests/ShellTests.swift +++ b/Tests/ShellKitTests/ShellTests.swift @@ -291,4 +291,27 @@ import ShellCommandKit // Shell.register(_:) bridge #expect(status.code == 42) } } + + @Test func parsableCommandVendsNamedInstanceWithoutRegistering() async throws { + // The producer-side API: obtain an installable Command instance + // (named from configuration.commandName) WITHOUT touching any shell's + // `commands` map — the path an external installer (e.g. SwiftBash) + // uses to place the command into its own bin catalog. + let command = Shell.parsableCommand(Echo.self) + #expect(command.name == "shellkit-test-echo") + + let captured = OutputSink() + let shell = Shell(stdout: captured) + #expect(shell.commands["shellkit-test-echo"] == nil) // never registered + + try await shell.withCurrent { + let status = try await command.run( + ["shellkit-test-echo", "hi", "there"]) + #expect(status == .success) + } + + captured.finish() + let out = await captured.readAllString() + #expect(out == "hi there\n") + } } From b152e5765d417306e0b9b459be35ec6d17a16c56 Mon Sep 17 00:00:00 2001 From: Oliver Drobnik Date: Tue, 9 Jun 2026 21:04:38 +0200 Subject: [PATCH 2/2] ShellCommandKit: redact Sandbox.Denial in the bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bridge funnelled every non-ExitCode/non-cancellation error through ArgumentParser's `fullMessage(for:)`, which `String(describing:)`s a `Sandbox.Denial` and dumps all its fields — including the sandbox root and the requested path. For an app-as-sandbox embedder that leaks the container path into ordinary command error output. Catch `Sandbox.Denial` explicitly and print only its `reason`. Putting this in the command-building base means every bridged command is protected once, rather than each embedder re-implementing the redaction in its own bridge. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/ShellCommandKit/ParsableCommandBridge.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Sources/ShellCommandKit/ParsableCommandBridge.swift b/Sources/ShellCommandKit/ParsableCommandBridge.swift index cbf6ccd..b6963b9 100644 --- a/Sources/ShellCommandKit/ParsableCommandBridge.swift +++ b/Sources/ShellCommandKit/ParsableCommandBridge.swift @@ -66,6 +66,17 @@ struct ParsableCommandBridge: Command { throw CancellationError() } catch let exit as ExitCode { return ExitStatus(exit.rawValue) + } 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 + // app-as-sandbox embedder can't leak its container path into a + // command's error output. (ArgumentParser's `fullMessage(for:)` + // would `String(describing:)` the whole value and dump every + // field.) Routing every bridged command through this catch keeps + // the redaction in one place — the command-building base — rather + // than re-implemented in each embedder. + Shell.current.stderr("\(name): \(denial.reason)\n") + return ExitStatus(1) } catch { return formatAndReport(error: error) }