When I was building SwiftBash I made surprisingly quick headway on the basic CLI utilities — jq, awk, sed, grep. Each one is a small, well-scoped language, and once you sit down with the spec it really is just a parser and an evaluator.
Then I hit a wall. The two CLIs I reach for most as a working developer aren’t tiny languages — they’re gh and glab, the GitHub and GitLab clients. And right next to them, the granddaddy of all dev CLIs: git. These aren’t 2,000-line tools. gh alone is roughly fifty thousand lines of Go, with subcommand trees, OAuth flows, REST + GraphQL clients, pagination, archive extraction, jq filtering — the works. Reimplementing all of that by hand felt like a year of evenings.
But the source code is right there on GitHub. And I have a coding agent. So I began to wonder: shouldn’t Opus 4.7 1M (extra-high) be able to translate cli/cli into Swift for me, given the original as ground truth?
It turns out: yes. That’s where SwiftPorts comes from.
What “porting” actually looks like
The workflow is duller than it sounds. I point the agent at a subcommand in the upstream Go source, give it the existing Swift module’s conventions, and ask it to produce the equivalent under swift-argument-parser. Then I run the resulting binary side-by-side with the original tool and look for divergence: different exit codes, different output framing, missing flags, wrong default behaviour.
A working example from this morning: a Codex reviewer caught that our gh api --input was silently dropping -f/-F field flags, where upstream documents that those flags should be appended to the endpoint’s query string. Two-line bug, one acceptance test, merged. That kind of paper-cut parity bug is the entire game. Build the surface, then beat it against reality until it behaves identically.
I started with gh and through a few iterations got most of the useful operations working: repo, pr, issue, release, workflow, run, gist, project, label, org, cache, variable, secret, ssh-key, gpg-key, search, config, auth. Some obscure corners — like gh attestation — I left for later. If you genuinely use gh attestation in anger, please tell me what it’s good for.
Next was glab for GitLab (which I run self-hosted), and a pattern emerged: a lot of the host-agnostic plumbing — TTY detection, ANSI handling, the keychain wrapper, the abstraction over git for the clone-and-checkout dance — was duplicated between the two. So we factored that out into ForgeKit, which both GhCommand and GlabCommand now share.
The git-aware ones, and SwiftGit on libgit2
There’s a class of gh/glab operations that aren’t pure remote API calls — they’re “git-aware.” gh pr create needs to know your current branch and the remote’s owner/name. gh repo clone shells out to git clone. gh pr checkout does a git fetch followed by git checkout. glab mr checkout is the same shape.
Upstream gh solves this by literally invoking /usr/bin/git as a subprocess. That works, but it’s not embeddable: no Process on iOS, no system git binary in a sandboxed Mac App Store app.
A while back I had some successful experiments with libgit2, the C reimplementation of git. So I wondered: could the agent build a complete git client on top of it? And — yes, it could. Most of the work, it turns out, is mechanical wiring: take an ArgumentParser flag, map it to the corresponding git_* option struct field, hand the struct to libgit2, translate the result back. There are sharp edges around credential callbacks, signature resolution, and the per-op git_libgit2_opts global state, but the bulk of git init / clone / fetch / pull / push / status / log / diff / show / commit / merge / rebase / cherry-pick / reset / checkout / switch / restore / add / rm / mv / clean / stash / tag / branch / remote / config / rev-parse / ls-files / ls-tree / cat-file / describe / blame / apply / reflog is just plumbing.
That became the SwiftGit module, with its own git executable. ForgeKit’s GitClient protocol lets gh/glab swap between a ProcessGitClient (the “shell out” path) and SwiftGit’s libgit2-backed in-process client without changing a line of subcommand code. On iOS, SwiftGit is the only path that works.
The compression rabbit hole
A handful of gh operations need decompression. gh release download should auto-extract .zip / .tar.gz / .tar.bz2 / .tar.xz / .tar.zst / .tar.lz4 assets without subprocess calls; gh run download and gh run view --log need to crack open ZIP-format workflow artifacts.
I started with ZIPFoundation, and that worked beautifully — until I tried to build for Android. (I now routinely build for Android and Windows in CI, because nothing focuses the mind like five green checkmarks across iOS, Mac, Linux, Windows and Android)
Marc Prud’hommeaux dropped a great suggestion in issue #6: use his Swift-friendly fork of libarchive instead of ZIPFoundation. I had Opus evaluate it, and the conclusion was: this changes the scope. libarchive doesn’t just give you Zip — it gives you tar with auto-detected gzip / bzip2 / xz / zstd / lz4 filtering, plus 7z, plus a half-dozen other formats nobody’s asking for. So instead of one ZipKit umbrella we ended up with a whole compression family: ZipKit, TarKit, GzipKit, Bzip2Kit, XzKit, ZstdKit, Lz4Kit. Each one ships its own kit (the library) plus a command (the CLI), plus the one-letter aliases (gunzip, zcat, bunzip2, xzcat, unzstd, lz4cat, …) you’d find in coreutils.
The fun edge case: not every codec ships as a system library on iOS. liblzma and liblz4 in particular aren’t separately available there. So those kits look at the platform at compile time and route through Apple’s Compression framework instead — XzKit uses Compression.framework‘s LZMA path; Lz4Kit uses COMPRESSION_LZ4_RAW. The result is that an iOS app can gh release download a .tar.xz asset and unpack it entirely in-process, with no subprocess and no missing-codec apology.
JqKit, stolen from SwiftBash
gh api --jq and glab api --jq are how you actually use those commands productively against GraphQL. Upstream gh runs the response through a real jq library; the lazy port would shell out to /usr/bin/jq.
I’d already written a pure-Swift jq parser + evaluator + builtins for SwiftBash, so I stole it back and wrapped it in JqKit — a Jq.eval / Jq.evalString facade with no system C dependency, callable from any Swift context. gh api --jq '.full_name' now runs the filter in-process. So does glab api --jq.
This is the part of SwiftPorts that’s genuinely mutual with SwiftBash. Both projects benefit; neither owns the code.
Sandboxing and async everything
Once the surface area was wide enough, I started prep work for plugging these tools into SwiftBash. That meant two big mechanical refactors.
The first was making everything async-throwing and adding Task.checkCancellation() calls inside every hot loop — the recursive directory walks in tar, the per-page pagination loops in gh search, the byte-pump loops in the compression engines. The user-facing payoff is that SwiftBash can Ctrl-C a running operation and have it actually stop in milliseconds, instead of waiting out the rest of a 50,000-file walk.
The second was a sandbox. SwiftBash, when embedded in an iOS or sandboxed-Mac app, can’t run with the host’s full filesystem and environment — it has to be confined to a folder, with environment variables and argv strictly under the embedder’s control.
What I really wanted was for the OS to provide me a sandbox primitive I could just enter. macOS has sandbox_init and Apple’s seatbelt profiles, but they’re private API and not what you want to be shipping on the App Store. iOS doesn’t expose anything similar at all. So I had to build it in user space.
The result is the Sandbox module, about 650 lines of Swift in two files. It’s a default-deny @TaskLocal policy: when Sandbox.current is non-nil, every URL handed to the gated I/O sites in SwiftPorts has to authorize through Sandbox.authorize(_:), and every ambient reach (environment variables, process arguments, region directories like ~/.config) consults the sandbox’s own values rather than the host’s. Two factories cover the common cases: Sandbox.rooted(at:) for single-folder confinement on Mac/Linux, and Sandbox.appContainer(id:) for iOS where the standard documents/temporary/caches/group folders form the natural perimeter.
Crucially, the sandbox also intercepts environment variable reads. The naïve sandbox is the one you can escape with HOME=/etc git config --global ... — point a tool at a “different” home directory and watch it write outside your perimeter. SwiftPorts code never reads ProcessInfo.processInfo.environment directly; it reads through the sandbox, which by default returns [:] and only returns host values if the embedder explicitly asks for passthrough. I went through every existing call site with a regression test that bans ambient ProcessInfo and FileManager access in Sources/, so the perimeter doesn’t bit-rot.
When Sandbox.current is nil — which is the case for everyone running the binaries directly from the command line — every gate is a no-op and behaviour is identical to the original. You only pay for the sandbox if you’re embedding.
So… where does this all live?
SwiftPorts is a separate repo from SwiftBash on purpose. Developing the dev tooling separately keeps each tractable and lets the ports be used as true CLIs, or embedded into apps without dragging in an interpreter. You could, for example, build a real GitHub client for iPad on top of GitHub + SwiftGit + JqKit and never need SwiftBash at all.
I’m still figuring out where the line is. My current feeling: anything that’s a port of a real CLI utility belongs in *-Ports. SwiftBash should be just the interpreter — the bash language, expansions, redirections, control flow — pulling in builtins by Swift Package dependency. SwiftScript, the Swift interpreter, fits the same shape: another language frontend that consumes the same builtins.
If that lands, the picture I see is a pull-down bash shell on my iPad with a coding agent in the next pane, fully sandboxed and App Store-legal. SwiftBash for the shell, SwiftScript for the inline-Swift-snippet escape hatch, SwiftPorts for the actual work — git, gh, tar, jq. That’s the unifying daydream.
Right now SwiftPorts is still very much a solution in search of a problem. I have a nebulous vision of a command-center app that reads issues from GitLab and GitHub, hands them to coding agents, deals with review comments, watches CI, and fixes things that come up there — a universal Mac/iOS app I could keep running on my iPad to babysit my OSS while AFK. Maybe that’s where this goes.
What I actually need from you
All three projects — SwiftPorts, SwiftBash, SwiftScript — are mostly in want of real use cases that exercise them against the originals. The goal is for the ported utilities to behave exactly like the tools they replace. If you run gh or glab or git from SwiftPorts and you spot anything — a flag the upstream tool accepts that ours rejects, an output format that’s subtly different, a default that diverges, an exit code that doesn’t match — that’s the gold. Open an issue and I’ll feed it to the agent. The closer we get to invisible parity, the more useful any of this becomes.
The repo is on GitHub. Tell me what you’d build with it — and tell me where it gets things wrong.
Categories: Updates