~/Code/orbit
$ share README.md --dry-run
Would airdrop
file README.md (50 B)
$ share rey@example.com . --smart --dry-run
warning: orbit has 1 sensitive file that would prompt: config/deploy.ts
Would email → rey@example.com
package ~/Code/orbit → orbit-2026-09-23-212736.zip (825 B, estimated)
subject Shared: orbit
action draft
$ share '+1 (437) 555-0100' README.md 'build is up' --dry-run
Would messages → +14375550100
file README.md (50 B)
text build is up
action draft
service iMessage
$ share @team README.md --dry-run
Would batch → email:rey@example.com, email:sam@example.com, messages:+14375550100
file 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.

share ./distAirDrop picker, folder zipped first
share rey@example.comMail draft with the items attached
share +14375550100Messages, with the text pre-filled
share @rey report.pdfwherever the alias points
share @team notes.mda batch: everyone in the group gets the same items

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.

~/Code/orbit
$ share preview . --smart
orbit [node]
● orbit/ (57 files, 25.0 MB)
with --smart: 9 files, 825 B
excluded:
✕ .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
Figure 1. share preview on a small Next.js project. With --smart, 57 files and 25 MB become 9 files and 825 bytes, and the one secret that no file-name rule catches is flagged by its contents.
  • Built-in rules

    Version control, dependencies, build output, caches, logs, editor folders, and every .env file except the .env.example kind.

  • Project rules

    Detected from the folder root. package.json, Package.swift, Cargo.toml, go.mod and 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.

~/Code/orbit
$ share zip . --smart --yes -o ..
warning: orbit contains files that look sensitive:
config/deploy.ts (GitHub token)
warning: continuing because --yes was given
smart: excluded 1 entry
~/Code/orbit.zip
(2.8 KB)
$ unzip -l ../orbit.zip
Archive: ../orbit.zip
Length 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.ts
50 09-23-2026 21:27 orbit/README.md
0 09-23-2026 21:27 orbit/public/
96 09-23-2026 21:27 orbit/public/logo.svg
41 09-23-2026 21:27 orbit/.gitignore
266 09-23-2026 21:27 orbit/package.json
63 09-23-2026 21:27 orbit/.env.example
0 09-23-2026 21:27 orbit/src/
63 09-23-2026 21:27 orbit/src/app.tsx
0 09-23-2026 21:27 orbit/src/components/
47 09-23-2026 21:27 orbit/src/components/Countdown.tsx
0 09-23-2026 21:27 orbit/src/lib/
93 09-23-2026 21:27 orbit/src/lib/api.ts
--------- -------
825 15 files
Figure 2. The smart zip of the same project. --yes lets the flagged file through after printing it; without it, share would ask.

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.

~/Code/orbit
$ share airdrop .
warning: orbit contains files that look sensitive:
.env (sensitive file name)
config/deploy.ts (GitHub token)
Share anyway? [y/N] n
cancelled
Figure 3. The prompt reads from /dev/tty, so it still works when a body is piped in on stdin. Declining exits with code 6.

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.

~/Code
$ share serve orbit-0.4.0.dmg --once --no-copy --timeout 60
Serving orbit-0.4.0.dmg (18.0 MB)
http://10.88.111.157:50273/nqlm0yckow1q/orbit-0.4.0.dmg
also: http://Reys-MacBook-Air.local:50273/nqlm0yckow1q/orbit-0.4.0.dmg
Waiting 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.
Figure 4. A real run, with the download made by curl on the same Mac. --once stops the server after the first complete 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 GET and HEAD on /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.

share <args>smart routing or a subcommandInputResolverpaths, URLs, text, stdin, the clipboardPreparerevery guard lives here and only hereguards/, ~, 1 GBstaging--smart rulessecretsnames + tokenspackagingditto, one folderlimits25 MB mailAirDropNSSharingServiceMailAppleScript draftMessagessms: URLShortcutsshortcuts runserveNWListenerRunner.finishhistory, notification, JSON result
Figure 5. The path behind every sharing command. Guards exist in exactly one place, so no command can skip them.

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.

~/Code/orbit
$ share doctor
share 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.
Figure 6. share doctor checks each moving part. Automation permission is only tested on request, because testing it means driving Mail and Messages.

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.

~/Code/orbit
$ share airdrop ./missing.txt; echo "exit $?"
share: no such file or directory: ./missing.txt
hint: check the path, or quote it if it contains spaces
exit 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
}
Figure 7. The same failure, written for a person and for a script.

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.

~/Code/share
$ 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 5
ok home directory refused
ok root refused
ok init needs a terminal
69 passed, 0 failed
Figure 8. Both suites, run for this post.

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 --to flag 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 serve work 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 --json output 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 up

share is open source under the MIT license. GitHub