agentleFS
buildosaurusbuildosaurus/scripteratops/skills/swift-script/SKILL.md

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.

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
  1. Very specific:

    Keep dependencies few: swift-argument-parser (CLI) and swift-log (logging) cover most tools.

    line 46
  2. Very specific:

    Always a timeout: Wait on a DispatchSemaphore signalled by terminationHandler.

    line 89
  3. Very specific:

    Every tool ships tests in the ToolCoreTests target, @testable import ToolCore.

    line 228

≈ “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.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

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:

  1. Distribution → a SwiftPM package. Swift is compiled and has no stable single-file-with-dependencies story (swift-sh is niche; a bare #!/usr/bin/env swift file can't use SwiftPM deps and can't be unit-tested). A package is what buys dependencies and testability.
  2. Interface → swift-argument-parser (Apple).
  3. 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 ToolCore

The 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"), never from: or a range). A tool has no reason to float; swift package update is the deliberate way to bump.
  • Keep dependencies few: swift-argument-parser (CLI) and swift-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 a TOMLKit dependency.

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 Sendable protocols — CommandRunner, HTTPClient, EnvironmentReading. These are the single mock hooks. A fake is a 3-line struct conforming to the protocol.
  • Pure logic (transform) — input → output, no I/O, tested with plain #expect.
  • Config is a Codable struct; 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 as any 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.run is synchronous. A Process is a non-Sendable class; keeping the call sync means it never crosses a concurrency domain. A subprocess is blocking anyway.
  • HTTPClient.data(from:) is async — the network is the one place awaiting pays, and URLSession is Sendable.

2.2 Subprocess boundary

ProcessRunner routes all subprocess calls through Foundation.Process:

  • Never a shell. Launch /usr/bin/env with an argument list — no shell means no injection from a filename or argument.
  • Always a timeout. Wait on a DispatchSemaphore signalled by terminationHandler; on deadline, terminate() and throw ToolError.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 defaults

resolveConfig 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. URLSession does not throw on 4xx/5xx, so check HTTPURLResponse.statusCode and throw ToolError.http; a 404 page would otherwise sail through as a "successful" body.
  • Inject the session so tests register a stub URLProtocol and run offline — the Swift equivalent of a mock transport. (For server-grade streaming or heavy Linux use, AsyncHTTPClient is 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 not TERM=dumb).
  • ColorLogHandler implements swift-log's log(event:) and colorizes the level name when enabled. Implement log(event:), not the older log(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's SystemExit(143), a C signal handler cannot safely unwind defer blocks — _exit (async-signal-safe) is the honest choice.
  • Preflight external tools for glue scripts (a which-style check up front), raising ToolError.config listing 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.1

Then 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 shared assets/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 only

Without 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-stdlib for 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/--help and --version are automatic (set version: in CommandConfiguration) — unlike argparse, you don't wire them by hand.
  • Verbosity: @Flag(name: .shortAndLong) var verbose: Int counts occurrences (-vv = 2); same for quiet. Map the net count to a level with logLevel(forVerbosity:).
  • --color is an @Option over a ColorMode enum (ExpressibleByArgument).
  • --dry-run short-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. ArgumentParser renders each group as its own --help heading.
  • 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:

CodeMeaning
0Success
1Runtime error (a ToolError caught in run())
2Usage error (ArgumentParser emits this automatically)
124A subprocess exceeded its timeout (ToolError.timeout)
130Interrupted — SIGINT / Ctrl-C (Swift's default)
141Broken pipe — downstream closed (SIGPIPE ignored → write error)
143Terminated — 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 URLProtocol on a URLSessionConfiguration.ephemeral session 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/lint

swift 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:

  1. A SwiftPM package copied from assets/template/: logic + boundaries in ToolCore, a thin ArgumentParser executable, .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.
  2. Tests in ToolCoreTests covering pure logic, config precedence, orchestration (with fakes), and any boundary the tool ships.
  3. 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).

Download now (ZIP)

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
What did you use it for? e.g. Reviewing pull requests in a Rails app

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.