diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..7bf306e
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,36 @@
+name: CI
+
+on:
+ push:
+ branches: [master]
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ build:
+ name: Build (${{ matrix.architecture }})
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - runner: macos-15
+ architecture: arm64
+ - runner: macos-15-intel
+ architecture: x86_64
+ runs-on: ${{ matrix.runner }}
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Build release binary
+ run: swift build --configuration release --arch "${{ matrix.architecture }}"
+
+ - name: Verify version command and architecture
+ shell: bash
+ run: |
+ set -euo pipefail
+ binary="$(swift build --configuration release --arch '${{ matrix.architecture }}' --show-bin-path)/quill"
+ test "$("$binary" --version)" = "$(sed -n 's/.*static let current = "\([^"]*\)".*/\1/p' Sources/quill/Version.swift)"
+ lipo -archs "$binary" | tr ' ' '\n' | grep -qx '${{ matrix.architecture }}'
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..de5f4b9
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,105 @@
+name: Release
+
+on:
+ push:
+ tags:
+ - "v*.*.*"
+
+permissions:
+ contents: write
+
+concurrency:
+ group: release-${{ github.ref }}
+ cancel-in-progress: false
+
+jobs:
+ build:
+ name: Package (${{ matrix.architecture }})
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - runner: macos-15
+ architecture: arm64
+ - runner: macos-15-intel
+ architecture: x86_64
+ runs-on: ${{ matrix.runner }}
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Package release binary
+ shell: bash
+ run: |
+ set -euo pipefail
+ version="${GITHUB_REF_NAME#v}"
+ ./scripts/package-release.sh "$version" "${{ matrix.architecture }}"
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: quill-macos-${{ matrix.architecture }}
+ path: dist/quill-macos-${{ matrix.architecture }}.tar.gz
+ if-no-files-found: error
+
+ publish:
+ name: Publish GitHub release and Homebrew formula
+ needs: build
+ runs-on: ubuntu-latest
+ env:
+ GH_TOKEN: ${{ github.token }}
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ github.event.repository.default_branch }}
+ fetch-depth: 0
+
+ - uses: actions/download-artifact@v4
+ with:
+ path: dist
+ merge-multiple: true
+
+ - name: Create checksums
+ working-directory: dist
+ run: sha256sum quill-macos-*.tar.gz > SHA256SUMS
+
+ - name: Create or update GitHub release
+ shell: bash
+ run: |
+ set -euo pipefail
+ if gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
+ gh release upload "$GITHUB_REF_NAME" dist/quill-macos-*.tar.gz dist/SHA256SUMS \
+ --repo "$GITHUB_REPOSITORY" --clobber
+ else
+ gh release create "$GITHUB_REF_NAME" dist/quill-macos-*.tar.gz dist/SHA256SUMS \
+ --repo "$GITHUB_REPOSITORY" \
+ --verify-tag \
+ --generate-notes \
+ --title "quill ${GITHUB_REF_NAME#v}"
+ fi
+
+ - name: Update Homebrew formula
+ shell: bash
+ run: |
+ set -euo pipefail
+ version="${GITHUB_REF_NAME#v}"
+ manifest_sha256="$(sha256sum dist/SHA256SUMS | cut -d' ' -f1)"
+ arm64_sha256="$(sha256sum dist/quill-macos-arm64.tar.gz | cut -d' ' -f1)"
+ x86_64_sha256="$(sha256sum dist/quill-macos-x86_64.tar.gz | cut -d' ' -f1)"
+ ./scripts/render-homebrew-formula.sh \
+ "$version" "$GITHUB_REPOSITORY" "$manifest_sha256" \
+ "$arm64_sha256" "$x86_64_sha256"
+
+ - name: Commit Homebrew formula
+ shell: bash
+ run: |
+ set -euo pipefail
+ git add Formula/quill.rb
+ if git diff --cached --quiet; then
+ exit 0
+ fi
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git commit -m "chore(homebrew): update quill to ${GITHUB_REF_NAME#v}"
+ git pull --rebase origin "${{ github.event.repository.default_branch }}"
+ git push origin "HEAD:${{ github.event.repository.default_branch }}"
diff --git a/.gitignore b/.gitignore
index e94f7ce..9a57655 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,4 @@
.build/
+.swiftpm/
+dist/
.DS_Store
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..e5bad56
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,20 @@
+# Changelog
+
+All notable user-visible changes are recorded here. The project follows
+[Semantic Versioning](https://semver.org/).
+
+## [0.1.0] - 2026-08-01
+
+Initial public release.
+
+### Added
+
+- Separate microphone and system-audio recording on macOS 15 and later.
+- Automatic, local Parakeet transcription with `me` and `them` speaker tags.
+- Crash-resistant CAF sessions and filesystem-backed transcription recovery.
+- Menu-bar controls, diagnostics, JSON configuration, and post-session hooks.
+- Native Apple Silicon and Intel release archives with SHA-256 checksums.
+- Homebrew installation through the repository's custom tap.
+- `quill --version`, dual-architecture CI, and contributor documentation.
+
+[0.1.0]: https://github.com/digimata/quill/releases/tag/v0.1.0
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..51f2f15
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,124 @@
+# Contributing to quill
+
+Thanks for helping improve quill. Small, focused pull requests are easiest to
+review, especially around audio capture and permissions where macOS behavior
+can be subtle.
+
+## Before you start
+
+- Search existing issues before opening a new one.
+- For a substantial feature or behavior change, open an issue first so the
+ approach can be agreed before a large patch is written.
+- Never upload a real meeting recording, transcript, config file containing
+ secrets, or another person's voice. Create a short synthetic audio sample
+ when a reproduction needs audio.
+
+## Development setup
+
+You need:
+
+- macOS 15 Sequoia or later
+- Xcode 16 or later, or matching Command Line Tools
+- Swift 6
+- A microphone for capture testing; headphones are useful when testing the
+ separate mic and system tracks
+
+Fork the repository, then clone your fork:
+
+```sh
+git clone https://github.com/YOUR-NAME/quill.git
+cd quill
+git remote add upstream https://github.com/digimata/quill.git
+swift package resolve
+swift build
+```
+
+Check the CLI without starting the menu-bar daemon:
+
+```sh
+.build/debug/quill --version
+.build/debug/quill doctor
+```
+
+Running `.build/debug/quill` starts the daemon. macOS will request microphone
+and System Audio Recording permissions on first use. Development and release
+binaries may be treated as distinct permission identities after a rebuild, so
+recheck System Settings → Privacy & Security when capture unexpectedly becomes
+silent.
+
+## Project layout
+
+| Path | Purpose |
+|---|---|
+| `Sources/quill/Audio` | Microphone and Core Audio process-tap capture |
+| `Sources/quill/Transcription` | On-device transcription and job coordination |
+| `Sources/quill/UI` | Menu-bar UI |
+| `Sources/quill/RecordingSession.swift` | Session lifecycle and on-disk metadata |
+| `Sources/quill/Config.swift` | User config parsing and path resolution |
+| `scripts` | Release packaging and Homebrew formula generation |
+| `.github/workflows` | Intel/Apple Silicon CI and tagged releases |
+
+The filesystem is also the transcription queue: a session with `meta.json`
+and no `transcript.json` is pending. Preserve that recovery behavior when
+changing session or transcription code.
+
+## Making a change
+
+1. Create a branch from the latest `master`.
+2. Keep the change focused and explain behavior changes in the commit or PR.
+3. Add or update documentation for user-visible CLI, config, file-format, or
+ permission changes.
+4. Build the production configuration before opening a PR:
+
+ ```sh
+ swift build --configuration release
+ "$(swift build --configuration release --show-bin-path)/quill" --version
+ ```
+
+5. For audio changes, test start, stop, and process termination. Confirm the
+ two CAF files remain readable and that a later recording can still start.
+
+CI performs a release build on native Apple Silicon and Intel macOS runners.
+The project does not yet have a complete automated audio test suite, so include
+the exact manual scenarios you ran in the PR description.
+
+## Pull requests
+
+A useful PR description answers:
+
+- What user problem does this solve?
+- What changed, and what intentionally did not change?
+- How was it tested, including Mac architecture and macOS version?
+- Does it change permissions, config, metadata, transcript output, or recovery
+ after a crash?
+
+Do not include generated `.build` content, downloaded transcription models, or
+recordings in commits.
+
+## Maintainer release process
+
+quill uses semantic versions and starts at `0.1.0` while interfaces are still
+settling.
+
+1. Update `QuillVersion.current` in `Sources/quill/Version.swift` and
+ `CFBundleShortVersionString` in `Sources/quill/Info.plist` to the same value.
+2. Move the release notes in `CHANGELOG.md` under the new version and date.
+3. Run a release build and package the current Mac architecture:
+
+ ```sh
+ swift build --configuration release
+ ./scripts/package-release.sh 0.1.0 "$(uname -m)"
+ ```
+
+4. Commit the version bump, create an annotated tag, and push it:
+
+ ```sh
+ git tag -a v0.1.0 -m "quill 0.1.0"
+ git push origin master v0.1.0
+ ```
+
+The release workflow verifies that the tag, Swift version, and Info.plist
+version agree. It builds `arm64` and `x86_64` archives, creates a GitHub release
+with checksums, renders `Formula/quill.rb`, and commits the updated formula to
+the default branch. Repository Actions must have permission to write contents;
+branch protection must allow the release workflow's formula update.
diff --git a/README.md b/README.md
index 14192fa..5519b83 100644
--- a/README.md
+++ b/README.md
@@ -1,25 +1,69 @@
# quill
+[](https://github.com/digimata/quill/actions/workflows/ci.yml)
+[](https://github.com/digimata/quill/releases/latest)
+
A minimal, fully local macOS meeting recorder + transcriber. One menu-bar
click records your mic and all system audio as two separate tracks; when you
stop, quill transcribes both on-device and writes a speaker-tagged transcript.
Nothing ever leaves the machine.
-Named for the feather. Sibling of [parrot](https://github.com/digimata/parrot), same skeleton: single
-Swift binary, menu-bar tray, no app bundle.
+Named for the feather. Sibling of [parrot](https://github.com/digimata/parrot),
+same skeleton: single Swift binary, menu-bar tray, no app bundle.
## Install
+### Homebrew (recommended)
+
+```sh
+brew tap digimata/quill https://github.com/digimata/quill
+brew install digimata/quill/quill
+quill doctor
+```
+
+The custom tap lives in this repository. Each version tag publishes native
+Apple Silicon and Intel binaries, then updates the formula with their verified
+SHA-256 checksums.
+
+Start quill from a terminal:
+
+```sh
+quill
+```
+
+Or register its per-user LaunchAgent so it starts when you sign in:
+
+```sh
+quill install --launch-at-login
+```
+
+### Build from source
+
+You need macOS 15 or later, Xcode 16 or later (or matching Command Line Tools),
+and Swift 6:
+
```sh
+git clone https://github.com/digimata/quill.git
cd quill
swift build -c release
sudo cp .build/release/quill /usr/local/bin/quill
-quill install --launch-at-login # optional — runs in the background on login
+quill doctor
```
-**Requires:** macOS 15+ (Core Audio process taps for system audio — no
-virtual device, no kernel extension). Apple Silicon recommended for
-transcription speed.
+If `/usr/local/bin` is not on your `PATH`, install the binary into another
+directory that is. Apple Silicon is recommended for transcription speed.
+
+### Uninstall
+
+Remove the login item before removing the binary:
+
+```sh
+quill install --uninstall
+brew uninstall quill
+```
+
+Recordings and config are deliberately left in place. Delete
+`~/Recordings` and `~/.config/quill` yourself only if you no longer need them.
## How to use
@@ -114,6 +158,20 @@ quill install --uninstall
- **FluidAudio / Parakeet** — on-device Core ML transcription
- **NSStatusItem** — the whole UI
+## Contributing
+
+Issues and pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md)
+for the development setup, project layout, validation checklist, and release
+process. Please never attach a real private meeting recording to an issue;
+use a short synthetic sample when audio is needed to reproduce a bug.
+
+## Releases
+
+quill follows semantic versioning while the CLI and transcript formats settle.
+A tag such as `v0.1.0` triggers builds on native Apple Silicon and Intel GitHub
+runners, publishes both archives plus `SHA256SUMS`, and refreshes the Homebrew
+formula. See [CHANGELOG.md](CHANGELOG.md) for user-visible changes.
+
## Gotchas
- A global tap records *everything* the Mac plays — notification dings,
@@ -125,3 +183,7 @@ quill install --uninstall
engine.
- The binary embeds its Info.plist (`__TEXT,__info_plist`) so TCC can
attribute permissions to quill itself when running as a LaunchAgent.
+
+## License
+
+[MIT](LICENSE)
diff --git a/Sources/quill/Info.plist b/Sources/quill/Info.plist
index 85805b4..439802c 100644
--- a/Sources/quill/Info.plist
+++ b/Sources/quill/Info.plist
@@ -6,6 +6,10 @@
com.digimata.quill
CFBundleName
quill
+ CFBundleShortVersionString
+ 0.1.0
+ CFBundleVersion
+ 1
NSMicrophoneUsageDescription
quill records your microphone during meetings so you can transcribe them later. Audio never leaves this Mac.
NSAudioCaptureUsageDescription
diff --git a/Sources/quill/Install.swift b/Sources/quill/Install.swift
index 200912a..9c64d1d 100644
--- a/Sources/quill/Install.swift
+++ b/Sources/quill/Install.swift
@@ -4,8 +4,8 @@ import Foundation
/// Manage quill's LaunchAgent so the daemon starts at login.
///
/// We deliberately do NOT use SMAppService.mainApp here — that requires a full
-/// .app bundle. Since quill ships as a single binary in /usr/local/bin, a
-/// plain LaunchAgent plist is the simpler, more honest mechanism.
+/// .app bundle. Since quill ships as a single command-line binary, a plain
+/// LaunchAgent plist is the simpler, more honest mechanism.
struct Install: ParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Install or remove the launch-at-login LaunchAgent."
@@ -95,26 +95,56 @@ struct Install: ParsableCommand {
}
private func resolveBinaryPath() throws -> String {
- // /usr/local/bin/quill is the canonical install path. Honor a real
- // location if running from elsewhere (e.g. dev).
- let candidate = "/usr/local/bin/quill"
- if FileManager.default.isExecutableFile(atPath: candidate) {
- return candidate
- }
- // Fall back to the running executable's resolved path.
+ // Keep the path the user invoked whenever possible. In particular,
+ // this preserves Homebrew's stable /opt/homebrew/bin symlink instead
+ // of resolving it into a versioned Cellar path that breaks on upgrade.
let argv0 = CommandLine.arguments.first ?? "quill"
- if argv0.hasPrefix("/"), FileManager.default.isExecutableFile(atPath: argv0) {
- FileHandle.standardError.write(Data(
- "note: /usr/local/bin/quill not found; using \(argv0)\n".utf8
- ))
- return argv0
+ if argv0.contains("/") {
+ let workingDirectory = URL(
+ fileURLWithPath: FileManager.default.currentDirectoryPath,
+ isDirectory: true
+ )
+ let invokedPath = URL(fileURLWithPath: argv0, relativeTo: workingDirectory)
+ .standardizedFileURL.path
+ if FileManager.default.isExecutableFile(atPath: invokedPath) {
+ return invokedPath
+ }
}
+
+ // Shells do not have to pass an absolute argv[0]. Resolve the command
+ // against PATH so Homebrew, MacPorts, and developer installs work.
+ if let resolved = resolveOnPath(argv0) {
+ return resolved
+ }
+
+ // Common stable install paths are useful when argv[0] is unusual
+ // (for example when another process launches quill directly).
+ for candidate in ["/opt/homebrew/bin/quill", "/usr/local/bin/quill"]
+ where FileManager.default.isExecutableFile(atPath: candidate) {
+ return candidate
+ }
+
FileHandle.standardError.write(Data(
- "couldn't locate the quill binary. install it to /usr/local/bin/quill first.\n".utf8
+ "couldn't locate the quill binary. install it with Homebrew or copy it into a directory on PATH first.\n".utf8
))
throw ExitCode(1)
}
+ private func resolveOnPath(_ command: String) -> String? {
+ guard !command.contains("/") else { return nil }
+ let path = ProcessInfo.processInfo.environment["PATH"] ?? ""
+ for directory in path.split(separator: ":", omittingEmptySubsequences: false) {
+ let base = directory.isEmpty ? FileManager.default.currentDirectoryPath : String(directory)
+ let candidate = URL(fileURLWithPath: base, isDirectory: true)
+ .appendingPathComponent(command)
+ .standardizedFileURL.path
+ if FileManager.default.isExecutableFile(atPath: candidate) {
+ return candidate
+ }
+ }
+ return nil
+ }
+
private func uid() -> uid_t { getuid() }
private func runLaunchctl(_ args: [String]) -> (status: Int32, stderr: String) {
diff --git a/Sources/quill/Quill.swift b/Sources/quill/Quill.swift
index 57d0bb6..fd2e348 100644
--- a/Sources/quill/Quill.swift
+++ b/Sources/quill/Quill.swift
@@ -7,6 +7,7 @@ struct Quill: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "quill",
abstract: "Local meeting recorder + transcriber. Records mic and system audio as two tracks, then transcribes on-device.",
+ version: QuillVersion.current,
subcommands: [Run.self, Doctor.self, Install.self],
defaultSubcommand: Run.self
)
diff --git a/Sources/quill/Version.swift b/Sources/quill/Version.swift
new file mode 100644
index 0000000..480f327
--- /dev/null
+++ b/Sources/quill/Version.swift
@@ -0,0 +1,5 @@
+enum QuillVersion {
+ /// Keep this value in sync with CFBundleShortVersionString in Info.plist.
+ /// The release workflow refuses to package a tag with a different version.
+ static let current = "0.1.0"
+}
diff --git a/packaging/homebrew/quill.rb.template b/packaging/homebrew/quill.rb.template
new file mode 100644
index 0000000..2ff0a3e
--- /dev/null
+++ b/packaging/homebrew/quill.rb.template
@@ -0,0 +1,43 @@
+# typed: strict
+# frozen_string_literal: true
+
+# Homebrew formula for the quill macOS recorder and transcriber.
+class Quill < Formula
+ desc "Local macOS meeting recorder and on-device transcriber"
+ homepage "https://github.com/@REPOSITORY@"
+ url "https://github.com/@REPOSITORY@/releases/download/v@VERSION@/SHA256SUMS", using: :nounzip
+ version "@VERSION@"
+ sha256 "@MANIFEST_SHA256@"
+ license "MIT"
+ depends_on macos: :sequoia
+
+ resource "binary" do
+ on_arm do
+ url "https://github.com/@REPOSITORY@/releases/download/v@VERSION@/quill-macos-arm64.tar.gz"
+ sha256 "@ARM64_SHA256@"
+ end
+
+ on_intel do
+ url "https://github.com/@REPOSITORY@/releases/download/v@VERSION@/quill-macos-x86_64.tar.gz"
+ sha256 "@X86_64_SHA256@"
+ end
+ end
+
+ def install
+ resource("binary").stage { bin.install "quill" }
+ end
+
+ def caveats
+ <<~EOS
+ Before the first recording, check permissions and model availability:
+ quill doctor
+
+ To start quill automatically when you sign in:
+ quill install --launch-at-login
+ EOS
+ end
+
+ test do
+ assert_match version.to_s, shell_output("#{bin}/quill --version")
+ end
+end
diff --git a/scripts/package-release.sh b/scripts/package-release.sh
new file mode 100755
index 0000000..df0b1a1
--- /dev/null
+++ b/scripts/package-release.sh
@@ -0,0 +1,73 @@
+#!/bin/sh
+set -eu
+
+usage() {
+ echo "usage: $0 [output-directory]" >&2
+ exit 64
+}
+
+[ "$#" -ge 2 ] && [ "$#" -le 3 ] || usage
+
+version=$1
+architecture=$2
+output_directory=${3:-dist}
+
+if ! printf '%s\n' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
+ echo "version must be a semantic version such as 0.1.0" >&2
+ exit 64
+fi
+
+case "$architecture" in
+ arm64|x86_64) ;;
+ *) echo "unsupported architecture: $architecture" >&2; exit 64 ;;
+esac
+
+source_version=$(sed -n 's/.*static let current = "\([^"]*\)".*/\1/p' Sources/quill/Version.swift)
+plist_version=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' Sources/quill/Info.plist)
+
+if [ "$source_version" != "$version" ] || [ "$plist_version" != "$version" ]; then
+ echo "release version mismatch: requested=$version source=$source_version plist=$plist_version" >&2
+ exit 65
+fi
+
+host_architecture=$(uname -m)
+if [ "$host_architecture" != "$architecture" ]; then
+ echo "package on a native $architecture runner (current host: $host_architecture)" >&2
+ exit 69
+fi
+
+swift build --configuration release --arch "$architecture"
+binary_directory=$(swift build --configuration release --arch "$architecture" --show-bin-path)
+binary="$binary_directory/quill"
+
+if [ ! -x "$binary" ]; then
+ echo "release binary not found at $binary" >&2
+ exit 66
+fi
+
+actual_version=$($binary --version)
+if [ "$actual_version" != "$version" ]; then
+ echo "binary reports $actual_version, expected $version" >&2
+ exit 65
+fi
+
+if ! /usr/bin/lipo -archs "$binary" | tr ' ' '\n' | grep -qx "$architecture"; then
+ echo "binary does not contain the $architecture architecture" >&2
+ exit 65
+fi
+
+# Ad-hoc signing gives the standalone Mach-O a stable internal identity. A
+# future Developer ID certificate can replace '-' without changing packaging.
+/usr/bin/codesign --force --sign - --identifier com.digimata.quill "$binary"
+/usr/bin/codesign --verify --strict "$binary"
+
+mkdir -p "$output_directory"
+stage=$(mktemp -d "${TMPDIR:-/tmp}/quill-release.XXXXXX")
+trap 'rm -rf "$stage"' EXIT HUP INT TERM
+archive="$output_directory/quill-macos-$architecture.tar.gz"
+cp "$binary" "$stage/quill"
+cp LICENSE README.md "$stage/"
+
+rm -f "$archive"
+COPYFILE_DISABLE=1 /usr/bin/tar -czf "$archive" -C "$stage" quill LICENSE README.md
+echo "$archive"
diff --git a/scripts/render-homebrew-formula.sh b/scripts/render-homebrew-formula.sh
new file mode 100755
index 0000000..f74600b
--- /dev/null
+++ b/scripts/render-homebrew-formula.sh
@@ -0,0 +1,48 @@
+#!/bin/sh
+set -eu
+
+usage() {
+ echo "usage: $0 [output]" >&2
+ exit 64
+}
+
+[ "$#" -ge 5 ] && [ "$#" -le 6 ] || usage
+
+version=$1
+repository=$2
+manifest_sha256=$3
+arm64_sha256=$4
+x86_64_sha256=$5
+output=${6:-Formula/quill.rb}
+
+if ! printf '%s\n' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
+ echo "version must be a semantic version such as 0.1.0" >&2
+ exit 64
+fi
+
+if ! printf '%s\n' "$repository" | grep -Eq '^[0-9A-Za-z_.-]+/[0-9A-Za-z_.-]+$'; then
+ echo "repository must be owner/name" >&2
+ exit 64
+fi
+
+case "$manifest_sha256:$arm64_sha256:$x86_64_sha256" in
+ *[!0-9a-f:]*|*:|:*) echo "checksums must be lowercase hexadecimal" >&2; exit 64 ;;
+esac
+
+[ "${#manifest_sha256}" -eq 64 ] && \
+ [ "${#arm64_sha256}" -eq 64 ] && \
+ [ "${#x86_64_sha256}" -eq 64 ] || {
+ echo "checksums must contain 64 characters" >&2
+ exit 64
+}
+
+mkdir -p "$(dirname "$output")"
+sed \
+ -e "s|@VERSION@|$version|g" \
+ -e "s|@REPOSITORY@|$repository|g" \
+ -e "s|@MANIFEST_SHA256@|$manifest_sha256|g" \
+ -e "s|@ARM64_SHA256@|$arm64_sha256|g" \
+ -e "s|@X86_64_SHA256@|$x86_64_sha256|g" \
+ packaging/homebrew/quill.rb.template > "$output"
+
+echo "$output"