diff --git a/Sources/ShellCommandKit/ParsableCommandBridge.swift b/Sources/ShellCommandKit/ParsableCommandBridge.swift index 7d710c6..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) } @@ -91,6 +102,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 +157,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") + } }