swift-script
Skill0 starsChanged 3 months agoMITView on GitHub
Write self-contained, portable, testable Swift command-line tools — a SwiftPM package with pure-logic/side-effect separation (boundaries as protocols) that Swift Testing can drive with fakes, an ArgumentParser CLI, and a compiler/test/format gate. Use as soon as the user wants a Swift script, CLI utility, automation tool, or "glue" code (orchestrating git, xcodebuild, curl, file manipulation, parsing) in Swift, even if they never say "package". Also triggers on adding tests to an existing Swift tool, making one portable/testable, or porting a shell/Python script to Swift.
In this repository7 other files it gives its agents.
- go-scriptSkill
- kotlin-scriptSkill
- python-scriptSkill
- rust-scriptSkill
- scripteratopsSkill
- shell-scriptSkill
- typescript-scriptSkill
The file
We split this skill into the rules it gives an agent
3 rules. About 0 are common, 0 are distinctive, and 3 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈0
Distinctive · estimate
This author's own choices. Worth a look.
3
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 46
Keep dependencies few: swift-argument-parser (CLI) and swift-log (logging) cover most tools.
- Very specific: line 89
Always a timeout: Wait on a DispatchSemaphore signalled by terminationHandler.
- Very specific: line 228
Every tool ships tests in the ToolCoreTests target, @testable import ToolCore.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Swift script
Standard for all Swift command-line tools: a small SwiftPM package whose logic
lives in a testable library target, driven by a thin ArgumentParser executable,
verified by the compiler + Swift Testing + swift-format. The goal is a
self-contained tool (no external install beyond the Swift toolchain) whose logic
is trivially testable — the Swift counterpart of the python-script skill.
Three decisions are already settled. Don't second-guess them, apply them:
- Distribution → a SwiftPM package. Swift is compiled and has no stable
single-file-with-dependencies story (
swift-shis niche; a bare#!/usr/bin/env swiftfile can't use SwiftPM deps and can't be unit-tested). A package is what buys dependencies and testability. - Interface →
swift-argument-parser(Apple). - Tests → Swift Testing (
@Test/#expect), made easy by an architecture that isolates side effects behind protocols.
This file carries the decisions; the code implementing them is a complete,
gate-passing package in assets/template/. Copy and adapt that package — don't
retype it from here. Then delete what your tool doesn't earn.
1. Package layout
tool/
├── Package.swift # deps pinned with .exact()
├── Sources/
│ ├── ToolCore/ # pure logic + boundaries → LIBRARY target (testable)
│ └── tool/ # main.swift-equivalent → thin EXECUTABLE target
└── Tests/
└── ToolCoreTests/ # @testable import ToolCoreThe split is the whole point: an executable target can't be @testable-imported,
so all logic worth testing lives in the ToolCore library target; the
executable only parses arguments and wires things together.
- Pin every dependency to an exact version in
Package.swift(.package(url:…, exact: "1.8.2"), neverfrom:or a range). A tool has no reason to float;swift package updateis the deliberate way to bump. - Keep dependencies few:
swift-argument-parser(CLI) andswift-log(logging) cover most tools. Foundation gives you JSON (Codable), subprocess (Process), HTTP (URLSession), and files for free — reach for a dependency only for a real gain. - JSON is the "free" config format (Foundation
Codable); Swift has no stdlib TOML, so use JSON unless comments justify aTOMLKitdependency.
2. Structure
The structure is designed to make tests trivial: pure logic never touches the outside world directly. Everything that leaves the process (subprocess, network, filesystem, environment) sits behind a protocol boundary, injected into the orchestration. Tests pass a fake conformance; production passes the real one.
- Boundaries are
Sendableprotocols —CommandRunner,HTTPClient,EnvironmentReading. These are the single mock hooks. A fake is a 3-linestructconforming to the protocol. - Pure logic (
transform) — input → output, no I/O, tested with plain#expect. - Config is a
Codablestruct; precedence is one pure function,resolveConfig(cli:environment:file:), tested without any fake. - Orchestration (
doWork) assembles boundary calls + pure logic: data positional, behaviour flags with defaults, returns a count. It takes the boundaries asany CommandRunner/any HTTPClient, so a test drives it with no process and no socket. - The executable is thin: parse arguments, build the real boundaries, call
doWork, map errors to an exit code. Nothing worth testing lives here.
2.1 Concurrency (Swift 6)
The template compiles clean under Swift 6 strict concurrency. Two consequences shape the boundaries:
CommandRunner.runis synchronous. AProcessis a non-Sendableclass; keeping the call sync means it never crosses a concurrency domain. A subprocess is blocking anyway.HTTPClient.data(from:)isasync— the network is the one place awaiting pays, andURLSessionisSendable.
2.2 Subprocess boundary
ProcessRunner routes all subprocess calls through Foundation.Process:
- Never a shell. Launch
/usr/bin/envwith an argument list — no shell means no injection from a filename or argument. - Always a timeout. Wait on a
DispatchSemaphoresignalled byterminationHandler; on deadline,terminate()and throwToolError.timeout(which maps to exit 124). An unbounded child is the classic way a tool hangs. - On non-zero exit, throw
ToolError.command(cmd:code:stderr:)with the stripped stderr.
2.3 Config and precedence
Resolve settings with an explicit, documented precedence — higher wins:
CLI flags > environment variables > config file > built-in defaultsresolveConfig is pure (takes already-loaded Config layers, ??-chains them so
an absent value never clobbers a lower layer). Each source is read by one boundary
— readConfigFile (JSON), configFromEnvironment(_:) (through the
EnvironmentReading hook). Secrets get no CLI flag (argv is world-readable via
ps); a token comes from the environment or config file. Never log a raw
Config — route it through redacted(_:), which masks declared secret fields.
2.4 HTTP requests
URLSessionClient wraps URLSession in one boundary:
- Set an explicit timeout on the request — an unbounded call hangs forever.
- Raise on non-2xx yourself.
URLSessiondoes not throw on 4xx/5xx, so checkHTTPURLResponse.statusCodeand throwToolError.http; a 404 page would otherwise sail through as a "successful" body. - Inject the session so tests register a stub
URLProtocoland run offline — the Swift equivalent of a mock transport. (For server-grade streaming or heavy Linux use,AsyncHTTPClientis the alternative; URLSession is the default.)
2.5 Colorized logging
Logging uses swift-log, bootstrapped once in the executable to a handler that writes to stderr (never stdout — stdout is for program data). Color:
shouldColor(mode:isTTY:environment:)is a pure decision — precedence--color=always/never>NO_COLOR/FORCE_COLOR> TTY (and notTERM=dumb).ColorLogHandlerimplements swift-log'slog(event:)and colorizes the level name when enabled. Implementlog(event:), not the olderlog(level:message:…)(deprecated in swift-log 1.6+).
2.6 Robustness & signals
- Bound every subprocess and HTTP call with a timeout (§2.2, §2.4).
- SIGTERM →
_exit(143), SIGPIPE ignored (a closed downstream pipe becomes a write error, not a kill), SIGINT keeps its default. Unlike Python'sSystemExit(143), a C signal handler cannot safely unwinddeferblocks —_exit(async-signal-safe) is the honest choice. - Preflight external tools for glue scripts (a
which-style check up front), raisingToolError.configlisting every missing tool.
2.7 Subcommands (if needed)
ArgumentParser handles this natively: a root ParsableCommand with
subcommands: [...], each its own AsyncParsableCommand. No manual dispatch.
2.8 Man page (optional)
swift-argument-parser ships a generate-manual plugin — the page is built from
your command definitions, no extra source:
swift package --allow-writing-to-package-directory \
generate-manual --output-directory ./man(Run under the Xcode toolchain, as for the test gate.) It writes man/tool.1
(mdoc format). Install into a MANPATH directory — on macOS
/usr/local/share/man/man1/ is there by default:
sudo install -m 644 man/tool.1 /usr/local/share/man/man1/tool.1Then man tool works; man ./man/tool.1 previews it without installing. The
generated page is a build artifact — don't commit it.
2.9 Build, install & README (optional, for tools you distribute)
The README carries the environment set-up, not just the gates: a
Prerequisites section with per-platform install of the Swift toolchain
(macOS/Linux/Windows, noting the Xcode-toolchain requirement for swift test) and
the dependency list, pointing to Package.swift as the pinned source. For a
distributed binary, an optional --dependencies flag printing the pinned versions
is a nice touch — skip it for source-run tools.
- Release binary:
swift build -c release→.build/release/tool. - Install: the package template ships a
README.md; copy the sharedassets/install.py(a python-script) into the package root. It builds the release binary and installs it (+ man page via the plugin):
uv run install.py . --name tool # build + install binary + man
uv run install.py . --name tool --no-man # binary onlyWithout uv: swift build -c release, then copy the binary from
swift build -c release --show-bin-path into a PATH directory.
- Limits: the binary is per-OS — build on each platform (add
--static-swift-stdlibfor a portable Linux binary). Windows Swift CLI tooling is out of scope.
3. CLI contract
swift-argument-parser gives most of the baseline for free:
-h/--helpand--versionare automatic (setversion:inCommandConfiguration) — unlike argparse, you don't wire them by hand.- Verbosity:
@Flag(name: .shortAndLong) var verbose: Intcounts occurrences (-vv= 2); same forquiet. Map the net count to a level withlogLevel(forVerbosity:). --coloris an@Optionover aColorModeenum (ExpressibleByArgument).--dry-runshort-circuits before output, announced at warning level.- Group the help once flags pile up (the same rule as the Python skill):
put the tool's own flags in
@OptionGroup(title:)sections (Config sources,Behaviour, …) and let the baseline (--version/--help) fall in the default section.ArgumentParserrenders each group as its own--helpheading. - Stream discipline: program data → stdout (
print); logs, progress, diagnostics → stderr (the swift-log handler).
Exit codes
Map typed errors to a code in the executable's catch. Keep the convention:
| Code | Meaning |
0 | Success |
1 | Runtime error (a ToolError caught in run()) |
2 | Usage error (ArgumentParser emits this automatically) |
124 | A subprocess exceeded its timeout (ToolError.timeout) |
130 | Interrupted — SIGINT / Ctrl-C (Swift's default) |
141 | Broken pipe — downstream closed (SIGPIPE ignored → write error) |
143 | Terminated — SIGTERM / kill (_exit(143)) |
ToolError.exitCode keeps the code with the failure category; run() throws
ExitCode(error.exitCode).
4. Tests with Swift Testing
Every tool ships tests in the ToolCoreTests target, @testable import ToolCore.
- Never test subprocess or network for real — inject a fake conforming to the boundary protocol. That's what the protocol is for.
- Fakes are tiny structs (
struct FakeRunner: CommandRunner { … }), not a mocking framework. - HTTP: register a stub
URLProtocolon aURLSessionConfiguration.ephemeralsession and inject it — cover 2xx → payload and non-2xx → typed error. Mark the HTTP suite@Suite(.serialized)(Swift Testing runs tests in parallel; a shared stub handler must not race). - Pure logic and precedence are tested by direct call — no fakes at all.
- Table-driven cases use
@Test(arguments:).
Coverage priority: pure logic and orchestration first, then config precedence, then arg parsing.
5. Quality gates
Three gates. Note the big difference from Python: there is no separate type
checker — the compiler is the type gate. So mypy collapses into swift build.
swift build # types (the compiler IS the gate)
swift test # Swift Testing
swift format lint --strict --recursive Sources Tests Package.swift # format/lintswift test runs Swift Testing only under the Xcode toolchain (or a swift.org
toolchain), not the Command Line Tools. Under the CLT it fails with no such
module 'Testing' — the Testing.framework is on disk under CommandLineTools,
but the CLT's SwiftPM doesn't wire it into the test build. Point at Xcode for the
test (and build) gates:
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift test(or sudo xcode-select -s once). The template uses swift format's default
2-space indentation, so no .swift-format config is needed. Add one only if
you want to override it (e.g. 4-space or a different line length).
Summary of what to produce
Faced with a Swift tool request, produce:
- A SwiftPM package copied from
assets/template/: logic + boundaries inToolCore, a thinArgumentParserexecutable,.exact()-pinned deps, the §2 structure, the §3 CLI contract and exit codes, and the robustness rules the tool's job earns (shelling out earns the timeout + preflight; the network earns the HTTP boundary + timeout; holding config/secrets earns precedence + redaction). Delete what it doesn't earn. - Tests in
ToolCoreTestscovering pure logic, config precedence, orchestration (with fakes), and any boundary the tool ships. - In one line: how to run it (
swift run tool …), and that the three gates —swift build+swift test+swift format lint— are green (tests/build via an Xcode toolchain).
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

