A Swift command-line tool that exposes Apple's on-device Foundation Models framework directly from the terminal. No API keys. No cloud. No model downloads. No Xcode required to build.
macOS 27 includes
fm, Apple's built-in Foundation Models CLI. It uses the same on-device framework.aiisupports macOS 26 and adds compact typed field options for structured output and command execution with approval.
- One-shot mode — single prompt, streams response, exits
- Pipe as content — pipe a file or command output as context, provide the instruction as the argument:
cat notes.md | aii "extract action items" - File attachment —
--fileattaches a file's contents as context - Structured output — declare typed fields to extract a flat JSON object for scripts, with
nullfor unavailable values - Interactive mode — conversational UI with session history,
/newto reset,/quitto exit- System prompt — pass a positional argument with
-ito set model persona:aii -i "you are a pirate"
- System prompt — pass a positional argument with
- Command execution — opt-in
--execlets the model run programs on your Mac, with approval - Clean error output — availability and generation errors written to stderr (optinally also as JSONL with machine-readable codes)
- macOS 26.0 or later (Apple Silicon)
- Apple Intelligence enabled in System Settings
- Swift 6.3 or 6.4 via Swiftly (see Build section)
# one-shot
aii "explain what a closure is in Swift"
# with file context
aii "summarize this" --file meeting-notes.md
# with piped content
git diff | aii "write a conventional commit message"
cat error.log | aii "what is causing this error?"
# interactive session
aii -i
aii -i "you are a helpful rubber duck"
# let the model run programs (asks before anything not allowlisted)
aii -x "how much free disk space do I have?"
# version / help
aii --version
aii --help| Flag | Short | Description |
|---|---|---|
prompt |
Positional — one-shot prompt, or system prompt with -i |
|
--interactive |
-i |
Start interactive conversational mode |
--file |
-f |
Attach file contents as context (one-shot only) |
--json |
-j |
Output errors as JSONL to stderr |
--int |
Integer field: name[:description] (repeatable, one-shot only) |
|
--float |
Number field allowing fractional values: name[:description] (repeatable, one-shot only) |
|
--bool |
Boolean field: name[:description] (repeatable, one-shot only) |
|
--str |
String field: name[:description] (repeatable, one-shot only) |
|
--model-info |
-m |
Show context size, model variant, and supported languages |
--exec |
-x |
Let the model run programs via the run_command tool |
--list-allowed |
Print the effective exec allowlist and exit | |
--reset-allowed |
Delete the exec allowlist config file and exit | |
--version |
Print version and exit | |
--help |
-h |
Print help and exit |
| Command | Description |
|---|---|
/new |
Clear history and reset the screen |
/quit or /exit |
Exit |
Declare fields to get a JSON object that a script can read directly:
printf '%s\n' 'Ada is 37 years old. Her city is not given.' | \
aii "extract the person's details" --str name --int age:years --str city
# {"name":"Ada","age":37,"city":null}
df -k / | aii "Extract the counts unchanged from the df output" \
--int total:1024-blocks --int free:AvailableRepeat --int, --float, --bool, or --str for each field. Descriptions
are optional: --int age and --int age:years both work. Split the name
and description with a colon; further colons belong to the description.
Quote arguments containing spaces or shell-special characters.
Every declared key appears in the result. Values have the declared type,
or null when the information is unavailable. Field names must be
non-empty and unique across all types. Output is a flat object; nested
objects and arrays are not supported.
The model is instructed to invent values only when you explicitly request
invented or mock data. Otherwise, unavailable values should be null.
A complete JSON document is written to stdout after generation succeeds,
followed by a newline. Errors go to stderr; --json controls their format
and is not required for structured responses. Command approvals and
allowlists still apply with --exec.
Structured output constrains the fields and value types. It does not verify facts, calculations, or the model's interpretation of command output. For exact storage measurements, extract the reported block counts and perform any unit conversion in your script.
--exec also depends on the model choosing valid commands and arguments.
Failed tool calls can leave fields as null. When you already know the
command, run it directly and pipe its output into aii, as above.
Field options work in one-shot mode with a prompt, piped content, or
--file. They cannot be combined with --interactive, --model-info,
--list-allowed, or --reset-allowed.
With --exec / -x the model gets one tool, run_command, and can act on
your Mac instead of only showing a command in a code block. There is no
shell: the model supplies a program and an argument array, so pipes,
redirects and wildcards do not work.
Commands matching the allowlist (~/.config/aii/allow, or read-only built-in
defaults until that file exists) run silently, with a log line on stderr.
Anything else prompts on /dev/tty with the exact command; y runs it once,
a adds a narrow prefix to the allowlist, anything else denies. With no
terminal available, non-allowlisted commands are denied. There is no
auto-approve flag, by design, and this is a guardrail, not a sandbox.
Commands time out after 30 s and output is capped at 2048 bytes. See
SPEC-EXEC.md for the full design and threat model.
The Apple Command Line Tools ship a Swift compiler but their SPM (Swift Package Manager) has a known manifest compiler bug on macOS 26 and 27 as well. See Learning & Gotchas below. Swiftly provides a self-contained Swift toolchain that sidesteps this entirely, without requiring a full Xcode installation.
curl -O https://download.swift.org/swiftly/darwin/swiftly.pkg
installer -pkg swiftly.pkg -target CurrentUserHomeDirectory
~/.swiftly/bin/swiftly init
# follow the prompts — adds swiftly to your PATHThen install Swift 6.3 on macOS26, or Swift 6.4 on macOS27
swiftly install 6.4.0
swiftly use 6.4.0Verify:
which swift # should be ~/.swiftly/bin/swift
swift --version # should show Swift 6.3git clone https://github.com/greg76/aii.git
cd aii
swift build -c releasemake install
# installs to ~/.local/bin/aiiErrors are written to stderr, as JSONL when --json is supplied:
{"error":"unavailable_not_enabled","message":"Apple Intelligence is not enabled...","detail":"..."}| Code | Meaning |
|---|---|
unavailable_not_supported |
Device doesn't support Apple Intelligence |
unavailable_not_enabled |
Apple Intelligence not enabled in System Settings |
unavailable_downloading |
Model assets still downloading |
context_exceeded |
Input exceeds the maximum allowed token context window — use /new |
guardrail_violation |
Prompt blocked by safety filters |
unsupported_language |
Prompt language not supported by the model |
rate_limited |
Model busy, try again |
file_not_found |
--file path doesn't exist or isn't readable |
conflicting_input |
Incompatible input or mode options, or structured output requested without a prompt or content |
invalid_field |
Empty or duplicate structured field name |
This project was built as an agentic coding exercise — the design, specification, and architecture were developed collaboratively with Claude Sonnet 4.6, and the implementation was written by Gemini 3 Flash Preview. The process surfaced several non-obvious issues worth documenting.
The biggest obstacle was a broken SPM manifest compiler in the Apple Command Line Tools on macOS 26. The error looks like this:
error: 'aii': Invalid manifest (compiled with: [..."-target",
"arm64-apple-macosx14.0"...])
Undefined symbols for architecture arm64:
"PackageDescription.Package.__allocating_init(..."
The root cause: the CLT's libPackageDescription runtime is compiled
against macosx14.0 and is ABI-incompatible with the Swift 6.x compiler,
regardless of which SDK or SDKROOT you point it at. The manifest
compiler and the Swift compiler are separate binaries and don't share the
same ABI in the macOS 26 CLT release.
The fix: install Swiftly,
which provides a self-contained Swift toolchain with its own
libPackageDescription that matches the compiler. No Xcode needed.
Counterintuitively, Package.swift should declare
swift-tools-version: 5.9 even when building with Swift 6.3. Using
6.0 as the tools version triggers the broken CLT manifest path even
when Swiftly is installed, due to how SPM selects its manifest compiler.
5.9 routes through the compatible path.
The CLT maintains a MacOSX.sdk symlink in
/Library/Developer/CommandLineTools/SDKs/ that points to the active
SDK. After updating the CLT, verify it points to the current SDK:
ls -la /Library/Developer/CommandLineTools/SDKs/MacOSX.sdk
# should point to MacOSX26.x.sdk, not MacOSX15.x.sdkAfter updating to macOS27 and the CLT v27.0.0.0.1788430756 the build broke due to a new toolchain bug. The host-side compiler invocation SwiftPM uses to build plugins under Xcode 27 is emitting a driver flag that the same-version Swiftly frontend doesn't recognize. (Skip bumped into the same issue.) The resolution is to install Swift 6.4.0, but:
- Swiftly 1.1.3 is broken
- You can not use
install 6.4.0it will complain about non existant pkg. - You can't also use
self-update, you need to reinstall Swiftly, to get to v1.1.4
- You can not use
- Once on Swiftly 1.1.4, you can issue
install 6.4.0anduse 6.4.0
If it points to an older SDK, cgo and other C toolchain operations will
use the wrong headers and frameworks. Updating the CLT via
softwareupdate fixes the symlink automatically.
Originally the on-device model has a 4,096 token context window covering both
input and output combined. This has been expanded on macOS27 devices to 8192.
In interactive mode, history accumulates with each turn and will eventually hit
this limit. Use /new to start a fresh session when this happens.
Unlike some CLI tools that accept unquoted multi-word arguments, aii
requires the prompt in quotes. This is intentional — the shell expands
special characters (?, *, $ etc.) before passing arguments to any
program, so unquoted prompts with punctuation will produce unexpected
results regardless of how the CLI is designed. Quotes are the honest
interface.
aii (Swift binary)
└── FoundationModels.framework (macOS 26, on-device)
Single self-contained binary. No C bridge, no runtime dependencies beyond
macOS 26. Conversation history is managed internally by
LanguageModelSession and discarded when the process exits.
Apple's built-in fm command arrived in macOS 27. It supports one-shot
prompts, chat, schema-based structured output, token counting, and a local
HTTP server. It also supports separate instructions through
--instructions. See Apple's
WWDC26 introduction.
For general prompting on macOS 27, fm is available without installing
another tool. aii focuses on macOS 26 compatibility, flat structured
output declared with --int, --float, --bool, and --str, and
command execution controlled by approval and an allowlist.
While working on this project apfel got published, a polished Swift CLI tool that covers a similar ground. Hats off: it's a well-designed, actively maintained project that also exposes Apple's Foundation Models from the terminal, and its source code was a useful reference for the correct Foundation Models API surface.
apfel is a broader tool — it includes an OpenAI-compatible local HTTP server, a native macOS GUI debug inspector, voice input/output, and self-discussion mode. If that feature set fits your workflow, use it.
aii made sense to complete for a few specific reasons:
- Instruction + content separation.
aiitreats piped stdin and--fileas context attached to a separate instruction, not as the prompt itself.cat notes.md | aii "extract the action items"is a different interaction model than apfel's single-string approach — more composable for scripting and agentic use cases where the instruction and the content come from different sources. - Minimal footprint. No GUI, no server, no voice — just a binary that reads from stdin and writes to stdout. Easier to audit, easier to embed as a subprocess in other tools.
- JSONL error protocol. With
--json, errors go to stderr as structured JSONL with machine-readable codes, makingaiieasier to drive programmatically from other applications or shell scripts that need to distinguish between availability failures, guardrail violations, and context window exhaustion. - Learning. This project was an agentic coding exercise from the start. Discovering apfel mid-journey was a useful reality check, not a reason to stop — the constraints, design decisions, and toolchain lessons documented here have value independent of the end result.