Thanks for helping build Capsule — a native macOS app for managing containers through a
command-line container runtime. This guide covers the architecture you’ll be working in,
the code-style rules CI enforces, and the policies for screenshots and releases.
Capsule targets a deliberately narrow, modern envelope. Contributions must stay inside it:
- Apple silicon only (
arm64), macOS 26+, CLI-backed, unsandboxed, Hardened Runtime, notarized. No Intel, no iOS, no sandboxed/App Store build. See the README.
Capsule ships an agent playbook so AI coding assistants stay inside the same guardrails as human contributors.
- CLAUDE.md is the lean playbook: orientation, when to read
code, the canonical
makecommands, and which subagent to use for which task. It treats this file and the README as the source of truth and does not duplicate them. AGENTS.md is a thin pointer to it. - Project subagents live in
.claude/agents/:command-adder(adds acontainercommand end-to-end),architecture-guardian(read-only boundary/secret reviewer),swiftui-view-builder(CapsuleUI views), andtest-author(tests). - The rules agents follow are the ones already enforced here — the layering
rules in Enforced boundaries, checked by
ArchitectureGuardTestsandScripts/check-architecture.sh; a deliberate new edge means updating both. - Human review still gates merges. Treat agent output like any other patch:
run
make check && make test, keep secrets off argv, and follow the Adding a command without touching the UI recipe. Note AI assistance in your PR (see the pull-request template).
Capsule’s reusable core is a Swift Package of strictly-layered modules under Sources/. The
macOS app bundle (Info.plist, entitlements, asset catalog, UI tests) is a thin Xcode app
target under App/, generated from App/project.yml by XcodeGen.
CapsuleApp ──▶ CapsuleUI, CapsuleTerminal, CapsuleCLIBackend, CapsuleRegistryClient,
CapsuleAutomation, CapsuleDiagnostics, CapsuleDomain, CapsuleBackend
(+ Sparkle)
CapsuleTerminal ──▶ CapsuleUI, CapsuleDomain, SwiftTerm (terminal engine adapter)
CapsuleUI ──▶ CapsuleDomain
CapsuleAutomation ──▶ CapsuleBackend (leaf / side; drives the port)
CapsuleDiagnostics ──▶ CapsuleDomain, CapsuleBackend (leaf / side)
CapsuleCLIBackend ──▶ CapsuleBackend, CapsuleDiagnostics (adapter; conforms to port)
CapsuleRegistryClient ──▶ CapsuleBackend (adapter; conforms to the search port)
CapsuleDomain ──▶ CapsuleBackend (the port)
CapsuleBackend ──▶ (no Capsule dependencies) (port; bottom of the graph)
X ──▶ Y means “X depends on Y”. CapsuleApp is the only composition root.
| Module | Responsibility |
|---|---|
CapsuleApp |
App lifecycle, Scene, menu commands, window management, the Sparkle-backed updater, composition root. |
CapsuleDomain |
Resource models, actions, task state, outcome/diagnostics types, privacy disclosure. No UI, no Process. |
CapsuleBackend |
ContainerBackend + ImageRegistrySearching protocols, shared value types (the ports), MockBackend + MockImageRegistry. |
CapsuleCLIBackend |
Process plumbing, argument building, output parsing. Conforms to ContainerBackend. |
CapsuleRegistryClient |
Unauthenticated Docker Hub search/tags over URLSession. Conforms to ImageRegistrySearching. |
CapsuleAutomation |
App Intents + AppleScript vocabulary over the backend port. |
CapsuleDiagnostics |
OSLog wrappers, diagnostic-bundle export, error normalization, secret redaction. |
CapsuleUI |
SwiftUI views, inspectors, sheets, the updater/privacy settings surfaces. |
CapsuleTerminal |
SwiftTerm/PTY engine adapter. |
Two rules are checked automatically by
ArchitectureGuardTests (under
swift test) and Scripts/check-architecture.sh (pre-commit
hook + CI):
CapsuleUInever imports a Backend module (no UI → Backend edge).CapsuleDomainnever importsCapsuleUI(no Domain → UI edge) and never usesFoundation.Process.
If you introduce a new forbidden edge deliberately, update both the test and the script.
The architecture is designed so a new container command is a backend + domain change, never a view change. To add one — say, “pause a container”:
- Domain — add a case to
ResourceActioninSources/CapsuleDomain/Action.swift:case pause(containerID: String). - Backend port — declare it on
ContainerBackend(Sources/CapsuleBackend/ContainerBackend.swift):func pause(containerID: String) async throws. - Adapter — implement it in
Sources/CapsuleCLIBackendwithArgumentBuilder+ProcessRunner+OutputParser, and add an argument-building unit test (against the built argv, no real process). - Mock — mirror the behavior in
Sources/CapsuleBackend/MockBackend.swiftso the model/UI can be tested without the CLI. - Domain orchestration — expose it from the relevant model so the UI can invoke it.
Views in CapsuleUI bind to the domain and render whatever it exposes — they neither know nor
care which backend ran the command.
CI runs make check (lint + architecture + headers) and make test. Match what’s already
there; when in doubt, read a neighbouring file.
- Formatting —
swift formatvia.swift-format. Runmake formatbefore committing; CI runsmake lint --strictand fails on any diff. - License headers — every Swift file starts with the standard header.
Scripts/add-headers.shadds it to new files;make headersverifies. - Layering — respect the dependency arrows above. No
importthat creates a forbidden edge. The domain maps backend value types into its own models so backend types never reach the UI. - Concurrency — models that the UI observes are
@MainActor @Observable(fromObservation, not SwiftUI, so the domain stays UI-free). Backends areSendable. - No force-unwrap /
try!/fatalErrorin non-test code for recoverable conditions; surface a normalizedCapsuleErrorinstead. Tests may force-unwrap fixtures. - Secrets never leak — registry passwords and other credentials are passed via stdin, never
argv, and are scrubbed by
SecretRedactorbefore any log or diagnostic export. Never add a code path that echoes a password or writes command content to a shareable artifact without going through theDiagnosticOptionsopt-in. - Tests first — add or update tests in the same change. New pure logic gets a unit test; new CLI argv gets an argument-building test; new critical UI flow gets a golden UI test.
Screenshots and other doc images live under docs/. Because they drift silently:
- A PR that changes visible UI must refresh any screenshot that now shows stale UI, in the same PR. If you add a new surface worth documenting, add its screenshot.
- Capture on an Apple-silicon Mac at the default window size in both light and dark mode
where the change is appearance-sensitive; name files
docs/screenshots/<surface>-<light|dark>.png. - Keep them lean (PNG, retina but cropped to the relevant surface). Reference them with relative Markdown links so they render on GitHub.
- If you can’t regenerate a screenshot (no Apple-silicon host), say so in the PR and a maintainer will capture it — don’t leave a stale image in place.
make check # lint + architecture boundaries + license headers
make test # unit tests (integration tests self-skip unless CAPSULE_INTEGRATION=1)Or run everything CI runs: make ci. Coverage: make coverage.
- Git hooks —
make hooks(ormake bootstrapon a fresh clone) installs a pre-commit hook running the lint + header checks on staged files. - The app bundle —
make appbuilds the.appvia XcodeGen + xcodebuild;make runlaunches it.
- Capsule uses semantic versioning. The user-facing version is
CFBundleShortVersionStringinApp/Info.plist;CFBundleVersionis the monotonic build number. - Distribution is Developer ID + notarization + stapling with Hardened Runtime, driven by
Scripts/release/andmake release(seeScripts/release/README.md). Auto-updates ship through Sparkle; a release regeneratesappcast.xmlwith the EdDSA private key. - Tagging
vX.Y.Ztriggers.github/workflows/release.yml, which signs, notarizes, staples, packages, and publishes the artifact + appcast.
Open an issue with the Bug report template — it requires your Capsule version, container system version, host macOS version, and an exported diagnostic bundle. Generate the bundle
in-app from About / Diagnostics → Export Diagnostics… (it is local-only and scrubbed of
secrets by default).