Command-Line Tools in Swift
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages. |
A command-line tool written in Swift is a Swift package whose executable target has an entry point. The language compiles
to native code, starts quickly, has no virtual machine to install, and runs on macOS, Linux and Windows. The ecosystem
around it is smaller than the ones for Go or Rust, and its distribution story differs by platform: a macOS tool links
the system Swift runtime, a Linux tool either needs the Swift runtime on the target machine or is built fully static
with the Static Linux SDK, and Windows is a supported but younger platform. This page builds the Bookshelf shelf
tool step by step and explains where each platform behaves differently.
The page was written against Swift 6.4.1 (the release listed on swift.org on 2026-10-25), swift-argument-parser
1.8.2, swift-subprocess 1.0.2 and swift-system 1.8.1. The package in the examples was built and run with
Swift 6.4 on macOS (Apple silicon), including the debug and the universal release builds, the generated completion
script and manual page, the signal handler and the terminal size query on a pseudo-terminal. The code paths for Linux,
the Static Linux SDK and Windows (Musl, Glibc and WinSDK imports, swift sdk install) were not compiled or run
while the page was written, because no such toolchain was available; those parts follow the official installation and
Static Linux SDK documentation and are marked where they appear. The basics of the language and of SwiftPM are on
Swift and Swift Package Manager.
A shorter introduction to swift-argument-parser, written for the CLIs for AI agents running example, is on
Swift & .NET CLI Frameworks; this page starts where that one
stops. Terminal concepts (pseudo-terminals, termios, window size) are on
Terminals, TTYs & Pseudo-Terminals, POSIX processes and signals on
POSIX Processes, Signals & I/O, and the Win32 side on
Windows Console & Processes.
Platforms at a glance
| Topic | macOS | Linux | Windows |
|---|---|---|---|
Toolchain |
Xcode (includes the Swift compiler and SwiftPM) or a swift.org toolchain; Command Line Tools for Xcode are enough for packages |
swift.org toolchain (installer or Docker images), per-distribution packages listed on the install page |
|
C library module |
|
|
|
Runtime dependency of a built tool |
The Swift runtime and Foundation shipped with the OS or the toolchain; the deployment target in the manifest decides the minimum macOS |
The Swift runtime libraries must be present, unless the tool is built with the Static Linux SDK |
The Swift runtime libraries from the toolchain; ship them with the tool or document the requirement |
Static executable |
Not possible: system calls go through |
Yes, with the Static Linux SDK (musl) |
Not documented by the Swift project as a supported route |
Signals |
POSIX signals and Dispatch signal sources |
POSIX signals and Dispatch signal sources |
No POSIX signal model; console control events through Win32 (see below) |
Terminal size |
|
|
|
Creating a package with an executable
SwiftPM can scaffold three kinds of executable. All of them create a Package.swift and a Sources/<name>/ directory.
| Command | Result |
|---|---|
|
An executable target with a single entry point and no dependencies. |
|
Like |
|
A plugin invoked as |
The tool template generated with Swift 6.4 sets swift-tools-version: 6.4 and a minimum swift-argument-parser of
1.2.0; a real project should raise that minimum to a release that has the features it uses. The manifest below is the
one used by the rest of the page. It declares one executable target and three dependencies, all of them library
products of Swift packages: the parser, swift-system and swift-subprocess.
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "shelf",
platforms: [.macOS(.v13)],
dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.8.2"),
.package(url: "https://github.com/apple/swift-system", from: "1.8.1"),
.package(url: "https://github.com/swiftlang/swift-subprocess", from: "1.0.2"),
],
targets: [
.executableTarget(
name: "shelf",
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "SystemPackage", package: "swift-system"),
.product(name: "Subprocess", package: "swift-subprocess"),
]
),
]
)
Source: Swift.org — Getting started with SwiftPM (command-line tool) and Apple — Creating a standalone Swift package with Xcode.
Three details of the manifest deserve a comment:
-
platforms: [.macOS(.v13)]sets the deployment target on macOS only. Linux and Windows have no such setting, because the toolchain and the installed runtime decide what is available. -
swift-subprocess1.0.x needs a Swift 6.2 or newer toolchain (Xcode 26 or newer on macOS), according to its README. A tool that must build with older compilers cannot use it and falls back toFoundation.Process, shown later on this page. -
The executable target is named like the product that users type. SwiftPM builds
.build/debug/shelfforswift buildand.build/release/shelfforswift build -c release;swift run shelf listbuilds and runs in one step.
Entry points: @main and main.swift
An executable module needs exactly one entry point. Either the module contains a file named main.swift with top-level
code, or exactly one type is marked with the @main attribute and provides a static main() function. The two cannot be
mixed: the compiler rejects @main in a module that has top-level code, and suggests -parse-as-library when the code
is intended. swift-argument-parser is designed for @main: the protocols ParsableCommand and AsyncParsableCommand
supply the static main(), which parses CommandLine.arguments, runs the command and exits with the right status.
For that reason the example uses Shelf.swift, not main.swift.
swift-argument-parser beyond the basics
The declarations (@Argument, @Option, @Flag, subcommands) are explained on the AI section’s page and in the
library documentation.
This section shows what a real tool adds on top. The root command, a custom argument type and a shared option group:
import ArgumentParser
import Foundation
@main
struct Shelf: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "shelf",
abstract: "Manage the Bookshelf catalogue.",
discussion: "Examples:\n shelf add 978-0-13-468599-1 \"The Swift Programming Language\"\n shelf list --color never",
version: "0.1.0",
subcommands: [Add.self, List.self, Import.self, Size.self, Watch.self, Sync.self],
defaultSubcommand: List.self
)
}
/// A value type that can be parsed from the command line.
struct ISBN: ExpressibleByArgument, Codable, Equatable {
let digits: String
init?(argument: String) {
let cleaned = argument.filter { $0.isNumber }
guard cleaned.count == 13 else { return nil }
digits = cleaned
}
var defaultValueDescription: String { digits }
}
enum ColorMode: String, CaseIterable, ExpressibleByArgument {
case auto, always, never
}
struct GlobalOptions: ParsableArguments {
@Flag(name: .long, help: "Emit JSON on standard output.")
var json = false
@Option(help: "When to use colour.")
var color: ColorMode = .auto
@Option(help: "Catalogue file.", completion: .file(extensions: ["json"]))
var catalogue: String?
}
struct Add: ParsableCommand {
static let configuration = CommandConfiguration(abstract: "Add a book to the catalogue.")
@OptionGroup(title: "Global options") var global: GlobalOptions
@Argument(help: "The 13-digit ISBN; hyphens are ignored.")
var isbn: ISBN
@Argument(help: "The title of the book.")
var title: String
@Option(name: .shortAndLong, help: "Year of first publication.")
var year: Int?
func validate() throws {
if let year, !(1450...2100).contains(year) {
throw ValidationError("--year must be between 1450 and 2100, got \(year).")
}
if title.trimmingCharacters(in: .whitespaces).isEmpty {
throw ValidationError("The title must not be empty.")
}
}
func run() throws {
if global.json {
print(#"{"isbn":"\#(isbn.digits)","title":"\#(title)","added":true}"#)
} else {
print("Added \(title) (\(isbn.digits))")
}
}
}
struct List: ParsableCommand {
static let configuration = CommandConfiguration(abstract: "List the books on the shelf.")
@OptionGroup(title: "Global options") var global: GlobalOptions
func run() throws {
let env = ProcessInfo.processInfo.environment
let home = env["SHELF_HOME"] ?? "(unset)"
FileHandle.standardError.write(Data("shelf: catalogue in \(home)\n".utf8))
print("0 book(s)")
}
}
struct Import: ParsableCommand {
static let configuration = CommandConfiguration(abstract: "Read one title per line from standard input.")
func run() throws {
var count = 0
while let line = readLine(strippingNewline: true) {
let title = line.trimmingCharacters(in: .whitespaces)
if title.isEmpty { continue }
count += 1
print("\(count): \(title)")
}
print("imported \(count) title(s)")
}
}
What each piece does
| Feature | Behaviour |
|---|---|
|
|
|
|
|
Embeds another |
|
A type that can be parsed from one string. |
|
Runs after parsing and before |
|
Decides what a shell offers for a value, for example |
|
Throw |
The commands share one global option group. A few runs of the tool built from these files (macOS, Swift 6.4):
$ shelf add 978-0-13-468599-1 "The Swift Programming Language" -y 2015
Added The Swift Programming Language (9780134685991)
$ shelf add 12345 "T"
Error: The value '12345' is invalid for '<isbn>'
Help: <isbn> The 13-digit ISBN; hyphens are ignored.
Usage: shelf add [--json] [--color <color>] [--catalogue <catalogue>] <isbn> <title> [--year <year>]
See 'shelf add --help' for more information.
$ echo $?
64
$ shelf add 978-0-13-468599-1 "T" --year 3000
Error: --year must be between 1450 and 2100, got 3000.
$ echo $?
64
$ shelf list --color purple
Error: The value 'purple' is invalid for '--color <color>'. Please provide one of 'auto', 'always' or 'never'.
$ echo $?
64
Exit codes
The library ends with status 0 after --help, --version and a successful run(), with 1 after any other thrown error
(unless it is an ExitCode), and with 64 for every parsing or validation failure. 64 is EX_USAGE from the BSD
sysexits.h header; ExitCode.validationFailure is that value. A tool that follows a different convention, for
example 2 for usage errors as many GNU tools do, overrides the root command’s static main() and maps the code, as the
AI section’s page shows. The wider rules for exit codes and the stdout / stderr split are on
Streams & Exit Codes.
Completion scripts, help dump and manual pages
Every tool built with the library gets three generators without extra code:
| Invocation | Result |
|---|---|
|
Prints a completion script for |
|
Prints the command tree, its arguments and flags as JSON. The name says the format may change between releases. |
|
The |
The plugin needs permission to write into the package directory when the output is written there:
$ swift package plugin --list
'generate-docc-reference' (plugin 'GenerateDoccReference' in package 'swift-argument-parser')
'generate-manual' (plugin 'GenerateManual' in package 'swift-argument-parser')
$ swift package --allow-writing-to-package-directory generate-manual
Building package in release mode...
Generating manual for shelf...
Generated manual in 'file:///.../.build/plugins/GenerateManual/outputs/shelf'
$ head -1 .build/plugins/GenerateManual/outputs/shelf/shelf.1
.\" "Generated by swift-argument-parser"
Its options include --multi-page (one manual per subcommand), --section, --date (pass a fixed date for reproducible
output; without it the generation date is used, formatted in the system locale) and
--authors. Completion scripts, man page installation and the other ways a shell finds them are covered on
Shells, Completions & Shell Integration.
Source: swift-argument-parser — Installing Completion Scripts and
Customizing Completions.
The library documentation on Swift Package Index does not have a standalone article for the manual-page plugin in the
1.8.2 documentation set; its options above come from the plugin’s own --help output.
Arguments, environment and standard streams
swift-argument-parser reads the raw arguments for you, but a tool sometimes needs them directly, for example to detect how it was invoked.
| API | Behaviour |
|---|---|
|
A |
|
The same list, from Foundation. |
|
A |
|
The last component of |
Source: Apple — CommandLine and Apple — ProcessInfo.
Reading input and writing output
Standard input, output and error are available as FileHandle.standardInput, FileHandle.standardOutput and
FileHandle.standardError; the Swift standard library also provides print (standard output) and readLine
(standard input, nil at end of input). The import command uses readLine and the earlier list command writes its
diagnostic line with FileHandle.standardError:
struct Import: ParsableCommand {
static let configuration = CommandConfiguration(abstract: "Read one title per line from standard input.")
func run() throws {
var count = 0
while let line = readLine(strippingNewline: true) {
let title = line.trimmingCharacters(in: .whitespaces)
if title.isEmpty { continue }
count += 1
print("\(count): \(title)")
}
print("imported \(count) title(s)")
}
}
$ printf 'Dune\n\nEmma\n' | shelf import
1: Dune
2: Emma
imported 2 title(s)
Source: Apple — readLine(strippingNewline:) and Apple — FileHandle.
Three behaviours matter in practice:
-
Buffering.
printwrites through the C standard output stream, which is line buffered on a terminal and fully buffered when output goes to a file or pipe. Awatch-style tool that prints progress and is run asshelf watch > log.txtshows nothing until the buffer fills or the process exits. Callfflush(stdout)(fromDarwin,Glibc,Muslorucrt) after a line that must appear at once. Standard error is unbuffered. -
Is it a terminal?
isatty(STDIN_FILENO)(POSIX) or_isatty(Windows C runtime) says whether input comes from a person. The decisions that follow from it (colour, prompts, progress bars) are described on TTY, Colour & Interactivity. -
Encoding.
readLinedecodes input as UTF-8 and repairs invalid sequences. A tool that must handle arbitrary bytes readsFileHandledata instead. On Windows the console code page matters for output; see Windows Console & Processes.
Running other programs
Swift has two mainstream ways to launch a child process. Foundation.Process is part of Foundation on every platform.
Subprocess is a separate package, swift-subprocess, maintained under the swiftlang organisation on GitHub; it is
not part of Foundation, it is built on Swift concurrency.
Foundation.Process
import Foundation
/// Runs `git --version` with Foundation.Process and returns its standard output.
func gitVersion() throws -> String {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
process.arguments = ["git", "--version"]
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = FileHandle.standardError // let git's diagnostics through
try process.run()
// Read before waiting: a child that fills the pipe buffer blocks until the parent reads.
let data = pipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
guard process.terminationReason == .exit, process.terminationStatus == 0 else {
throw CocoaError(.fileReadUnknown)
}
return String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)
}
print(try gitVersion())
let args = CommandLine.arguments
print("argc = \(args.count), argv[0] = \(args[0])")
print(ProcessInfo.processInfo.processName, ProcessInfo.processInfo.arguments.count)
print(ProcessInfo.processInfo.environment["SHELF_HOME"] ?? "(unset)")
Source: Apple — Process and
Apple — Pipe. This program was compiled with Swift 6.4 and
run on macOS; it printed git version 2.54.0 (Apple Git-157), argc = 3, argv[0] = ./proc and /srv for
SHELF_HOME=/srv ./proc a b.
Process pitfalls:
-
Read the pipe before
waitUntilExit(). A child that writes more than the pipe buffer holds blocks until the parent reads, and a parent that waits first deadlocks. -
terminationReasondistinguishes.exitfrom.uncaughtSignal;terminationStatusis the exit code in the first case and the signal number in the second. -
Processresolves an executable from a URL, not fromPATH. The example goes through/usr/bin/envto get aPATHsearch on macOS and Linux; Windows has noenvand needs the full path or a search ofPATHby the caller. -
Passing
nilforstandardOutputshares the parent’s standard output; passing aPiperedirects it.
swift-subprocess
swift-subprocess offers a run function that starts the child, supervises it with structured concurrency and returns an
ExecutionResult. Output can be collected as a String, bytes or Data, discarded, written to a file descriptor, or
consumed as an AsyncSequence while the child runs. The Sync command runs git in a chosen directory:
import ArgumentParser
import Foundation
import Subprocess
#if canImport(System)
import System
#else
import SystemPackage
#endif
struct Sync: AsyncParsableCommand {
static let configuration = CommandConfiguration(abstract: "Run git in the catalogue directory.")
@Option(help: "Directory that holds the catalogue.")
var directory: String = "."
@Argument(parsing: .remaining, help: "Arguments passed to git.")
var gitArguments: [String] = ["--version"]
func run() async throws {
let result = try await Subprocess.run(
.name("git"),
arguments: Arguments(gitArguments),
workingDirectory: FilePath(directory),
output: .string(limit: 64 * 1024),
error: .string(limit: 64 * 1024)
)
print(result.standardOutput, terminator: "")
if !result.standardError.isEmpty {
FileHandle.standardError.write(Data(result.standardError.utf8))
}
switch result.terminationStatus {
case .exited(let code) where code == 0:
return
case .exited(let code):
throw ExitCode(code)
case .signaled(let signal):
throw ExitCode(128 + Int32(signal))
}
}
}
$ shelf sync --directory /tmp -- --version
git version 2.54.0 (Apple Git-157)
$ echo $?
0
$ shelf sync -- status
fatal: not a git repository (or any of the parent directories): .git
$ echo $?
128
Source: swift-subprocess — documentation and swift-subprocess on GitHub. The program was built with swift-subprocess 1.0.2 and run on macOS.
| Aspect | Foundation.Process | swift-subprocess |
|---|---|---|
Availability |
Part of Foundation on macOS, Linux and Windows |
Separate package; macOS, Linux and Windows; needs Swift 6.2 or newer |
Concurrency model |
Callbacks and blocking calls ( |
|
Output |
|
Collected with a size |
Termination status |
|
|
Platform options |
A few properties on |
|
Executable lookup |
URL only |
|
The limit: argument is a security feature: output collection stops a child that produces unbounded output from
exhausting the parent’s memory. For output that does not fit, use the closure form of run and consume the streaming
sequences. The wider rules for passing arguments to children without a shell, and for validating what comes back, are
in Security.
Handling signals
A POSIX signal handler may only call a short list of async-signal-safe functions, and Swift code is not allowed in it
in practice. Dispatch solves this with signal sources: the kernel signal is turned into an event on a dispatch queue, and
the handler is ordinary Swift code. The watch command polls until interrupted, then shuts down cleanly:
import ArgumentParser
import Dispatch
import Foundation
struct Watch: AsyncParsableCommand {
static let configuration = CommandConfiguration(abstract: "Poll the catalogue until interrupted.")
@Option(help: "Seconds between polls.")
var interval: Double = 1.0
func run() async throws {
// The default action of SIGINT and SIGTERM must be switched off, or the
// process dies before the dispatch source can run its handler.
signal(SIGINT, SIG_IGN)
signal(SIGTERM, SIG_IGN)
let stream = AsyncStream<Int32> { continuation in
// A dispatch source that is released is cancelled: keep the sources alive
// until the stream ends.
let sources = [SIGINT, SIGTERM].map { number in
let source = DispatchSource.makeSignalSource(signal: number, queue: .main)
source.setEventHandler { continuation.yield(number) }
source.resume()
return source
}
continuation.onTermination = { _ in sources.forEach { $0.cancel() } }
}
let seconds = interval
let poller = Task { () -> Int in
var polls = 0
while !Task.isCancelled {
polls += 1
print("poll \(polls)")
try? await Task.sleep(for: .seconds(seconds))
}
return polls
}
for await number in stream {
FileHandle.standardError.write(Data("shelf: received signal \(number), stopping\n".utf8))
poller.cancel()
break
}
let polls = await poller.value
print("stopped after \(polls) poll(s)")
throw ExitCode(128 + 2) // the shell convention for "terminated by SIGINT"
}
}
Run on macOS, with an interrupt sent after 1.5 seconds and the output going to a file:
$ shelf watch --interval 0.3 > out.txt 2> err.txt &
$ kill -INT $!; wait $!; echo "exit $?"
exit 130
$ cat out.txt err.txt
poll 1
...
poll 5
stopped after 5 poll(s)
shelf: received signal 2, stopping
The details that decide whether this works:
-
Ignore the default action first.
signal(SIGINT, SIG_IGN)is required. A signal source observes the signal but does not replace its default action, which forSIGINTandSIGTERMis to terminate the process. -
Keep the sources alive. A
DispatchSourcethat is released is cancelled. An early version of this example created the sources inside a loop without keeping them, and the handler never ran; the final version keeps them in an array until the stream ends. -
Use the main queue. The async entry point of swift-argument-parser drains the main queue, so
.mainworks. With a hand-writtenmain.swiftyou needdispatchMain()or an async entry point to service it. -
Exit status. A process that ends because of a signal reports 128 plus the signal number to the shell. Code that handles the signal itself and then exits normally should do the same: the example throws ExitCode(128 + 2), 130, and the run above shows it.
-
Windows. Windows has no POSIX signals: the C runtime emulates
SIGINTfor the console, but a console tool that must react to Ctrl+C, Ctrl+Break and window close registers a handler withSetConsoleCtrlHandlerfromWinSDK. Swift’s own documentation does not describe a Dispatch signal source for Windows, and the example above was not compiled there. See Windows Console & Processes for the control-event model.
Long-running tools that must survive logout, restart on failure or reload their configuration on SIGHUP are covered on
Long-Running Processes & Services.
swift-system and C library imports
The Swift platform modules re-export the C library of the operating system. Which module name exists depends on the
target, so portable code selects it with canImport, as the Size.swift file below does.
| Module | Platform | Contains |
|---|---|---|
|
macOS (and other Apple platforms) |
The BSD C library: |
|
Linux with the GNU C library |
The same POSIX functions; request constants such as |
|
The Static Linux SDK |
The musl C library. Code written against |
|
Windows |
Win32: |
|
Windows |
The C runtime ( |
swift-system (module SystemPackage from the package; System when it ships with the OS) adds typed wrappers over the
same system calls: FileDescriptor, FilePath, Errno, and FilePermissions. They give POSIX-style file I/O with
throws instead of a negative return and errno, and they are what swift-subprocess uses for its workingDirectory:
and file-descriptor outputs. The terminal queries used here are not among the wrappers this page relies on, so the terminal size comes from the
C library directly:
import ArgumentParser
import Foundation
#if canImport(Darwin)
import Darwin
#elseif canImport(Glibc)
import Glibc
#elseif canImport(Musl)
import Musl
#elseif canImport(WinSDK)
import WinSDK
#endif
/// Size of the terminal attached to standard output, in character cells.
func terminalSize() -> (columns: Int, rows: Int)? {
#if os(Windows)
var info = CONSOLE_SCREEN_BUFFER_INFO()
let handle = GetStdHandle(STD_OUTPUT_HANDLE)
guard GetConsoleScreenBufferInfo(handle, &info) else { return nil }
return (Int(info.srWindow.Right - info.srWindow.Left + 1),
Int(info.srWindow.Bottom - info.srWindow.Top + 1))
#else
var size = winsize()
guard ioctl(STDOUT_FILENO, UInt(TIOCGWINSZ), &size) == 0, size.ws_col > 0 else { return nil }
return (Int(size.ws_col), Int(size.ws_row))
#endif
}
struct Size: ParsableCommand {
static let configuration = CommandConfiguration(abstract: "Print the size of the terminal.")
func run() throws {
if let size = terminalSize() {
print("\(size.columns)x\(size.rows)")
} else {
print("not a terminal")
throw ExitCode.failure
}
}
}
On macOS the command printed not a terminal when its output went to a pipe, and 100x24 when started with a
pseudo-terminal whose window size was set to 100 columns by 24 rows. The Windows branch and the Glibc and Musl imports
were not compiled while this page was written. On Linux with musl the request argument of ioctl may have a different
integer type than on Glibc and Darwin, so the UInt(TIOCGWINSZ) conversion may need adjusting; this is exactly the kind
of difference the Static Linux SDK article warns about.
Source: swift-system on GitHub, the
Static Linux SDK article for the module
differences, and swift-argument-parser — Getting Started.
The size is not updated when the window is resized; a tool that follows resizes handles SIGWINCH on POSIX systems as
described on Terminals, TTYs & Pseudo-Terminals.
Swift on Linux and Windows
The same package builds on Linux and Windows with that platform’s toolchain; no source change is needed apart from platform-conditional code such as the imports above.
| Platform | Installation and notes |
|---|---|
Linux |
Install a swift.org toolchain for the distribution (the install page lists the supported ones), or use the Swift Docker images. A tool built this way links the Swift runtime and Foundation dynamically, so the target machine needs matching runtime libraries and a compatible glibc. Building on the oldest distribution you support keeps the binary running on newer ones. |
Windows |
|
Two honest limits: Swift’s documentation does not provide a TUI framework that is supported on Windows, and
no compatibility table of Foundation behaviour per platform. Treat any Windows build as something to test on Windows.
The packaging side (installers, winget manifests) is on
Native Packages & Installers.
The Static Linux SDK
The Static Linux SDK lets you build a Linux executable with no dependency on the system at all, not even the C library:
it is linked against musl and statically against the Swift runtime. Such a binary runs on any Linux distribution of the
right architecture, and can be built from macOS or Linux. The SDK article states the main trade-offs: there is no dynamic
linking whatsoever (dlopen does not work), everything the program needs must be linked in, and the SDK bundles only a few
C libraries (libxml2, zlib, curl and a TLS library, listed in its SBOM).
$ swift sdk install https://download.swift.org/swift-6.4.1-release/static-sdk/swift-6.4.1-RELEASE/swift-6.4.1-RELEASE_static-linux-0.1.0.artifactbundle.tar.gz --checksum <checksum-from-the-install-page>
$ swift sdk list
$ swift build -c release --swift-sdk x86_64-swift-linux-musl
$ swift build -c release --swift-sdk aarch64-swift-linux-musl
$ file .build/x86_64-swift-linux-musl/release/shelf
Source: Swift.org — Getting Started with the Static Linux SDK. These commands were not run while this page was written; the Static Linux SDK was not installed. The URL pattern comes from the article’s example for a specific toolchain version, and the checksum must be taken from the installation page.
The requirements that cause the most trouble:
-
The SDK version must match the toolchain exactly, and on macOS the toolchain must be an open-source one from swift.org: the toolchain that comes with Xcode cannot build with the SDK.
-
A remote SDK needs the
--checksumargument. -
Source that imports
Glibcmust also acceptMusl, as thecanImportchain inSize.swiftdoes. -
There is no equivalent for Apple platforms: Darwin routes system calls through a dynamic library, so a fully static macOS executable is not possible.
Xcode’s Command Line Tool template
Xcode has a project template for command-line tools (macOS, Command Line Tool). It creates an Xcode project with one
target and a main.swift file, and builds with xcodebuild or the Product menu. The template predates SwiftPM executables,
and a package is the better choice for new tools: it builds in the same way on Linux and Windows, it works in CI without an
Xcode project file, and it can be opened in Xcode by opening Package.swift. Use the Xcode template when the tool needs an
Xcode-only feature such as an embedded Info.plist, or when it is one target of a larger Xcode workspace. Objective-C tools
use the same Xcode template with a different language, as described on
Command-Line Tools in Objective-C.
Universal binaries on macOS
A universal binary holds machine code for Apple silicon and Intel Macs. SwiftPM builds one by repeating --arch:
$ swift build -c release --arch arm64 --arch x86_64
warning: The x86_64 architecture is deprecated for your deployment target (macOS 27.0). ...
Build complete! (12,12 s)
$ lipo -info "$(swift build -c release --arch arm64 --arch x86_64 --show-bin-path)/shelf"
Architectures in the fat file: .../shelf are: x86_64 arm64
Source: Swift.org — SwiftPM getting started. The build above was run with
Swift 6.4 and produced a binary of about 6 MB for the example package, linking the system Swift runtime. The warning is from
the macOS 27 SDK, which deprecates the Intel architecture for macOS 27 deployment targets; set a lower deployment target in
platforms: if Intel Macs are still in your audience. lipo can also combine two single-architecture builds, which is how
cross-compiled slices are joined; the general treatment of lipo, deployment targets and the other native-binary topics is on
Native Binaries & Cross-Compilation.
Signing and notarizing a tool
A tool that people download outside the App Store, and that must run on a Mac with Gatekeeper enabled, needs to be signed with a Developer ID Application certificate with the hardened runtime and a secure timestamp, and submitted to Apple’s notary service. A bare executable cannot have a ticket stapled to it; the usual choices are to ship it in a signed disk image or installer package, or in a zip that Gatekeeper checks online on first launch.
$ codesign --force --options runtime --timestamp --sign "Developer ID Application: Example Corp (TEAMID1234)" shelf
$ ditto -c -k --keepParent shelf shelf.zip
$ xcrun notarytool submit shelf.zip --keychain-profile "shelf-notary" --wait
Source: Apple — Notarizing macOS software before distribution.
These commands were not run while this page was written, because they need a paid developer account and a certificate. The full
procedure, including the credentials for notarytool, entitlements and the checks Gatekeeper performs, is on
macOS Distribution Outside the App Store and, for the other
platforms, on Signing, Notarization & OS Trust.
Build matrix
The table lists what each leg can and cannot do from the same source tree:
| macOS universal | Linux static | Windows | |
|---|---|---|---|
Where it builds |
A Mac with Xcode or a swift.org toolchain |
macOS or Linux, with the swift.org toolchain and the matching SDK |
Windows with the Visual Studio components |
Output |
One Mach-O file containing two slices |
One ELF file per architecture |
|
Needs on the target |
macOS at or above the deployment target |
Nothing but a Linux kernel of the right architecture |
The Swift runtime libraries, if they are not shipped with the tool |
Trust step |
|
Checksums and, optionally, detached signatures |
Authenticode signature |
The Linux and Windows legs of this matrix were not built while this page was written. A GitHub Actions workflow that runs
each leg on its own runner (macos-latest, ubuntu-latest, windows-latest) and collects the artifacts is shown on
Native Binaries & Cross-Compilation.
Pitfalls and security notes
| Pitfall | What happens and what to do |
|---|---|
|
The compiler rejects it. Use one entry point: |
Releasing a |
A signal source that is not retained is cancelled, and the handler never runs. Keep sources in a variable that lives as long as the handler is needed. |
Forgetting |
The default action terminates the process before the Dispatch handler can do any clean-up. |
Output not appearing |
|
Deadlock with |
Waiting for exit before reading a full pipe. Read first, or use swift-subprocess, which collects or streams for you. |
Unbounded child output |
Use the |
Building a shell command string |
Pass arguments as an array ( |
Trusting a title or file name printed to the terminal |
Book titles from a catalogue can contain escape sequences. Strip control characters before printing untrusted text; see Escape Sequences & Terminal Capabilities. |
Assuming Foundation matches across platforms |
Test file, process and locale behaviour on every platform you ship. On Linux and Windows Foundation is a separate implementation. |
Shipping a Linux binary built on a new distribution |
A dynamically linked glibc binary may refuse to run on an older system. Build on the oldest target or use the Static Linux SDK. |
Mixing SDK and toolchain versions |
The Static Linux SDK must match the toolchain version exactly; on macOS use the swift.org toolchain, not the one inside Xcode. |
What the official documentation does not provide
-
swift-argument-parser documents declarations, validation, completion and help in detail but does not prescribe an exit-code convention beyond its defaults (0, 1 and 64) and does not provide a testing recipe for whole commands; tests call
parse(_:)or run the built binary, as shown on Testing Terminal Apps & TUIs. -
The Swift project does not document a portable signal API. Dispatch signal sources are documented by Apple for Darwin; Windows console events are Win32 APIs reached through
WinSDK. -
There is no official terminal UI framework for Swift. SwiftTUI exists but has been dormant since 2024; the options are compared on Other TUI Libraries.
-
The Static Linux SDK article covers Linux only; there is no Swift project guidance for static Windows executables.
-
Swift’s documentation does not publish binary-size or startup-time figures for command-line tools. The 6 MB figure above is one measurement of the example package, not a benchmark.
References
-
Swift.org — Getting started with SwiftPM (command-line tool)
-
swift-argument-parser 1.8.2 documentation (Swift Package Index), including Commands and Subcommands, Validation, Installing Completion Scripts and Customizing Completions
-
swift-subprocess and its documentation