ワンクリックで
incur-swift
// incur-swift is the Swift port of the incur framework for building CLIs that work for both AI agents and humans. Use when creating new Swift CLIs.
// incur-swift is the Swift port of the incur framework for building CLIs that work for both AI agents and humans. Use when creating new Swift CLIs.
| name | incur-swift |
| description | incur-swift is the Swift port of the incur framework for building CLIs that work for both AI agents and humans. Use when creating new Swift CLIs. |
| command | incur-swift |
Swift framework for building CLIs for agents and human consumption. Strictly typed schemas for arguments and options via Swift macros, structured output envelopes, auto-generated skill files, and agent discovery via Skills, MCP, and --llms.
This SKILL is the Swift counterpart of the upstream TypeScript incur SKILL.md. Section structure is preserved so users moving between the TS and Swift docs find the same anchors.
Add to your Package.swift:
dependencies: [
.package(url: "https://github.com/douglance/incur-swift.git", from: "0.1.0"),
],
targets: [
.executableTarget(
name: "MyCLI",
dependencies: [
.product(name: "Incur", package: "incur-swift"),
]
),
]
Supported platforms: macOS 13+, iOS 18+, tvOS 17+, visionOS 2+. Swift 6.0+.
import Incur
struct GreetHandler: CommandHandler {
func run(_ ctx: CommandContext) async -> CommandResult {
let name = ctx.args["name"]?.stringValue ?? "world"
return .ok(data: ["message": .string("hello \(name)")])
}
}
@main
struct GreetCLI {
static func main() async {
let cli = Cli("greet")
.description("A greeting CLI")
.command("greet", CommandDef(
name: "greet",
argsFields: [
FieldMeta(name: "name", description: "Name to greet",
fieldType: .string, required: true),
],
handler: GreetHandler()
))
try? await cli.serve()
}
}
greet world
# → message: hello world
Cli(name) is the entry point. It has two modes:
Pass a single root command and don't register subcommands:
let cli = Cli("tool")
.description("Does one thing")
.command("tool", CommandDef(
name: "tool",
argsFields: [FieldMeta(name: "file", fieldType: .string, required: true)],
handler: ToolHandler()
))
Register multiple subcommands via .command(). Each call returns the CLI instance and is chainable:
let cli = Cli("gh")
.version("1.0.0")
.description("GitHub CLI")
.command("status", CommandDef(name: "status", handler: StatusHandler()))
.command("clone", CommandDef(name: "clone", handler: CloneHandler()))
try await cli.serve()
cli.command("install", CommandDef(
name: "install",
description: "Install a package",
argsFields: [
FieldMeta(name: "package", description: "Package name",
fieldType: .string, required: false),
],
optionsFields: [
FieldMeta(name: "save_dev", description: "Save as dev dependency",
fieldType: .boolean, alias: "D"),
FieldMeta(name: "global", description: "Install globally",
fieldType: .boolean, alias: "g"),
],
aliases: ["save_dev": "D", "global": "g"],
examples: [
Example(command: "express", description: "Install a package"),
Example(command: "vitest --save-dev", description: "Install as dev dependency"),
],
handler: InstallHandler()
))
.command() is chainable — it returns the CLI instance:
cli
.command("ping", CommandDef(name: "ping", handler: PingHandler()))
.command("version", CommandDef(name: "version", handler: VersionHandler()))
Create a sub-CLI and mount it as a command group:
let cli = Cli("gh").description("GitHub CLI")
let pr = Cli("pr").description("Pull request commands")
pr.command("list", CommandDef(
name: "list",
description: "List pull requests",
optionsFields: [
FieldMeta(name: "state", fieldType: .enum(["open", "closed", "all"]),
defaultValue: "open"),
],
handler: ListPRHandler()
))
pr.command("view", CommandDef(
name: "view",
description: "View a pull request",
argsFields: [FieldMeta(name: "number", fieldType: .number, required: true)],
handler: ViewPRHandler()
))
cli.group(pr)
try await cli.serve()
gh pr list --state closed
gh pr view 42
Groups nest arbitrarily — cli.group(pr).group(...) builds gh pr review approve-style trees.
Flatten the command tree into HTTP routes, then plug into your HTTP framework of choice (Hummingbird, Vapor, swift-nio):
let routes = cli.flattenCommands() // [HttpRoute]
// Each HttpRoute has .path, .method, .handler that maps Request -> Response.
Argv translates into HTTP using curl-style conventions: positional args become path segments, options become query string or JSON body. Responses are JSON envelopes:
{ "ok": true, "data": { ... }, "meta": { "command": "users", "duration": "3ms" } }
Streaming commands (CommandResult.stream(_:)) are emitted as NDJSON (application/x-ndjson).
Generate commands from an OpenAPI 3.x spec:
let commands = generateCommands(
spec: openApiSpec,
fetchFn: myHttpClient,
options: GenerateOptions(basePath: "https://api.example.com")
)
for cmd in commands {
cli.command(cmd.name, cmd)
}
This is the Swift equivalent of .command('api', { fetch, openapi: spec }) in the TS package — same idea, different shape.
All schemas use [FieldMeta] (or the @IncurArgs / @IncurOptions macros to derive them from struct fields). Arguments are positional (assigned by order in argsFields). Options are named flags.
argsFields: [
FieldMeta(name: "repo", description: "owner/repo", fieldType: .string, required: true),
FieldMeta(name: "branch", description: "Branch name", fieldType: .string, required: false),
]
tool clone owner/repo main
# ^^^^^^^^^^ ^^^^
# repo branch
optionsFields: [
FieldMeta(name: "state", fieldType: .enum(["open", "closed"]),
defaultValue: "open"),
FieldMeta(name: "limit", fieldType: .number, defaultValue: 30),
FieldMeta(name: "label", fieldType: .array(.string), required: false),
FieldMeta(name: "verbose", fieldType: .boolean, required: false),
]
Supported parsing:
--flag value and --flag=value-f value short aliases (via alias: on FieldMeta or via aliases: map)-abc stacked short aliases — all but the last must be boolean or count-vvv count flag incrementing--verbose boolean flags (true), --no-verbose (false)--label bug --label feature array optionsdefaultValue:, optionality from required: falseSet on FieldMeta directly:
FieldMeta(name: "state", fieldType: .string, alias: "s")
FieldMeta(name: "limit", fieldType: .number, alias: "l")
Or via the aliases: parameter on CommandDef (overrides field-level aliases):
aliases: ["state": "s", "limit": "l"]
tool list -s closed -l 10
Mark options as deprecated with @Incur(deprecated: "...") on the macro side, or deprecated: true on a hand-rolled FieldMeta. Shows [deprecated] in --help, **Deprecated.** in skill docs, deprecated: true in JSON Schema, and emits a stderr warning in TTY mode.
@IncurOptions
struct DeployOptions {
/// Availability zone
@Incur(deprecated: "Use --region instead")
var zone: String?
/// Target region
var region: String?
}
Declare an env schema with @IncurEnv (or envFields: on CommandDef). Values are read from ProcessInfo.processInfo.environment and coerced.
@IncurEnv
struct AppEnv {
/// Auth token
@Incur(env: "NPM_TOKEN")
var npmToken: String?
/// Registry URL
@Incur(env: "NPM_REGISTRY", default: "https://registry.npmjs.org")
var npmRegistry: String
}
Provide alternative usage strings to show in --help:
CommandDef(
name: "curl-md",
argsFields: [FieldMeta(name: "url", fieldType: .string, required: true)],
optionsFields: [
FieldMeta(name: "objective", fieldType: .string, required: false),
],
usagePatterns: [
UsagePattern(args: ["url"]),
UsagePattern(args: ["url"], options: ["objective"]),
UsagePattern(prefix: "cat file.txt |", suffix: "| head"),
],
handler: CurlHandler()
)
Renders as:
Usage: curl-md <url>
curl-md <url> --objective <objective>
cat file.txt | curl-md | head
Every handler returns a CommandResult. incur wraps the data in a structured envelope and serializes to the requested format.
Define outputSchema to declare the return shape. The runtime validates handler output against it:
CommandDef(
name: "info",
outputSchema: .object([
"name": .string,
"version": .string,
]),
handler: InfoHandler()
)
Control with --format <fmt> or --json:
| Flag | Format | Description |
|---|---|---|
| (default) | TOON | Token-efficient, ~40% fewer tokens than JSON |
--format json | JSON | JSONSerialization-safe |
--format yaml | YAML | Human-readable |
--format md | Markdown | Tables for docs/issues |
--format jsonl | NDJSON | One JSON line per stream chunk |
--format table | Table | ASCII column layout |
--format csv | CSV | Comma-separated values |
With --verbose, the full envelope is emitted:
tool info express --verbose
ok: true
data:
name: express
version: 4.21.2
meta:
command: info
duration: 12ms
Without --verbose, only data is emitted. On errors, only the error block is emitted.
Use --filter-output to prune command output to specific keys. Supports dot-notation for nested access, array slices with [start,end], and comma-separated paths:
tool users --filter-output users.name
tool users --filter-output users[0,2].name
tool users --filter-output id,title,nested.value
The filter implementation lives in Sources/Incur/Filter.swift and is shared across CLI and HTTP transports.
Use --token-count, --token-limit, and --token-offset to manage large outputs. Tokens are estimated using LLM tokenization rules:
# Check token count
tool users --token-count
# → 42
# Limit to first 20 tokens
tool users --token-limit 20
# Paginate with offset
tool users --token-offset 20 --token-limit 20
With --verbose, truncated output includes meta.nextOffset for programmatic pagination.
Use --schema to print the JSON Schema for a command's arguments, environment variables, options, and output:
tool install --schema
tool install --schema --format json # machine-readable
Not supported on fetch-gateway commands.
incur adapts output based on whether stdout is a TTY:
| Scenario | TTY (human) | Non-TTY (agent/pipe) |
|---|---|---|
| Command output | Formatted data only | TOON envelope |
| Errors | Human-readable message | Error envelope |
--help | Pretty help text | Same |
--json / --format | Overrides to structured | Same |
CommandContext is what handlers receive in run(_ ctx:).
agent booleanctx.agent is true when stdout is not a TTY (piped or consumed by an agent), false when running in a terminal:
struct DeployHandler: CommandHandler {
func run(_ ctx: CommandContext) async -> CommandResult {
if !ctx.agent {
print("Deploying...")
}
return .ok(data: ["status": "ok"])
}
}
.ok() and .error() helpersCommandResult has variants for explicit result control:
struct GetHandler: CommandHandler {
func run(_ ctx: CommandContext) async -> CommandResult {
let id = ctx.args["id"]?.intValue ?? 0
guard let item = await db.find(id) else {
return .error(
code: "NOT_FOUND",
message: "Item \(id) not found",
retryable: false,
exitCode: 1
)
}
return .ok(data: item.toJSONValue())
}
}
Suggest next commands to guide agents on success:
return .ok(
data: ["id": 42, "name": .string(ctx.args["name"]?.stringValue ?? "")],
cta: CtaBlock(
commands: [
.detailed(command: "get 42", description: "View the item"),
.simple("list"),
],
description: "Suggested commands:"
)
)
Or on errors, to help agents self-correct:
return .error(
code: "NOT_AUTHENTICATED",
message: "GitHub token not found",
retryable: true,
exitCode: 1,
cta: CtaBlock(
commands: [
.detailed(command: "auth login", description: "Log in to GitHub"),
.detailed(command: "config set --token <token>", description: "Set token manually"),
],
description: "To authenticate:"
)
)
Every incur-swift CLI has built-in Model Context Protocol (MCP) support — exposing commands as MCP tools that agents can call directly. The implementation lives under Sources/Incur/Mcp.swift and uses the swift-sdk MCP package.
mcp add built-in commandRegister the CLI as an MCP server for your agents:
my-cli mcp add
This registers the CLI with your agent's MCP config. Works with Claude Code, Cursor, Amp, and others out of the box (21 agents supported via Sources/Incur/Agents.swift).
Options:
| Flag | Description |
|---|---|
-c, --command | Override the command agents will run to start the server |
--agent <agent> | Target a specific agent (e.g. claude-code, cursor) |
--no-global | Install to project instead of globally |
--mcp flagStart the CLI as an MCP stdio server:
my-cli --mcp
This exposes all commands as MCP tools over stdin/stdout. Command groups are flattened with underscores (e.g. pr_list, pr_view). Arguments and options are merged into a single flat input schema.
All incur-swift CLIs can auto-generate and install agent skill files with skills add:
my-cli skills add
This generates Markdown skill files from your command definitions and installs them so agents discover your CLI automatically.
Configure skills add on the root Cli:
let cli = Cli("my-cli")
.sync(SyncConfig(
depth: 1,
include: ["_root"],
suggestions: ["install react as a dependency", "check for outdated packages"]
))
| Option | Type | Description |
|---|---|---|
depth | Int | Grouping depth for skill files. 0 = single file, 1 = one per top-level command. Default: 1 |
include | [String] | Glob patterns for additional SKILL.md files to include. Use "_root" for the project-level SKILL.md |
suggestions | [String] | Example prompts shown after sync to help users get started |
--llms flagEvery incur-swift CLI gets a built-in --llms flag that outputs a machine-readable manifest of all commands:
tool --llms # Markdown skill documentation (default)
tool --llms-full # Full skill manifest including hidden commands
tool --llms --format json # JSON Schema manifest
Markdown sample:
# tool install
Install a package
## Arguments
| Name | Type | Required | Description |
| --------- | -------- | -------- | ----------------------- |
| `package` | `string` | no | Package name to install |
## Options
| Flag | Type | Default | Description |
| ------------ | --------- | ------- | ---------------------- |
| `--save-dev` | `boolean` | | Save as dev dependency |
| `--global` | `boolean` | | Install globally |
JSON sample:
{
"version": "incur.v1",
"commands": [
{
"name": "install",
"description": "Install a package",
"schema": {
"args": { "type": "object", "properties": { "package": { "type": "string" } } },
"options": { "type": "object", "properties": { "save_dev": { "type": "boolean" } } },
"output": { "type": "object", "properties": { "added": { "type": "number" } } }
}
}
]
}
| Flag | Description |
|---|---|
--help, -h | Show help for the CLI or a specific command |
--version | Print CLI version |
--llms | Output agent-readable command manifest |
--llms-full | Output full skill manifest |
--mcp | Start as an MCP stdio server |
--json | Shorthand for --format json |
--format <fmt> | Output format (toon, json, yaml, md, jsonl, table, csv) |
--verbose | Include full envelope (ok, data, meta) |
--filter-output <k> | Prune output to specific keys |
--schema | JSON Schema for command input |
--token-count | Print token count for output |
--token-limit <n> | Truncate output to N tokens |
--token-offset <n> | Skip first N tokens (paginate) |
completions <shell> | Generate shell completions (bash, zsh, fish, nushell) |
skills add | Sync skill files to AI agents |
mcp add | Register as MCP server with AI agents |
CommandDef(
name: "deploy",
argsFields: [
FieldMeta(name: "env", fieldType: .enum(["staging", "production"]), required: true),
],
optionsFields: [FieldMeta(name: "force", fieldType: .boolean)],
examples: [
Example(command: "staging", description: "Deploy to staging"),
Example(command: "production --force", description: "Force deploy to prod"),
],
handler: DeployHandler()
)
Examples appear in --help output and generated skill files.
CommandDef(
name: "publish",
hint: "Requires NPM_TOKEN to be set in your environment.",
handler: PublishHandler()
)
Hints display after examples in help output and are included in skill files.
Control whether output data is displayed to humans. .all (default) shows output to everyone. .agentOnly suppresses data in human/TTY mode while still returning it via --json, --format, or --verbose.
CommandDef(
name: "deploy",
outputPolicy: .agentOnly,
handler: DeployHandler()
)
Set on a group or root CLI to inherit across children. Children can override per-command.
Register composable before/after hooks with cli.useMiddleware(_:). Middleware executes in registration order, onion-style. Each calls await next() to proceed.
let cli = Cli("deploy-cli")
.description("Deploy tools")
.useMiddleware { @Sendable ctx, next in
let start = Date()
await next()
print("took \(Date().timeIntervalSince(start) * 1000)ms")
}
.command("deploy", CommandDef(name: "deploy", handler: DeployHandler()))
$ deploy-cli deploy
# → deployed: true
# took 12ms
Middleware on a sub-CLI only applies to its commands:
let admin = Cli("admin")
.description("Admin commands")
.useMiddleware { @Sendable ctx, next in
guard isAdmin() else {
fputs("forbidden\n", stderr)
return
}
await next()
}
.command("reset", CommandDef(name: "reset", handler: ResetHandler()))
cli.group(admin)
Per-command middleware runs after root and group middleware, and only for that command:
let requireAuth: MiddlewareFn = { @Sendable ctx, next in
guard ctx.vars["user"] != nil else {
fputs("must be logged in\n", stderr)
return
}
await next()
}
cli.command("deploy", CommandDef(
name: "deploy",
middleware: [requireAuth],
handler: DeployHandler()
))
Middleware does not run for built-in commands (--help, --llms, --mcp, mcp add, skills add).
Middleware sets typed variables via ctx.vars; handlers read them via the same map. Use defaultValue: on the var schema for vars that don't need middleware:
let cli = Cli("my-cli")
.description("My CLI")
.vars([
FieldMeta(name: "user", fieldType: .string),
FieldMeta(name: "request_id", fieldType: .string),
FieldMeta(name: "debug", fieldType: .boolean, defaultValue: false),
])
.useMiddleware { @Sendable ctx, next in
ctx.vars["user"] = .string(await authenticate())
ctx.vars["request_id"] = .string(UUID().uuidString)
await next()
}
.command("whoami", CommandDef(name: "whoami", handler: WhoAmIHandler()))
$ my-cli whoami
# → user: u_123
# → request_id: 550e8400-...
# → debug: false
Call serve() to parse argv from CommandLine.arguments and run:
try await cli.serve()
For testing, pass custom argv and DI overrides:
var output = ""
try await cli.serve(
argv: ["install", "express", "--json"],
stdout: { output += $0 },
exit: { _ in },
env: [:]
)
serve() parameters| Parameter | Type | Description |
|---|---|---|
argv | [String]? | Override CommandLine.arguments (default: read from process) |
stdout | (@Sendable (String) -> Void)? | Override stdout writer |
exit | (@Sendable (Int32) -> Void)? | Override exit handler |
env | [String: String]? | Override environment variables |
Return .stream(_:) to emit chunks incrementally over an AsyncStream<JSONValue>:
struct LogsHandler: CommandHandler {
func run(_ ctx: CommandContext) async -> CommandResult {
let stream = AsyncStream<JSONValue> { continuation in
Task {
continuation.yield(.string("connecting..."))
continuation.yield(.string("streaming logs"))
continuation.yield(.string("done"))
continuation.finish()
}
}
return .stream(stream)
}
}
Each yielded value is written as a line in human/TOON mode. With --format jsonl, each chunk becomes {"type":"chunk","data":"..."}. You can also yield objects:
continuation.yield(["progress": 50])
continuation.yield(["progress": 100])
The macro layer (@IncurArgs, @IncurOptions, @IncurEnv) is incur-swift's equivalent of TS's incur gen codegen. The macros expand at compile time to produce typed [FieldMeta] arrays. There is no separate codegen step.
If you need a wire-level manifest for an external tool, run:
my-cli --llms --format json
…and feed the JSON Schema output to your generator.
incur-swift tracks the upstream TypeScript incur package as its spec. Parity with incur@0.3.x is in progress; many features (token pagination, --filter-output, vars/DI, sync skills, OpenAPI gateway, fetch handler, type generation) have landed in either both or partial form. See the project README.md for the up-to-date status table.
import Foundation
import Incur
struct InstallHandler: CommandHandler {
func run(_ ctx: CommandContext) async -> CommandResult {
let pkg = ctx.args["package"]?.stringValue
if pkg == nil { return .ok(data: ["added": 120, "packages": 450]) }
return .ok(data: ["added": 1, "packages": 451])
}
}
struct OutdatedHandler: CommandHandler {
func run(_ ctx: CommandContext) async -> CommandResult {
.ok(data: [
"packages": [
["name": "express", "current": "4.18.0", "wanted": "4.21.2", "latest": "4.21.2"],
],
])
}
}
@main
struct NpmCLI {
static func main() async {
let cli = Cli("npm")
.version("10.9.2")
.description("The package manager for JavaScript.")
.command("install", CommandDef(
name: "install",
description: "Install a package",
argsFields: [
FieldMeta(name: "package", description: "Package name to install",
fieldType: .string, required: false),
],
optionsFields: [
FieldMeta(name: "save_dev", description: "Save as dev dependency",
fieldType: .boolean, alias: "D"),
FieldMeta(name: "global", description: "Install globally",
fieldType: .boolean, alias: "g"),
],
aliases: ["save_dev": "D", "global": "g"],
examples: [
Example(command: "express", description: "Install a package"),
Example(command: "vitest --save-dev", description: "Install as dev dependency"),
],
handler: InstallHandler()
))
.command("outdated", CommandDef(
name: "outdated",
description: "Check for outdated packages",
optionsFields: [
FieldMeta(name: "global", description: "Check global packages",
fieldType: .boolean, alias: "g"),
],
handler: OutdatedHandler()
))
do {
try await cli.serve()
} catch {
fputs("Error: \(error)\n", stderr)
Foundation.exit(1)
}
}
}