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

winget install --id Swift.Toolchain plus the Visual Studio components that the install page lists (C++ tools and a Windows SDK)

C library module

Darwin

Glibc (or Musl with the Static Linux SDK)

WinSDK for Win32, ucrt for the C runtime

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 libSystem

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

ioctl with TIOCGWINSZ

ioctl with TIOCGWINSZ

GetConsoleScreenBufferInfo

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

swift package init --type executable --name shelf

An executable target with a single entry point and no dependencies.

swift package init --type tool --name shelf

Like executable, but the manifest already depends on swift-argument-parser and the source file declares a ParsableCommand. The help text of swift package init recommends it for tools with a rich set of command-line arguments.

swift package init --type command-plugin

A plugin invoked as swift package <verb>; not a standalone tool.

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.

Package.swift
// 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"),
            ]
        ),
    ]
)

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-subprocess 1.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 to Foundation.Process, shown later on this page.

  • The executable target is named like the product that users type. SwiftPM builds .build/debug/shelf for swift build and .build/release/shelf for swift build -c release; swift run shelf list builds 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:

Sources/shelf/Shelf.swift
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

AsyncParsableCommand

run() may be async. The library starts the Swift concurrency runtime and runs the command to completion. A tree can mix ParsableCommand and AsyncParsableCommand subcommands; the root of a tree that contains an async subcommand must itself be async, as Shelf is here.

CommandConfiguration

commandName, abstract (one line shown in the parent’s list), discussion (the longer text in --help), version (adds --version), subcommands, defaultSubcommand (runs when the user names none: here a bare shelf lists books), aliases, and shouldDisplay to hide a subcommand from help.

@OptionGroup

Embeds another ParsableArguments type so that its flags and options are shared by several commands. The title: argument puts them under their own heading in --help (the add help below shows a GLOBAL OPTIONS section).

ExpressibleByArgument

A type that can be parsed from one string. init?(argument:) returns nil for an invalid value, and the library reports it as The value '12345' is invalid for '<isbn>'. A String-backed enum that is also CaseIterable gets the conformance for free and the help lists the allowed values.

validate()

Runs after parsing and before run(). Throw ValidationError for a problem with the combination of values; the message is printed with the usage line.

completion:

Decides what a shell offers for a value, for example .file(extensions: ["json"]), .directory, .list([...]) or a custom closure.

ExitCode

Throw ExitCode(n) (or ExitCode.failure) from run() to end with a chosen status and no message.

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

shelf --generate-completion-script zsh

Prints a completion script for zsh, bash or fish. The zsh output of this tool starts with #compdef shelf and defines helper functions per command. The installation locations per shell are in the library’s "Installing Completion Scripts" article.

shelf --experimental-dump-help

Prints the command tree, its arguments and flags as JSON. The name says the format may change between releases.

swift package plugin generate-manual

The GenerateManual plugin from swift-argument-parser (a plugin that appears in swift package plugin --list once the dependency is resolved). It builds the tool in release mode and writes shelf.1 in mdoc format under .build/plugins/GenerateManual/outputs/shelf.

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

CommandLine.arguments

A [String] with the program name at index 0, then the arguments, as the C main received them. On Windows the C runtime has already split the command line, following the rules described on Windows Console & Processes.

ProcessInfo.processInfo.arguments

The same list, from Foundation.

ProcessInfo.processInfo.environment

A [String: String] snapshot of the environment. Variable names are case-sensitive on macOS and Linux and case-insensitive on Windows; code that looks up Path and PATH behaves differently there.

ProcessInfo.processInfo.processName

The last component of argv[0] (for example proc when started as ./proc).

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:

Sources/shelf/Shelf.swift (excerpt)
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)

Three behaviours matter in practice:

  • Buffering. print writes through the C standard output stream, which is line buffered on a terminal and fully buffered when output goes to a file or pipe. A watch-style tool that prints progress and is run as shelf watch > log.txt shows nothing until the buffer fills or the process exits. Call fflush(stdout) (from Darwin, Glibc, Musl or ucrt) 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. readLine decodes input as UTF-8 and repairs invalid sequences. A tool that must handle arbitrary bytes reads FileHandle data 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.

  • terminationReason distinguishes .exit from .uncaughtSignal; terminationStatus is the exit code in the first case and the signal number in the second.

  • Process resolves an executable from a URL, not from PATH. The example goes through /usr/bin/env to get a PATH search on macOS and Linux; Windows has no env and needs the full path or a search of PATH by the caller.

  • Passing nil for standardOutput shares the parent’s standard output; passing a Pipe redirects 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:

Sources/shelf/Sync.swift
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 (waitUntilExit), terminationHandler

async / await; cancelling the calling task runs a configurable teardown sequence (for example a graceful stop, then a kill) through TeardownStep

Output

Pipe and FileHandle; the caller is responsible for avoiding deadlocks

Collected with a size limit: (an overflow ends the run with an error), or streamed

Termination status

terminationReason and terminationStatus

TerminationStatus: .exited(code) or .signaled(signal); on Windows every command ends as exited

Platform options

A few properties on Process

PlatformOptions: user, group and session on Unix, quality of service on Darwin, console and window behaviour on Windows

Executable lookup

URL only

Executable.name searches PATH; Executable.path takes a full path

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:

Sources/shelf/Watch.swift
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 for SIGINT and SIGTERM is to terminate the process.

  • Keep the sources alive. A DispatchSource that 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 .main works. With a hand-written main.swift you need dispatchMain() 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 SIGINT for the console, but a console tool that must react to Ctrl+C, Ctrl+Break and window close registers a handler with SetConsoleCtrlHandler from WinSDK. 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

Darwin

macOS (and other Apple platforms)

The BSD C library: ioctl, isatty, fflush, signal, kill, and the winsize struct

Glibc

Linux with the GNU C library

The same POSIX functions; request constants such as TIOCGWINSZ are available

Musl

The Static Linux SDK

The musl C library. Code written against Glibc needs an extra canImport(Musl) branch, and some types import with different nullability

WinSDK

Windows

Win32: GetStdHandle, GetConsoleScreenBufferInfo, SetConsoleCtrlHandler

ucrt

Windows

The C runtime (printf, fflush, _isatty)

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:

Sources/shelf/Size.swift
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

winget install --id Swift.Toolchain -e --source winget, after installing the Visual Studio components that the install page lists (the C++ build tools and a Windows 11 SDK). The result is a .exe; check which Swift runtime libraries it needs before distributing it. A manual installer (.exe) is also offered.

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 --checksum argument.

  • Source that imports Glibc must also accept Musl, as the canImport chain in Size.swift does.

  • 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

flowchart LR SRC["Package.swift + Sources/shelf"] SRC --> MAC["macOS host: swift build -c release --arch arm64 --arch x86_64"] SRC --> LIN["Any host with the Static Linux SDK: swift build --swift-sdk x86_64-swift-linux-musl and aarch64-swift-linux-musl"] SRC --> WIN["Windows host: swift build -c release"] MAC --> UNI["Universal Mach-O: arm64 + x86_64, dynamic Swift runtime"] LIN --> STA["Two static ELF files: musl, no runtime dependencies"] WIN --> EXE["shelf.exe + Swift runtime libraries"] UNI --> SIGN["codesign, notarytool, zip or pkg"] STA --> TAR["tar.gz per architecture"] EXE --> ZIP["zip or installer, Authenticode signature"]

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

shelf.exe plus the Swift runtime libraries it needs

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

codesign and notarization

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

@main together with main.swift

The compiler rejects it. Use one entry point: @main for swift-argument-parser tools.

Releasing a DispatchSource

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 signal(SIGINT, SIG_IGN)

The default action terminates the process before the Dispatch handler can do any clean-up.

Output not appearing

print is fully buffered when redirected. Flush after lines that must appear at once, or write to standard error.

Deadlock with Process

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 limit: of .string / .bytes outputs in swift-subprocess, and stream when the output is large.

Building a shell command string

Pass arguments as an array (Arguments, Process.arguments) and never through sh -c with interpolated user input; that is how command injection happens.

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.