$ share README.md --dry-runWould airdropfile README.md (50 B)$ share rey@example.com . --smart --dry-runwarning: orbit has 1 sensitive file that would prompt: config/deploy.tsWould email → rey@example.compackage ~/Code/orbit → orbit-2026-09-23-212736.zip (825 B, estimated)subject Shared: orbitaction draft$ share '+1 (437) 555-0100' README.md 'build is up' --dry-runWould messages → +14375550100file README.md (50 B)text build is upaction draftservice iMessage$ share @team README.md --dry-runWould batch → email:rey@example.com, email:sam@example.com, messages:+14375550100file README.md (50 B)action draft
Built With
- Swift
- Swift Argument Parser
- AppKit
- AppleScript
- Network.framework
- Core Image
Runs On
macOS 12 or later, Apple silicon and Intel
Overview
share sends files from the Mac terminal through the apps that already move them: AirDrop, Mail, Messages, Shortcuts, or a link any phone on the same Wi-Fi can open. The arguments decide where things go. Folders are zipped, stripped of build output, and checked for secrets on the way out, and nothing is sent until you say so.
The Problem
The terminal is where files get made, and the worst place to send them from. A build finishes, a log needs to reach a teammate, a folder needs to get to the phone on the desk, and every one of those means leaving: find the folder in Finder, compress it, open Mail, drag it in, type the address.
The pieces are already on the Mac. AirDrop, Mail and Messages move files well. They just have no command line. share is that command line: one verb that takes whatever you point it at and hands it to the right app, without doing anything you can’t take back.
One Command
With no subcommand, share reads the shape of its arguments and picks the destination. Paths alone go to AirDrop. Anything shaped like name@host.tld opens a Mail draft, a phone number opens Messages, an @alias resolves to whatever it points at, and a group alias or a comma-separated list becomes a batch. The recipient can sit anywhere on the line, so share README.md @rey and share @rey README.md are the same command.
The rules are deliberately conservative. Phone detection rejects anything containing a dot, so an IP address or a version number never opens Messages, and without a country code only North American lengths count. A path that doesn’t exist is reported as an error, never guessed at as a recipient.
static func looksLikePhone(_ str: String) -> Bool {
let trimmed = str.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { return false }
let allowed = Set("0123456789+-() .")
guard trimmed.allSatisfy({ allowed.contains($0) }) else { return false }
// IP addresses and version-like strings contain dots between digit groups; phone numbers do not.
if trimmed.contains(".") { return false }
let digits = trimmed.filter(\.isNumber)
if trimmed.hasPrefix("+") {
return digits.count >= 7 && digits.count <= 15
}
// Without a country code only North-American-length numbers are accepted; anything
// longer (dates, IDs, international numbers without +) is too ambiguous.
return digits.count >= 10 && digits.count <= 11
}Sources/ShareCLI/Core/SmartRouter.swift: what counts as a phone number.
Drafts, Not Sends
Email and Messages open a draft. Sending is always an explicit --send, including in share batch and share diff, where 1.0 removed the last paths that could message someone on their own.
Mail is the easy half. AppleScript builds a real draft with the recipients, subject, body and attachments, and leaves the window open for review. Messages is harder: its scripting dictionary can send a message but has no way to compose one and leave it unsent. So the draft path opens an sms: URL with the text pre-filled, puts any files on the clipboard, and tells you to press ⌘V. It is one keystroke more than ideal, and the only way to draft a message without sending it.
static func draftURL(recipient: String, text: String) -> String? {
var allowed = CharacterSet.alphanumerics
allowed.insert(charactersIn: "+@.-_")
guard let handle = recipient.addingPercentEncoding(withAllowedCharacters: allowed) else { return nil }
var url = "sms:" + handle
if !text.isEmpty {
var bodyAllowed = CharacterSet.alphanumerics
bodyAllowed.insert(charactersIn: "-._~")
guard let body = text.addingPercentEncoding(withAllowedCharacters: bodyAllowed) else { return nil }
url += "&body=" + body
}
return url
}Sources/ShareCLI/Backends/MessagesBackend.swift: a pre-filled draft, since Messages can't script one.
Leaving Out the Junk
Zipping a project folder usually means zipping node_modules, the build output, the .git directory and a .env along with it. --smart makes a filtered copy first and shares that. The original is never touched, and share preview shows exactly what would be dropped before anything leaves.
$ share preview . --smartorbit [node]● orbit/ (57 files, 25.0 MB)with --smart: 9 files, 825 Bexcluded:✕ .DS_Store✕ dist✕ node_modules✕ logs/dev.log✕ .env✕ .git✕ coverage.gitignore is honored (git ls-files)⚠ sensitive files that would be shared:! config/deploy.ts (GitHub token)archive: orbit-<timestamp>.zip
Built-in rules
Version control, dependencies, build output, caches, logs, editor folders, and every
.envfile except the.env.examplekind.Project rules
Detected from the folder root.
package.json,Package.swift,Cargo.toml,go.modand eight other ecosystems each add their own exclusions..gitignore
Inside a repository the file list comes from
git ls-files, so what ships is what git tracks plus anything untracked but not ignored..shareignore and --exclude
Full gitignore syntax, anchors and negation included, for anything project-specific or one-off.
Archives are made with ditto -c -k --norsrc --keepParent, so they unpack to a single folder named after the project and carry no __MACOSX or ._ files for someone on Windows to wonder about.
$ share zip . --smart --yes -o ..warning: orbit contains files that look sensitive:config/deploy.ts (GitHub token)warning: continuing because --yes was givensmart: excluded 1 entry~/Code/orbit.zip(2.8 KB)$ unzip -l ../orbit.zipArchive: ../orbit.zipLength Date Time Name--------- ---------- ----- ----0 09-23-2026 21:27 orbit/0 09-23-2026 21:27 orbit/config/106 09-23-2026 21:27 orbit/config/deploy.ts50 09-23-2026 21:27 orbit/README.md0 09-23-2026 21:27 orbit/public/96 09-23-2026 21:27 orbit/public/logo.svg41 09-23-2026 21:27 orbit/.gitignore266 09-23-2026 21:27 orbit/package.json63 09-23-2026 21:27 orbit/.env.example0 09-23-2026 21:27 orbit/src/63 09-23-2026 21:27 orbit/src/app.tsx0 09-23-2026 21:27 orbit/src/components/47 09-23-2026 21:27 orbit/src/components/Countdown.tsx0 09-23-2026 21:27 orbit/src/lib/93 09-23-2026 21:27 orbit/src/lib/api.ts--------- -------825 15 files
Secrets Stay Home
Before a folder leaves the machine it is scanned, after exclusions, so smart mode keeps the scan quiet. File names catch the obvious: .env, id_rsa, *.pem, credentials.json. A bounded content pass catches tokens pasted into ordinary files: private-key blocks and the prefixed key formats of AWS, GitHub, GitLab, Slack, Stripe, Google, Anthropic, OpenAI, npm, SendGrid and Hugging Face.
$ share airdrop .warning: orbit contains files that look sensitive:.env (sensitive file name)config/deploy.ts (GitHub token)Share anyway? [y/N] ncancelled
Only high-precision formats are matched, so a prompt is worth reading when it appears. Without a terminal there is nobody to ask, and the share is refused unless --yes is given.
let sources: [(String, String)] = [
(#"-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY(?: BLOCK)?-----"#, "private key"),
(#"\bAKIA[0-9A-Z]{16}\b"#, "AWS access key"),
(#"\bgh[pousr]_[A-Za-z0-9]{36,}\b"#, "GitHub token"),
(#"\bsk_live_[0-9a-zA-Z]{20,}\b"#, "Stripe live key"),
(#"\bsk-ant-[A-Za-z0-9_\-]{20,}\b"#, "Anthropic API key"),
// …
]Sources/ShareCLI/Utilities/SecretsDetector.swift: a few of the content patterns.
A Link Instead of AirDrop
AirDrop needs Apple hardware on both ends. share serve covers everything else: it starts a small HTTP server on the Mac, prints a link and a QR code, and serves exactly one file to anything on the same network. Folders are zipped first, with smart mode applied.
$ share serve orbit-0.4.0.dmg --once --no-copy --timeout 60Serving orbit-0.4.0.dmg (18.0 MB)http://10.88.111.157:50273/nqlm0yckow1q/orbit-0.4.0.dmgalso: http://Reys-MacBook-Air.local:50273/nqlm0yckow1q/orbit-0.4.0.dmgWaiting for downloads for 1m 0s… (Ctrl-C to stop)↓ 10.88.111.157 downloaded orbit-0.4.0.dmg (18.0 MB)First download complete, 1 download.
Unguessable, not secret
The path carries a random 12-character token. Anyone who sees the link can download while the server runs, so it is treated like a link pasted in chat.
One file, nothing else
Only
GETandHEADon/token/file. Every other path is a 404 and every other method a 405. No listing, no upload, no way to reach anything else.Resumable
Single byte ranges are supported, so iOS Safari and download managers can pick up where they left off. A download counts once the whole file has arrived, even across several ranges.
Local only
It binds to the local network with no relay and no cloud. The transport is plain HTTP, which is fine on your own Wi-Fi and the wrong tool on a network you don’t trust.
Architecture
share is a Swift package with one executable target and no dependencies beyond Swift Argument Parser. Every sharing command runs the same four steps: InputResolver turns arguments, stdin and the clipboard into typed items, Preparer makes them safe to send, a backend delivers them, and Runner.finish records the history, posts the optional notification and prints the result.
One Pipeline
Keeping every guard inside Preparer is the rule the rest of the design leans on. A new command can’t forget the secrets scan, because it never implements one. Dry runs take the same entry point and branch into a plan that measures folders and runs the scan without copying or zipping anything, which is why --dry-run output is a faithful preview rather than a guess.
The guards themselves are blunt on purpose. Packaging /, /Users or the system volumes is always refused, the home directory needs --yes, and anything over 1 GB or 50,000 files asks first. Email attachments over 25 MB prompt before a draft is made that would only bounce.
Backends
AirDrop goes through NSSharingService, adapted from the MIT airdrop-cli: the process briefly becomes an accessory app, opens the native picker, and runs the main run loop until the transfer finishes, is cancelled, or times out. Apple exposes no API for choosing a device, so the picker is the interface. Files and URLs can’t share one AirDrop payload, so mixed sets go one item at a time.
Mail and Messages are driven with AppleScript. Messages had to be rewritten for 1.0 against the current dictionary: the old buddy and service commands no longer exist on modern macOS, and sending now goes through a participant of the first iMessage account. Shortcuts run through /usr/bin/shortcuts, and serve is a Network.framework listener.
$ share doctorshare doctor✓ macOS 26.5.2✓ share 1.0.0 at ~/Code/share/.build/release/share✓ AirDrop available✓ Mail installed✓ Messages installed✓ Shortcuts installed✓ shortcuts CLI /usr/bin/shortcuts✓ ditto /usr/bin/ditto✓ git available✓ scratch dir $TMPDIR/share-cli (9 files, 825 B in use)✓ config dir ~/.config/share✓ config ~/.config/share/config.json✓ aliases 2 aliases! completions zsh not installed (share completions --install)All checks passed, 1 suggestion above.Run 'share doctor --automation' to test Mail/Messages permissions.
Errors You Can Script Against
Every failure is a ShareError case with its own exit code: 3 for a missing path, 6 for a cancel, 7 when macOS refuses automation, 12 when a safety guard stops the command. The custom entry point prints each one with a hint, or as JSON under --json, and status messages always go to stderr so stdout stays clean for results.
$ share airdrop ./missing.txt; echo "exit $?"share: no such file or directory: ./missing.txthint: check the path, or quote it if it contains spacesexit 3$ share airdrop ./missing.txt --json{"error" : {"code" : "input_not_found","exitCode" : 3,"hint" : "check the path, or quote it if it contains spaces","message" : "no such file or directory: .\/missing.txt"},"ok" : false}
Testing
None of the tests touch AirDrop, Mail or Messages. The 73 unit tests cover routing, globbing, staging, packaging, secrets, config, history, aliases, the QR renderer and the HTTP server. They use Swift Testing, so they run with only the Command Line Tools installed; the Makefile passes the framework search paths that setup needs. A smoke script then drives the built binary through 69 end-to-end checks using --dry-run and an isolated config and scratch directory.
$ make test 2>&1 | tail -n 2✔ Suite ShareTests passed after 1.147 seconds.✔ Test run with 73 tests in 14 suites passed after 1.148 seconds.$ ./scripts/smoke.sh .build/release/share | tail -n 5ok home directory refusedok root refusedok init needs a terminal69 passed, 0 failed
Decisions
Drafts by default
An accidental send can’t be taken back; an extra click can. Every command that could message someone waits for
--send.No device flag for AirDrop
A
--toflag would be the obvious feature, but Apple exposes no API for choosing an AirDrop device. share opens the native picker, where the choice already lives, and waits for the transfer to finish.Nothing leaves the network
A relay would let
share servework between networks. It would also mean every share depends on someone else’s server. The files go through apps you already trust, or not at all.stdout is for results
Status, warnings and prompts go to stderr and
/dev/tty, so--jsonoutput and pipes stay parseable, and a piped body and a confirmation can coexist.
Install
Build from source with Swift 5.10 or the Command Line Tools, or install the formula in the repo with Homebrew. Every GitHub release also ships a universal binary.
git clone https://github.com/reyabsaluja/share.git
cd share
make && sudo make install # or: brew install --formula ./Formula/share.rb
share init # aliases, defaults, completions
share doctor # confirm everything is wired upshare is open source under the MIT license. GitHub