Agent CLI Give the app a command line, so an agent can do everything a person can do in the window: . The app binary is the CLI. Each command goes to the running app over a private socket, runs there like a button press, and the window updates live. CLI, MCP, or App Intents | Way in | Who uses it | Ship it | | --- | --- | --- | | CLI | Claude Code, Codex, scripts, people in Terminal | Always. Agents learn it from , and it costs no context until used | | MCP ( ) | Claude Desktop and other clients with no shell | Optional. The same commands, one tool each. One line to register | | App Intents |…

\"$src/ContentView.swift\"; then\n perl -0pi -e 's/^(\\s*)\\.task \\{\\n/$1.task {\\n$1 AgentRouter.model = model\\n$1 AgentSocket.listen { AgentRouter.handle(\\$0) }\\n/m' \"$src/ContentView.swift\"\n grep -q \"AgentSocket.listen\" \"$src/ContentView.swift\" && started=1\nfi\n\n# build.sh puts the CLI on PATH.\nif ! grep -q \"local/bin/$cli\" \"$app_dir/build.sh\"; then\n perl -0pi -e \"s|(echo \\\"installed \\\\\\$app[^\\n]*\\n)|\\$1# The CLI: the app binary under a short name on PATH (bama agent-cli skill).\\nmkdir -p \\\"\\\\\\$HOME/.local/bin\\\"\\nln -sf \\\"\\\\\\$app/Contents/MacOS/\\\\\\$name\\\" \\\"\\\\\\$HOME/.local/bin/$cli\\\"\\n|\" \"$app_dir/build.sh\"\n grep -q \"local/bin/$cli\" \"$app_dir/build.sh\" || echo \"note: add the ~/.local/bin/$cli link to build.sh by hand\"\nfi\n\necho \"added the agent CLI to $name as \\`$cli\\`. Next (agent-cli skill):\"\necho \" 3. replace the example commands in $src/Agent/AgentCommands.swift\"\n[[ -n \"${started:-}\" ]] || echo \" 4. start the socket in the main view's .task: AgentRouter.model = model; AgentSocket.listen { AgentRouter.handle(\\$0) }\"\necho \" 5. ./build.sh, then $cli help\"\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":2877,"content_sha256":"985db14044cfc8a45c46e6c13b92a3ba134175aa397a841fabc1ef61d98bdc38"},{"filename":"scripts/check-agent-cli.sh","content":"#!/bin/zsh\n# Checks an app's agent CLI against the agent-cli rules, on a test database.\n# Usage: check-agent-cli.sh \u003ccli> the CLI name or a path to the app binary\n# check-agent-cli.sh tasks\n# check-agent-cli.sh ~/Applications/Tasks.app/Contents/MacOS/Tasks\n# Starts a test copy of the app (APP_STORE in /tmp), never the real one, and\n# quits it at the end. Prints PASS or FAIL lines, and exits 1 on any FAIL.\nset -uo pipefail\ncli=${1:?usage: check-agent-cli.sh \u003ccli>}\nexport APP_STORE=\"/tmp/agent-cli-check-$.store\"\nexport APP_AGENT_SOCKET=\"$APP_STORE.sock\"\nfails=0\npass() { echo \"PASS $1\"; }\nfail() { echo \"FAIL $1\"; fails=$((fails + 1)); }\n\nout=$(\"$cli\" help 2>&1); [[ $? == 0 && -n \"$out\" ]] && pass \"help\" || fail \"help exits 0 and prints the commands\"\n\nschema=$(\"$cli\" help --json 2>/dev/null)\npython3 -I -c '\nimport json, sys\ns = json.loads(sys.argv[1])\ncmds = s[\"commands\"]\nassert cmds, \"no commands\"\nfor c in cmds:\n assert c[\"description\"] and \"Example:\" in c[\"description\"], c[\"name\"] + \": summary and example\"\n assert c[\"inputSchema\"][\"type\"] == \"object\", c[\"name\"]\n' \"$schema\" 2>/dev/null && pass \"help --json: every command has a summary, an example, and a schema\" || fail \"help --json lists every command with a summary, example, and schema\"\n\n\"$cli\" no-such-command >/dev/null 2>&1; [[ $? == 2 ]] && pass \"unknown command exits 2\" || fail \"unknown command exits 2\"\nerr=$(\"$cli\" no-such-command --json 2>/dev/null)\npython3 -I -c 'import json,sys; e=json.loads(sys.argv[1])[\"error\"]; assert e[\"code\"] and e[\"message\"]' \"$err\" 2>/dev/null \\\n && pass \"errors with --json are {\\\"error\\\":{code,message}} on stdout\" || fail \"errors with --json are {\\\"error\\\":{code,message}} on stdout\"\n\n# Every read-only command without required params runs and prints JSON.\nfor name in ${(f)\"$(python3 -I -c '\nimport json, sys\nfor c in json.loads(sys.argv[1])[\"commands\"]:\n if c[\"inputSchema\"].get(\"required\") == [] and c.get(\"readOnly\"):\n print(c[\"name\"])\n' \"$schema\" 2>/dev/null)\"}; do\n res=$(\"$cli\" ${=name} --json 2>/dev/null); code=$?\n python3 -I -c 'import json,sys; json.loads(sys.argv[1])' \"$res\" 2>/dev/null && [[ $code == 0 ]] \\\n && pass \"$name --json\" || fail \"$name --json exits 0 with one JSON object (got exit $code)\"\ndone\n\nmcp=$(printf '%s\\n' \\\n '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-06-18\",\"capabilities\":{},\"clientInfo\":{\"name\":\"check\",\"version\":\"1\"}}}' \\\n '{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}' \\\n '{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\"}' | \"$cli\" mcp 2>/dev/null)\npython3 -I -c '\nimport json, re, sys\nlines = [json.loads(l) for l in sys.argv[1].splitlines() if l.strip()]\nassert lines[0][\"result\"][\"capabilities\"][\"tools\"] is not None\ntools = lines[1][\"result\"][\"tools\"]\nassert len(tools) == len(json.loads(sys.argv[2])[\"commands\"])\nfor t in tools:\n assert re.fullmatch(r\"[A-Za-z0-9_-]{1,64}\", t[\"name\"]), t[\"name\"]\n assert \"readOnlyHint\" in t[\"annotations\"]\n' \"$mcp\" \"$schema\" 2>/dev/null && pass \"mcp: initialize and tools/list, one tool per command\" || fail \"mcp answers initialize and tools/list with one tool per command\"\n\n# Quit the test copy only: the process that owns the test socket.\npid=$(lsof -t \"$APP_AGENT_SOCKET\" 2>/dev/null | head -1)\n[[ -n \"$pid\" ]] && kill \"$pid\" 2>/dev/null\nrm -f \"$APP_STORE\"* 2>/dev/null\n\n[[ $fails == 0 ]] && { echo \"PASS agent CLI\"; exit 0; }\necho \"FAIL agent CLI: $fails checks\"; exit 1\n","content_type":"application/x-sh; charset=utf-8","language":"bash","size":3450,"content_sha256":"ec26eda319e1c31ab0237170fd1ea60fe0d7811bd5777598a0e21d9dbddc8bb3"},{"filename":"teach-agents.md","content":"# Teach agents to drive the app\n\nAgents learn a CLI from three places: a short skill that says when and how\nto use it, `\u003ccli> help`, and the errors. Write the skill; the CLI does the\nrest.\n\n## Contents\n\n- The app's agent skill\n- Where to put it\n- Permissions\n- MCP registration\n\n## The app's agent skill\n\nSave as `.claude/skills/\u003ccli>/SKILL.md` in the app's repo. Replace the\nangle brackets. Keep it under 60 lines: list the five commands used most,\nand point at `help` for the rest.\n\n```markdown\n---\nname: \u003ccli>\ndescription: Drive the \u003cApp> app on this Mac from the shell with the `\u003ccli>` command: \u003cthe main jobs, like \"list, add, complete, and search tasks\">. Use when the user asks about their \u003cthings> or wants to change them, or names \u003cApp>.\n---\n\n# \u003cApp>\n\n`\u003ccli>` runs commands in the \u003cApp> app. Changes show in its window at once.\nIf the app is closed, the command opens it in the background.\n\n## Rules\n\n- Always add `--json` and read the JSON. Exit 0 is ok; otherwise read\n `error.code` and `error.message`, and do what the message says.\n- Find ids with `\u003ccli> \u003cnoun> list` or `--search`. An id is 8 characters.\n- Destructive commands (\u003cdelete, send>) only report what they would do.\n Show the user that report and ask before you run it again with `--yes`.\n- To try something without real data, add `APP_STORE=/tmp/\u003ccli>-test.store`\n in front of the command.\n\n## Main commands\n\n| Command | Does |\n| --- | --- |\n| `\u003ccli> status --json` | Is the app running, what is open, counts |\n| `\u003ccli> \u003cnoun> list [--search T] [--limit N] --json` | Find things |\n| `\u003ccli> \u003cnoun> add --title T --json` | Add one |\n| `\u003ccli> \u003cnoun> update \u003cid> [--field value] --json` | Change one |\n| `\u003ccli> open \u003cpage>` | Show a page in the window |\n\nEverything else: `\u003ccli> help`, and `\u003ccli> help \u003ccommand>` for its options.\n```\n\n## Where to put it\n\n| Place | Who sees it |\n| --- | --- |\n| `.claude/skills/\u003ccli>/SKILL.md` in the app's repo | Agents working on the app's code |\n| `~/.claude/skills/\u003ccli>/SKILL.md` | Claude Code in any folder: \"add milk to my tasks\" works everywhere |\n| `~/.codex/skills/\u003ccli>/SKILL.md` (or `AGENTS.md`) | Codex |\n\nCopy the same file to each place the user wants. Update all of them when a\ncommand changes.\n\n## Permissions\n\nClaude Code asks before each Bash command. Allow the read commands for the\nuser, in `.claude/settings.json` or `~/.claude/settings.json`:\n\n```json\n{ \"permissions\": { \"allow\": [\"Bash(\u003ccli> status:*)\", \"Bash(\u003ccli> help:*)\", \"Bash(\u003ccli> \u003cnoun> list:*)\", \"Bash(\u003ccli> \u003cnoun> show:*)\"] } }\n```\n\nLeave write and destructive commands out, so the user approves each one.\nAsk the user before changing their settings.\n\n## MCP registration\n\nOnly when the user wants MCP (Claude Desktop, or one prompt per tool).\nAlways the full path to the binary inside the app: MCP clients do not read\nthe shell's `PATH`.\n\n```bash\nclaude mcp add --scope user \u003ccli> -- ~/Applications/\u003cApp>.app/Contents/MacOS/\u003cApp> mcp\nclaude mcp list # shows \u003ccli> as connected\ncodex mcp add \u003ccli> -- ~/Applications/\u003cApp>.app/Contents/MacOS/\u003cApp> mcp\n```\n\nClaude Desktop, in `~/Library/Application Support/Claude/claude_desktop_config.json`:\n\n```json\n{ \"mcpServers\": { \"\u003ccli>\": { \"command\": \"/Users/\u003cyou>/Applications/\u003cApp>.app/Contents/MacOS/\u003cApp>\", \"args\": [\"mcp\"] } } }\n```\n\nRestart Claude Desktop after editing. Its logs are in\n`~/Library/Logs/Claude/mcp-server-\u003ccli>.log`.\n\nTools are named like the commands with `_` for spaces (`items_add`), carry\n`readOnlyHint` and `destructiveHint`, and take the same options as the CLI.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":3533,"content_sha256":"d356465dd2cae538cab223c99dc335e4e6272d9baf47578c2c026893c73df448"},{"filename":"template/Agent/AgentCLI.swift","content":"import Foundation\n\n/// The app binary is also its CLI. With a command after it, it runs that\n/// command against the running app and exits; without one, it opens the app.\n/// __CLI_NAME__ help every command, with examples\n/// __CLI_NAME__ items add --title \"Milk\" run one command\n/// __CLI_NAME__ items list --json one JSON object on stdout\n/// __CLI_NAME__ mcp an MCP server on stdin and stdout\n/// Exit codes: 0 ok, 1 error, 2 usage, 3 not found, 4 app not running.\nenum AgentCLI {\n /// nil when the app should open normally.\n static func runIfRequested() -> Int32? {\n var words = Array(CommandLine.arguments.dropFirst())\n // macOS and Xcode pass flags like -NSDocumentRevisionsDebugMode. Not ours.\n guard let first = words.first, !first.hasPrefix(\"-\") || [\"--help\", \"-h\", \"--version\"].contains(first) else { return nil }\n let json = words.contains(\"--json\")\n words.removeAll { $0 == \"--json\" }\n do {\n switch first {\n case \"help\", \"--help\", \"-h\":\n return help(Array(words.dropFirst()), json: json)\n case \"--version\", \"version\":\n // Through the ~/.local/bin link, Bundle.main cannot find Info.plist. Follow the link.\n let executable = AgentBinary.url\n let plist = executable.deletingLastPathComponent().deletingLastPathComponent().appending(path: \"Info.plist\")\n let version = NSDictionary(contentsOf: plist)?[\"CFBundleShortVersionString\"] as? String ?? \"dev\"\n print(json ? JSON.string([\"version\": version]) : \"__CLI_NAME__ \\(version)\")\n return 0\n case \"mcp\":\n AgentMCP.serve()\n return 0\n default:\n return try run(words, json: json)\n }\n } catch let error as AgentError {\n fail(error, json: json)\n return error.exit\n } catch {\n fail(.invalid(error.localizedDescription), json: json)\n return 1\n }\n }\n\n private static func run(_ words: [String], json: Bool) throws -> Int32 {\n // The longest command name that matches the first words: \"items add\".\n guard let command = AgentCommands.all\n .filter({ words.starts(with: $0.name.split(separator: \" \").map(String.init)) })\n .max(by: { $0.name.count \u003c $1.name.count }) else {\n throw AgentError.usage(\"unknown command \\\"\\(words.prefix(2).joined(separator: \" \"))\\\". Run `__CLI_NAME__ help`\")\n }\n var rest = Array(words.dropFirst(command.name.split(separator: \" \").count))\n if rest.contains(\"--help\") || rest.contains(\"-h\") {\n print(usage(command))\n return 0\n }\n let launch = !rest.contains(\"--no-launch\")\n rest.removeAll { $0 == \"--no-launch\" }\n let values = try parse(rest, for: command)\n let request = JSON.data([\"command\": command.name, \"args\": values])\n let reply = try AgentSocket.send(request, launch: launch)\n guard let object = try? JSONSerialization.jsonObject(with: reply) as? JSONObject else {\n throw AgentError.invalid(\"the app sent no answer\")\n }\n if object[\"ok\"] as? Bool == true {\n let result = object[\"result\"] ?? [:]\n print(json ? JSON.string(result) : human(result))\n return 0\n }\n let error = object[\"error\"] as? JSONObject ?? [:]\n let code = error[\"code\"] as? String ?? \"error\"\n throw AgentError(code: code, message: error[\"message\"] as? String ?? \"unknown error\",\n exit: [\"usage\": 2, \"not_found\": 3, \"app_unavailable\": 4][code] ?? 1)\n }\n\n /// --name value, --flag, and positional values, checked against the params.\n static func parse(_ words: [String], for command: AgentCommand) throws -> [String: String] {\n var values: [String: String] = [:]\n var positional = command.params.filter(\\.positional)\n var index = 0\n while index \u003c words.count {\n let word = words[index]\n if word.hasPrefix(\"--\") {\n let name = String(word.dropFirst(2))\n if name == \"yes\", command.destructive { values[\"yes\"] = \"true\"; index += 1; continue }\n guard let param = command.params.first(where: { $0.name == name }) else {\n throw AgentError.usage(\"\\(command.name) has no option --\\(name). Run `__CLI_NAME__ \\(command.name) --help`\")\n }\n let next = index + 1 \u003c words.count ? words[index + 1] : nil\n if param.kind == .boolean, next == nil || next!.hasPrefix(\"--\") || ![\"true\", \"false\", \"yes\", \"no\"].contains(next!.lowercased()) {\n values[name] = \"true\"\n index += 1\n } else {\n guard let next else { throw AgentError.usage(\"--\\(name) needs a value\") }\n values[name] = next\n index += 2\n }\n } else {\n guard !positional.isEmpty else { throw AgentError.usage(\"unexpected \\\"\\(word)\\\". Run `__CLI_NAME__ \\(command.name) --help`\") }\n values[positional.removeFirst().name] = word\n index += 1\n }\n }\n for param in command.params where param.required && values[param.name] == nil {\n throw AgentError.usage(\"missing \\(param.positional ? \"\u003c\\(param.name)>\" : \"--\\(param.name)\"). Example: \\(command.example)\")\n }\n return values\n }\n\n // MARK: Help\n\n private static func help(_ words: [String], json: Bool) -> Int32 {\n if json {\n print(JSON.string([\"commands\": AgentCommands.all.map(schema)]))\n return 0\n }\n let name = words.joined(separator: \" \")\n if let command = AgentCommands.all.first(where: { $0.name == name }) {\n print(usage(command))\n return 0\n }\n var lines = [\"__CLI_NAME__: drive the app from the shell. Every command takes --json.\", \"\"]\n let width = (AgentCommands.all.map(\\.name.count).max() ?? 0) + 2\n for command in AgentCommands.all {\n lines.append(\" \" + command.name.padding(toLength: width, withPad: \" \", startingAt: 0) + command.summary)\n }\n lines += [\"\", \"More: __CLI_NAME__ help \u003ccommand>, __CLI_NAME__ help --json (every command as a schema).\",\n \"Exit codes: 0 ok, 1 error, 2 usage, 3 not found, 4 app not running.\"]\n print(lines.joined(separator: \"\\n\"))\n return 0\n }\n\n static func usage(_ command: AgentCommand) -> String {\n var lines = [\"\\(command.summary)\", \"\", \" __CLI_NAME__ \\(command.name)\" + command.params.map { param in\n let shown = param.positional ? \"\u003c\\(param.name)>\" : param.kind == .boolean ? \"--\\(param.name)\" : \"--\\(param.name) \u003c\\(param.kind.rawValue)>\"\n return \" \" + (param.required ? shown : \"[\\(shown)]\")\n }.joined() + (command.destructive ? \" [--yes]\" : \"\")]\n if !command.params.isEmpty { lines.append(\"\") }\n for param in command.params {\n lines.append(\" \" + (param.positional ? param.name : \"--\" + param.name).padding(toLength: 14, withPad: \" \", startingAt: 0) + param.help)\n }\n if command.destructive { lines.append(\" --yes do it. Without --yes it only shows what it would change\") }\n lines += [\"\", \"Example: \\(command.example)\"]\n return lines.joined(separator: \"\\n\")\n }\n\n static func schema(_ command: AgentCommand) -> JSONObject {\n var properties: JSONObject = [:]\n for param in command.params {\n properties[param.name] = [\"type\": param.kind.rawValue, \"description\": param.help]\n }\n if command.destructive {\n properties[\"yes\"] = [\"type\": \"boolean\", \"description\": \"Do it. Without yes it only shows what it would change.\"]\n }\n return [\"name\": command.name, \"description\": command.summary + \" Example: \" + command.example,\n \"readOnly\": command.readOnly, \"destructive\": command.destructive,\n \"inputSchema\": [\"type\": \"object\", \"properties\": properties,\n \"required\": command.params.filter(\\.required).map(\\.name)]]\n }\n\n // MARK: Output\n\n /// Short plain lines for people. Agents use --json.\n private static func human(_ result: Any) -> String {\n if let items = (result as? JSONObject)?.values.first(where: { $0 is [JSONObject] }) as? [JSONObject] {\n return items.isEmpty ? \"(none)\" : items.map(line).joined(separator: \"\\n\")\n }\n if let object = result as? JSONObject { return line(object) }\n return \"\\(result)\"\n }\n\n private static func line(_ object: JSONObject) -> String {\n let first = [\"id\", \"title\", \"name\"]\n return object.keys.sorted { a, b in\n (first.firstIndex(of: a) ?? 9, a) \u003c (first.firstIndex(of: b) ?? 9, b)\n }\n .map { key in\n let value = object[key]\n // JSON booleans arrive as NSNumber; show them as yes and no.\n if let number = value as? NSNumber, CFGetTypeID(number) == CFBooleanGetTypeID() {\n return \"\\(key): \\(number.boolValue ? \"yes\" : \"no\")\"\n }\n return \"\\(key): \\(value.map { \"\\($0)\" } ?? \"\")\"\n }\n .joined(separator: \" \")\n }\n\n private static func fail(_ error: AgentError, json: Bool) {\n if json {\n print(JSON.string(error.json))\n } else {\n FileHandle.standardError.write(Data(\"__CLI_NAME__: \\(error.message)\\n\".utf8))\n }\n }\n}\n","content_type":"text/plain; charset=utf-8","language":"swift","size":9689,"content_sha256":"750e31122218e19828be1693c2eede88bc10f7c8dc27ed5ef92e786c74312f04"},{"filename":"template/Agent/AgentCommands.swift","content":"import Foundation\nimport SwiftData\n\n/// Everything an agent can do in __CLI_NAME__. Add a command here and the CLI,\n/// `help`, and the MCP tools all get it. Each `run` is on the main actor\n/// with the app's model, like a button in the window.\nenum AgentCommands {\n static let all: [AgentCommand] = [\n AgentCommand(name: \"status\", summary: \"Show whether the app runs, the open page, and item counts.\",\n example: \"__CLI_NAME__ status --json\", readOnly: true) { _, model in\n let items = try model.container.mainContext.fetch(FetchDescriptor\u003cItem>())\n return [\"running\": true, \"page\": model.sidebar?.rawValue ?? \"none\",\n \"items\": items.count, \"starred\": items.filter(\\.isStarred).count]\n },\n AgentCommand(name: \"items list\", summary: \"List items, newest first.\",\n params: [.init(name: \"starred\", kind: .boolean, help: \"only starred items\"),\n .init(name: \"search\", help: \"only items whose title or notes contain this text\"),\n .init(name: \"limit\", kind: .integer, help: \"at most this many (default 50)\"),\n .init(name: \"offset\", kind: .integer, help: \"skip this many, for the next page\")],\n example: \"__CLI_NAME__ items list --starred --limit 10 --json\", readOnly: true) { args, model in\n var items = try model.container.mainContext.fetch(FetchDescriptor\u003cItem>(sortBy: [SortDescriptor(\\.createdAt, order: .reverse)]))\n if try args.bool(\"starred\") == true { items = items.filter(\\.isStarred) }\n if let text = args.string(\"search\") {\n items = items.filter { $0.title.localizedStandardContains(text) || $0.notes.localizedStandardContains(text) }\n }\n let limit = try args.int(\"limit\", default: 50)\n let offset = try args.int(\"offset\", default: 0)\n let page = items.dropFirst(offset).prefix(limit)\n var result: JSONObject = [\"items\": page.map(\\.agentJSON), \"total\": items.count]\n if offset + page.count \u003c items.count { result[\"nextOffset\"] = offset + page.count }\n return result\n },\n AgentCommand(name: \"items show\", summary: \"Show one item.\",\n params: [.init(name: \"id\", required: true, positional: true, help: \"the item's id (the first 8 characters are enough)\")],\n example: \"__CLI_NAME__ items show 3f2a9c1e --json\", readOnly: true) { args, model in\n try find(try args.required(\"id\"), in: model).agentJSON\n },\n AgentCommand(name: \"items add\", summary: \"Add an item and select it in the window.\",\n params: [.init(name: \"title\", required: true, help: \"the title\"),\n .init(name: \"notes\", help: \"notes under the title\"),\n .init(name: \"starred\", kind: .boolean, help: \"star it\")],\n example: \"__CLI_NAME__ items add --title \\\"Buy milk\\\" --starred --json\") { args, model in\n let item = Item(title: try args.required(\"title\"))\n item.notes = args.string(\"notes\") ?? \"\"\n item.isStarred = try args.bool(\"starred\") ?? false\n model.container.mainContext.insert(item)\n try model.container.mainContext.save()\n model.selection = item.persistentModelID\n return item.agentJSON\n },\n AgentCommand(name: \"items update\", summary: \"Change an item. Only the options you give change.\",\n params: [.init(name: \"id\", required: true, positional: true, help: \"the item's id\"),\n .init(name: \"title\", help: \"new title\"),\n .init(name: \"notes\", help: \"new notes\"),\n .init(name: \"starred\", kind: .boolean, help: \"true or false\")],\n example: \"__CLI_NAME__ items update 3f2a9c1e --starred false --json\") { args, model in\n let item = try find(try args.required(\"id\"), in: model)\n if let title = args.string(\"title\") {\n guard !title.isEmpty else { throw AgentError.invalid(\"--title cannot be empty\") }\n item.title = title\n }\n if let notes = args.string(\"notes\") { item.notes = notes }\n if let starred = try args.bool(\"starred\") { item.isStarred = starred }\n try model.container.mainContext.save()\n return item.agentJSON\n },\n AgentCommand(name: \"items delete\", summary: \"Delete an item.\",\n params: [.init(name: \"id\", required: true, positional: true, help: \"the item's id\")],\n example: \"__CLI_NAME__ items delete 3f2a9c1e --yes --json\", destructive: true) { args, model in\n let item = try find(try args.required(\"id\"), in: model)\n let shown = item.agentJSON\n guard args.confirmed else { return [\"deleted\": false, \"wouldDelete\": shown, \"hint\": \"add --yes to delete it\"] }\n model.container.mainContext.delete(item)\n try model.container.mainContext.save()\n return [\"deleted\": true, \"item\": shown]\n },\n AgentCommand(name: \"open\", summary: \"Show a sidebar page in the window.\",\n params: [.init(name: \"page\", required: true, positional: true,\n help: SidebarItem.allCases.map(\\.rawValue).joined(separator: \", \"))],\n example: \"__CLI_NAME__ open starred\") { args, model in\n let name = try args.required(\"page\")\n guard let page = SidebarItem(rawValue: name) else {\n throw AgentError.usage(\"no page \\\"\\(name)\\\". Pages: \\(SidebarItem.allCases.map(\\.rawValue).joined(separator: \", \"))\")\n }\n model.sidebar = page\n return [\"page\": page.rawValue]\n },\n ]\n\n /// An item by its id, or the start of it.\n static func find(_ id: String, in model: AppModel) throws -> Item {\n let items = try model.container.mainContext.fetch(FetchDescriptor\u003cItem>())\n let matches = items.filter { $0.agentID.hasPrefix(id.lowercased()) }\n guard let item = matches.first else { throw AgentError.notFound(\"no item with id \\(id). Run `__CLI_NAME__ items list`\") }\n guard matches.count == 1 else { throw AgentError.usage(\"\\(matches.count) items start with \\(id). Give more characters\") }\n return item\n }\n}\n\nextension Item {\n /// A stable id for agents: lowercase, without dashes.\n var agentID: String { uuid.uuidString.lowercased().replacingOccurrences(of: \"-\", with: \"\") }\n\n var agentJSON: JSONObject {\n [\"id\": String(agentID.prefix(8)), \"title\": title, \"notes\": notes, \"starred\": isStarred,\n \"createdAt\": JSON.date(createdAt)]\n }\n}\n","content_type":"text/plain; charset=utf-8","language":"swift","size":6842,"content_sha256":"b7feee2e15fda1da45bda6157dad3f91a60e7b853fcea9b9a7d6b4cde7cddb37"},{"filename":"template/Agent/AgentCore.swift","content":"import Foundation\n\n// The agent interface: one list of commands (AgentCommands.swift) that the\n// CLI, its help, and the MCP server all read. Commands run inside the app,\n// on the main actor, so the window updates live and Undo keeps working.\n\ntypealias JSONObject = [String: Any]\n\n/// One thing an agent can do. Keep names as \"\u003cnoun> \u003cverb>\": \"items add\".\nstruct AgentCommand {\n struct Param {\n enum Kind: String { case string, integer, boolean }\n var name: String\n var kind: Kind = .string\n var required = false\n /// Given without its --name, right after the command: `items show 3f2a`.\n var positional = false\n var help: String\n }\n\n var name: String\n var summary: String\n var params: [Param] = []\n var example: String\n /// Only reads. Agents may run it without asking.\n var readOnly = false\n /// Removes data or cannot be undone. Without --yes it only reports what it would do.\n var destructive = false\n var run: (Args, AppModel) throws -> Any\n}\n\n/// The values a command was called with, checked against its params.\nstruct Args {\n var values: [String: String]\n\n func string(_ name: String) -> String? { values[name] }\n\n func required(_ name: String) throws -> String {\n guard let value = values[name], !value.isEmpty else { throw AgentError.usage(\"missing --\\(name)\") }\n return value\n }\n\n func int(_ name: String, default fallback: Int) throws -> Int {\n guard let raw = values[name] else { return fallback }\n guard let value = Int(raw) else { throw AgentError.usage(\"--\\(name) must be a whole number, not \\\"\\(raw)\\\"\") }\n return value\n }\n\n func bool(_ name: String) throws -> Bool? {\n guard let raw = values[name] else { return nil }\n switch raw.lowercased() {\n case \"\", \"true\", \"yes\", \"1\": return true\n case \"false\", \"no\", \"0\": return false\n default: throw AgentError.usage(\"--\\(name) must be true or false, not \\\"\\(raw)\\\"\")\n }\n }\n\n var confirmed: Bool { (try? bool(\"yes\")) == true }\n}\n\n/// An error with a stable code an agent can branch on, a message that says\n/// how to fix it, and the exit code the CLI returns.\nnonisolated struct AgentError: Error {\n var code: String\n var message: String\n var exit: Int32\n\n static func usage(_ message: String) -> AgentError { AgentError(code: \"usage\", message: message, exit: 2) }\n static func notFound(_ message: String) -> AgentError { AgentError(code: \"not_found\", message: message, exit: 3) }\n static func invalid(_ message: String) -> AgentError { AgentError(code: \"invalid\", message: message, exit: 1) }\n static func unavailable(_ message: String) -> AgentError { AgentError(code: \"app_unavailable\", message: message, exit: 4) }\n\n var json: JSONObject { [\"ok\": false, \"error\": [\"code\": code, \"message\": message]] }\n}\n\n/// Runs requests from the socket inside the app.\nenum AgentRouter {\n static var model: AppModel?\n\n /// A request is {\"command\": \"items add\", \"args\": {\"title\": \"Milk\"}}.\n static func handle(_ request: Data) -> Data {\n let reply: JSONObject\n do {\n guard let object = try JSONSerialization.jsonObject(with: request) as? JSONObject,\n let name = object[\"command\"] as? String else { throw AgentError.usage(\"bad request\") }\n guard let command = AgentCommands.all.first(where: { $0.name == name }) else {\n throw AgentError.usage(\"unknown command \\\"\\(name)\\\". Run `__CLI_NAME__ help`\")\n }\n guard let model else { throw AgentError.unavailable(\"the app is still starting. Try again\") }\n let args = Args(values: object[\"args\"] as? [String: String] ?? [:])\n reply = [\"ok\": true, \"result\": try command.run(args, model)]\n } catch let error as AgentError {\n reply = error.json\n } catch {\n reply = AgentError.invalid(error.localizedDescription).json\n }\n return JSON.data(reply)\n }\n}\n\nnonisolated enum JSON {\n static func data(_ object: Any) -> Data {\n (try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys, .withoutEscapingSlashes])) ?? Data(\"{}\".utf8)\n }\n\n static func string(_ object: Any) -> String {\n String(decoding: data(object), as: UTF8.self)\n }\n\n static func date(_ date: Date) -> String {\n date.formatted(.iso8601)\n }\n}\n\nnonisolated enum AgentBinary {\n /// The app's binary, with the ~/.local/bin link followed. Run from PATH,\n /// argv[0] is only the CLI's name, so ask the system for the full path.\n static var url: URL {\n var size: UInt32 = 0\n _NSGetExecutablePath(nil, &size)\n var buffer = [CChar](repeating: 0, count: Int(size))\n let path = _NSGetExecutablePath(&buffer, &size) == 0\n ? String(decoding: buffer.prefix { $0 != 0 }.map(UInt8.init(bitPattern:)), as: UTF8.self)\n : CommandLine.arguments[0]\n return URL(fileURLWithPath: path).resolvingSymlinksInPath()\n }\n}\n","content_type":"text/plain; charset=utf-8","language":"swift","size":5050,"content_sha256":"5dc15997d5e6ce31619e4f9b5070a943a204ace428661291f43c73fca5073b78"},{"filename":"template/Agent/AgentMCP.swift","content":"import Foundation\n\n/// A small MCP server over stdin and stdout (JSON-RPC, one message per line).\n/// Every AgentCommand becomes a tool (\"items add\" -> \"items_add\"), and each\n/// call goes to the running app, like the CLI. Register it once:\n/// claude mcp add --scope user __CLI_NAME__ -- ~/Applications/__APP_NAME__.app/Contents/MacOS/__APP_NAME__ mcp\n/// Optional: Claude Code drives the CLI fine without it. Use it for Claude\n/// Desktop and other clients that have no shell.\nenum AgentMCP {\n static func serve() {\n while let line = readLine(strippingNewline: true) {\n guard let data = line.data(using: .utf8),\n let message = try? JSONSerialization.jsonObject(with: data) as? JSONObject else { continue }\n // Notifications have no id and get no answer.\n guard let id = message[\"id\"] else { continue }\n let method = message[\"method\"] as? String ?? \"\"\n let params = message[\"params\"] as? JSONObject ?? [:]\n var reply: JSONObject = [\"jsonrpc\": \"2.0\", \"id\": id]\n switch method {\n case \"initialize\":\n reply[\"result\"] = [\n \"protocolVersion\": params[\"protocolVersion\"] as? String ?? \"2025-06-18\",\n \"capabilities\": [\"tools\": [:]],\n \"serverInfo\": [\"name\": \"__CLI_NAME__\", \"version\": \"1.0\"],\n \"instructions\": \"Tools that drive the __APP_NAME__ app on this Mac. Destructive tools only report what they would change unless yes is true.\",\n ]\n case \"ping\":\n reply[\"result\"] = [:] as JSONObject\n case \"tools/list\":\n reply[\"result\"] = [\"tools\": AgentCommands.all.map(tool)]\n case \"tools/call\":\n reply[\"result\"] = call(params)\n default:\n reply[\"error\"] = [\"code\": -32601, \"message\": \"unknown method \\(method)\"]\n }\n print(JSON.string(reply))\n fflush(stdout)\n }\n }\n\n private static func tool(_ command: AgentCommand) -> JSONObject {\n var schema = AgentCLI.schema(command)\n schema[\"name\"] = command.name.replacingOccurrences(of: \" \", with: \"_\")\n schema[\"annotations\"] = [\"readOnlyHint\": command.readOnly, \"destructiveHint\": command.destructive]\n schema[\"readOnly\"] = nil\n schema[\"destructive\"] = nil\n return schema\n }\n\n private static func call(_ params: JSONObject) -> JSONObject {\n let name = (params[\"name\"] as? String ?? \"\").replacingOccurrences(of: \"_\", with: \" \")\n let arguments = (params[\"arguments\"] as? JSONObject ?? [:]).mapValues { \"\\($0)\" }\n do {\n guard AgentCommands.all.contains(where: { $0.name == name }) else { throw AgentError.usage(\"unknown tool \\(name)\") }\n let reply = try AgentSocket.send(JSON.data([\"command\": name, \"args\": arguments]), launch: true)\n let object = (try? JSONSerialization.jsonObject(with: reply) as? JSONObject) ?? [:]\n let ok = object[\"ok\"] as? Bool == true\n return [\"content\": [[\"type\": \"text\", \"text\": JSON.string(ok ? object[\"result\"] ?? [:] : object[\"error\"] ?? [:])]],\n \"isError\": !ok]\n } catch let error as AgentError {\n return [\"content\": [[\"type\": \"text\", \"text\": JSON.string(error.json)]], \"isError\": true]\n } catch {\n return [\"content\": [[\"type\": \"text\", \"text\": error.localizedDescription]], \"isError\": true]\n }\n }\n}\n","content_type":"text/plain; charset=utf-8","language":"swift","size":3514,"content_sha256":"b70ca86215f098334ab96e432cf7dd7b5870225d539caba616842dafc284ae28"},{"filename":"template/Agent/AgentSocket.swift","content":"import AppKit\nimport Foundation\n\n/// A Unix domain socket that only this Mac user can open. The app listens;\n/// the CLI connects, sends one JSON line, and reads one JSON line back.\n/// ~/Library/Application Support/__APP_NAME__/agent.sock the real app\n/// \u003cAPP_STORE>.sock a test run\n/// APP_AGENT_SOCKET=\u003cpath> any other place\nnonisolated enum AgentSocket {\n static var path: String {\n let env = ProcessInfo.processInfo.environment\n if let path = env[\"APP_AGENT_SOCKET\"], !path.isEmpty { return path }\n if let store = env[\"APP_STORE\"], !store.isEmpty { return store + \".sock\" }\n let dir = URL.applicationSupportDirectory.appending(path: \"__APP_NAME__\", directoryHint: .isDirectory)\n try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)\n return dir.appending(path: \"agent.sock\").path\n }\n\n private static func address(_ path: String) throws -> sockaddr_un {\n var address = sockaddr_un()\n address.sun_family = sa_family_t(AF_UNIX)\n let bytes = Array(path.utf8)\n guard bytes.count \u003c MemoryLayout.size(ofValue: address.sun_path) else {\n throw AgentError.invalid(\"socket path is too long (over 103 bytes): \\(path). Set APP_AGENT_SOCKET to a shorter path\")\n }\n withUnsafeMutableBytes(of: &address.sun_path) { raw in\n raw.copyBytes(from: bytes)\n raw[bytes.count] = 0\n }\n return address\n }\n\n // MARK: App side\n\n /// Starts listening. `handle` runs on the main actor for each request.\n static func listen(handle: @escaping @MainActor @Sendable (Data) -> Data) {\n let path = self.path\n unlink(path)\n let server = socket(AF_UNIX, SOCK_STREAM, 0)\n guard server >= 0, var address = try? address(path) else { return }\n let bound = withUnsafePointer(to: &address) {\n $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { bind(server, $0, socklen_t(MemoryLayout\u003csockaddr_un>.size)) }\n }\n guard bound == 0, Darwin.listen(server, 16) == 0 else {\n print(\"AGENT could not listen on \\(path): \\(String(cString: strerror(errno)))\")\n close(server)\n return\n }\n chmod(path, 0o600)\n Thread.detachNewThread {\n while true {\n let client = accept(server, nil, nil)\n guard client >= 0 else { continue }\n Thread.detachNewThread { serve(client, handle: handle) }\n }\n }\n }\n\n private static func serve(_ client: Int32, handle: @escaping @MainActor @Sendable (Data) -> Data) {\n var timeout = timeval(tv_sec: 5, tv_usec: 0)\n setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout\u003ctimeval>.size))\n let request = readLine(client)\n let reply = DispatchQueue.main.sync { MainActor.assumeIsolated { handle(request) } }\n write(client, reply + Data(\"\\n\".utf8))\n close(client)\n }\n\n // MARK: CLI side\n\n /// Sends one request and returns the reply. Starts the app when it is\n /// not running, unless `launch` is false.\n static func send(_ request: Data, launch: Bool) throws -> Data {\n if let reply = try? exchange(request) { return reply }\n guard launch else { throw AgentError.unavailable(\"__APP_NAME__ is not running. Open it, or leave out --no-launch\") }\n try startApp()\n for _ in 0..\u003c50 {\n Thread.sleep(forTimeInterval: 0.2)\n if let reply = try? exchange(request) { return reply }\n }\n throw AgentError.unavailable(\"__APP_NAME__ did not answer within 10 seconds on \\(path)\")\n }\n\n private static func exchange(_ request: Data) throws -> Data {\n let client = socket(AF_UNIX, SOCK_STREAM, 0)\n guard client >= 0 else { throw AgentError.unavailable(\"no socket\") }\n defer { close(client) }\n var address = try address(path)\n let connected = withUnsafePointer(to: &address) {\n $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { connect(client, $0, socklen_t(MemoryLayout\u003csockaddr_un>.size)) }\n }\n guard connected == 0 else { throw AgentError.unavailable(\"not running\") }\n write(client, request + Data(\"\\n\".utf8))\n var timeout = timeval(tv_sec: 30, tv_usec: 0)\n setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout\u003ctimeval>.size))\n return readLine(client)\n }\n\n /// Opens the app in the background, with this process's environment, so a\n /// test run (APP_STORE) starts a test copy, never the real one.\n private static func startApp() throws {\n let executable = AgentBinary.url\n let bundle = executable.deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()\n let process = Process()\n if bundle.pathExtension == \"app\" {\n process.executableURL = URL(fileURLWithPath: \"/usr/bin/open\")\n var arguments = [\"-g\", \"-n\", bundle.path]\n for key in [\"APP_STORE\", \"APP_AGENT_SOCKET\"] {\n if let value = ProcessInfo.processInfo.environment[key] { arguments += [\"--env\", \"\\(key)=\\(value)\"] }\n }\n // Only a test run (APP_STORE set) may open a second copy.\n if ProcessInfo.processInfo.environment[\"APP_STORE\"] == nil { arguments.remove(at: 1) }\n process.arguments = arguments\n } else {\n process.executableURL = executable\n process.arguments = []\n }\n process.standardOutput = FileHandle.nullDevice\n process.standardError = FileHandle.nullDevice\n do { try process.run() } catch { throw AgentError.unavailable(\"could not start __APP_NAME__: \\(error.localizedDescription)\") }\n }\n\n // MARK: Bytes\n\n private static func readLine(_ socket: Int32) -> Data {\n var data = Data()\n var buffer = [UInt8](repeating: 0, count: 65_536)\n while true {\n let count = read(socket, &buffer, buffer.count)\n if count \u003c= 0 { break }\n data.append(buffer, count: count)\n if buffer[count - 1] == UInt8(ascii: \"\\n\") { break }\n }\n return data\n }\n\n private static func write(_ socket: Int32, _ data: Data) {\n data.withUnsafeBytes { raw in\n var offset = 0\n while offset \u003c raw.count {\n let sent = Darwin.write(socket, raw.baseAddress! + offset, raw.count - offset)\n if sent \u003c= 0 { return }\n offset += sent\n }\n }\n }\n}\n","content_type":"text/plain; charset=utf-8","language":"swift","size":6647,"content_sha256":"6a8a1cd2b994d4cdc41e3461fb65a2f112a5edc1dd907276348f24a15077d17c"},{"filename":"template/Main.swift","content":"import Foundation\n\n/// Starts the CLI when there is a command after the app's name, else the app.\n@main\nenum Main {\n static func main() {\n if let code = AgentCLI.runIfRequested() { exit(code) }\n __APP_NAME__App.main()\n }\n}\n","content_type":"text/plain; charset=utf-8","language":"swift","size":243,"content_sha256":"8383f503513f61b0f840ae2c6f5a26389beb70b517ca3a1706f2de639f3449d9"}],"content_json":{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"text":"Agent CLI","type":"text"}]},{"type":"paragraph","content":[{"text":"Give the app a command line, so an agent can do everything a person can do in the window: ","type":"text"},{"text":"tasks items add --title \"Call the dentist\"","type":"text","marks":[{"type":"code_inline"}]},{"text":". The app binary is the CLI. Each command goes to the running app over a private socket, runs there like a button press, and the window updates live.","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"text"},"content":[{"text":"Claude Code --bash--> tasks items add ... --socket--> Tasks.app (runs the command, saves, redraws)\nClaude Desktop --MCP--> tasks mcp --socket--> same commands, same app","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"CLI, MCP, or App Intents","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Way in","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Who uses it","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Ship it","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"CLI","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Claude Code, Codex, scripts, people in Terminal","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Always. Agents learn it from ","type":"text"},{"text":"--help","type":"text","marks":[{"type":"code_inline"}]},{"text":", and it costs no context until used","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"MCP (","type":"text"},{"text":"tasks mcp","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Claude Desktop and other clients with no shell","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Optional. The same commands, one tool each. One line to register","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"App Intents","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Siri, Spotlight, Shortcuts","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Separate work. Agents cannot call them","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"The CLI talks to the running app, never to the database file. Then there is one writer, the app's checks and side effects run, and the window shows the change at once. If the app is closed, the CLI opens it in the background. Rules and reasons: ","type":"text"},{"text":"rules.md","type":"text","marks":[{"type":"link","attrs":{"href":"rules.md","title":null}}]},{"text":".","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"1. List the commands","type":"text"}]},{"type":"paragraph","content":[{"text":"Read the user stories (","type":"text"},{"text":"docs/stories/","type":"text","marks":[{"type":"code_inline"}]},{"text":"). Write each thing a person does as a command, ","type":"text"},{"text":"\u003cnoun> \u003cverb>","type":"text","marks":[{"type":"code_inline"}]},{"text":", and mark it:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"read","type":"text","marks":[{"type":"strong"}]},{"text":": ","type":"text"},{"text":"items list","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"items show","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"status","type":"text","marks":[{"type":"code_inline"}]},{"text":". Safe to run any time.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"write","type":"text","marks":[{"type":"strong"}]},{"text":": ","type":"text"},{"text":"items add","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"items update","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"open","type":"text","marks":[{"type":"code_inline"}]},{"text":". Changes data, can be undone.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"destructive","type":"text","marks":[{"type":"strong"}]},{"text":": ","type":"text"},{"text":"items delete","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"mail send","type":"text","marks":[{"type":"code_inline"}]},{"text":". Only reports what it would do until ","type":"text"},{"text":"--yes","type":"text","marks":[{"type":"code_inline"}]},{"text":" is given.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Add ","type":"text"},{"text":"status","type":"text","marks":[{"type":"code_inline"}]},{"text":" (is it running, what is open, counts) and ","type":"text"},{"text":"open \u003cpage>","type":"text","marks":[{"type":"code_inline"}]},{"text":" (show a page in the window), so an agent can see and steer the app.","type":"text"}]},{"type":"paragraph","content":[{"text":"Check: every M1 story has its commands, and each command is marked.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"2. Add the files","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"\"${CLAUDE_SKILL_DIR}/scripts/add-agent-cli.sh\" \u003capp-folder> \u003ccli-name>","type":"text"}]},{"type":"paragraph","content":[{"text":"It copies ","type":"text"},{"text":"Sources/\u003cApp>/Agent/","type":"text","marks":[{"type":"code_inline"}]},{"text":" (core, socket, CLI, MCP, example commands) and ","type":"text"},{"text":"Main.swift","type":"text","marks":[{"type":"code_inline"}]},{"text":", moves ","type":"text"},{"text":"@main","type":"text","marks":[{"type":"code_inline"}]},{"text":" to ","type":"text"},{"text":"Main.swift","type":"text","marks":[{"type":"code_inline"}]},{"text":", starts the socket in the main view's ","type":"text"},{"text":".task","type":"text","marks":[{"type":"code_inline"}]},{"text":", and makes ","type":"text"},{"text":"build.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" link ","type":"text"},{"text":"~/.local/bin/\u003ccli-name>","type":"text","marks":[{"type":"code_inline"}]},{"text":". Apps not made from the template: do the same by hand, following the script.","type":"text"}]},{"type":"paragraph","content":[{"text":"Check: the script printed \"added the agent CLI\", and ","type":"text"},{"text":"swift build","type":"text","marks":[{"type":"code_inline"}]},{"text":" passes.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"3. Write the commands","type":"text"}]},{"type":"paragraph","content":[{"text":"Replace the examples in ","type":"text"},{"text":"Agent/AgentCommands.swift","type":"text","marks":[{"type":"code_inline"}]},{"text":". Each command has a name, a one-line summary, its params, a full example, its mark (","type":"text"},{"text":"readOnly","type":"text","marks":[{"type":"code_inline"}]},{"text":" or ","type":"text"},{"text":"destructive","type":"text","marks":[{"type":"code_inline"}]},{"text":"), and a ","type":"text"},{"text":"run","type":"text","marks":[{"type":"code_inline"}]},{"text":" that uses the same model code as the window's buttons. Never copy logic: call the function the button calls.","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Every model an agent touches gets ","type":"text"},{"text":"var uuid: UUID = UUID()","type":"text","marks":[{"type":"code_inline"}]},{"text":". Commands show the first 8 characters as ","type":"text"},{"text":"id","type":"text","marks":[{"type":"code_inline"}]},{"text":", and accept any unique start of it.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Results are small JSON objects: the fields an agent needs, not the whole model. Lists take ","type":"text"},{"text":"--limit","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"--offset","type":"text","marks":[{"type":"code_inline"}]},{"text":", and return ","type":"text"},{"text":"total","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"nextOffset","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Errors throw ","type":"text"},{"text":"AgentError","type":"text","marks":[{"type":"code_inline"}]},{"text":" with a message that says how to fix it: \"no item with id 3f2a. Run ","type":"text"},{"text":"tasks items list","type":"text","marks":[{"type":"code_inline"}]},{"text":"\".","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"All the rules: ","type":"text"},{"text":"rules.md","type":"text","marks":[{"type":"link","attrs":{"href":"rules.md","title":null}}]},{"text":".","type":"text"}]},{"type":"paragraph","content":[{"text":"Check: ","type":"text"},{"text":"\u003ccli-name> help","type":"text","marks":[{"type":"code_inline"}]},{"text":" lists every command from step 1, and ","type":"text"},{"text":"\u003ccli-name> help \u003ccommand>","type":"text","marks":[{"type":"code_inline"}]},{"text":" shows its params and example.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"4. Build and install","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"./build.sh\n\u003ccli-name> help","type":"text"}]},{"type":"paragraph","content":[{"text":"build.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" installs the app and links ","type":"text"},{"text":"~/.local/bin/\u003ccli-name>","type":"text","marks":[{"type":"code_inline"}]},{"text":" to the binary inside it, so the CLI always matches the app. If the shell cannot find it, add ","type":"text"},{"text":"~/.local/bin","type":"text","marks":[{"type":"code_inline"}]},{"text":" to ","type":"text"},{"text":"PATH","type":"text","marks":[{"type":"code_inline"}]},{"text":" in ","type":"text"},{"text":"~/.zshrc","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"paragraph","content":[{"text":"Check: ","type":"text"},{"text":"\u003ccli-name> --version","type":"text","marks":[{"type":"code_inline"}]},{"text":" prints the app's version.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"5. Test","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"\"${CLAUDE_SKILL_DIR}/scripts/check-agent-cli.sh\" \u003ccli-name>","type":"text"}]},{"type":"paragraph","content":[{"text":"It starts a test copy of the app on a temporary database, checks help, JSON output, error codes, every read command, and the MCP tool list, then quits the test copy. Then run each write command once by hand, with ","type":"text"},{"text":"APP_STORE=/tmp/\u003cApp>-test.store","type":"text","marks":[{"type":"code_inline"}]},{"text":", and look at the window: the change shows without a reload.","type":"text"}]},{"type":"paragraph","content":[{"text":"Check: the script prints ","type":"text"},{"text":"PASS agent CLI","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"6. Teach agents","type":"text"}]},{"type":"paragraph","content":[{"text":"Write ","type":"text"},{"text":".claude/skills/\u003ccli-name>/SKILL.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" in the app's repo from ","type":"text"},{"text":"teach-agents.md","type":"text","marks":[{"type":"link","attrs":{"href":"teach-agents.md","title":null}}]},{"text":": what the app is, the safety rules, the main commands, and \"run ","type":"text"},{"text":"\u003ccli-name> help","type":"text","marks":[{"type":"code_inline"}]},{"text":" for the rest\". To use the app from any folder, also copy it to ","type":"text"},{"text":"~/.claude/skills/\u003ccli-name>/","type":"text","marks":[{"type":"code_inline"}]},{"text":". Add one line to the app's ","type":"text"},{"text":"CLAUDE.md","type":"text","marks":[{"type":"code_inline"}]},{"text":": \"Drive the app with ","type":"text"},{"text":"\u003ccli-name>","type":"text","marks":[{"type":"code_inline"}]},{"text":". See ","type":"text"},{"text":".claude/skills/\u003ccli-name>/SKILL.md","type":"text","marks":[{"type":"code_inline"}]},{"text":".\"","type":"text"}]},{"type":"paragraph","content":[{"text":"Check: a fresh ","type":"text"},{"text":"claude -p \"List my starred items with \u003ccli-name>\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" finds the CLI and answers from real data.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"7. MCP, when the user wants it","type":"text"}]},{"type":"paragraph","content":[{"text":"For Claude Desktop, or to get one approval prompt per tool:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"claude mcp add --scope user \u003ccli-name> -- ~/Applications/\u003cApp>.app/Contents/MacOS/\u003cApp> mcp\ncodex mcp add \u003ccli-name> -- ~/Applications/\u003cApp>.app/Contents/MacOS/\u003cApp> mcp","type":"text"}]},{"type":"paragraph","content":[{"text":"Claude Desktop: add it to ","type":"text"},{"text":"~/Library/Application Support/Claude/claude_desktop_config.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" under ","type":"text"},{"text":"mcpServers","type":"text","marks":[{"type":"code_inline"}]},{"text":", with the full path and ","type":"text"},{"text":"\"args\": [\"mcp\"]","type":"text","marks":[{"type":"code_inline"}]},{"text":". More in ","type":"text"},{"text":"teach-agents.md","type":"text","marks":[{"type":"link","attrs":{"href":"teach-agents.md","title":null}}]},{"text":".","type":"text"}]},{"type":"paragraph","content":[{"text":"Check: ","type":"text"},{"text":"claude mcp list","type":"text","marks":[{"type":"code_inline"}]},{"text":" shows the server as connected.","type":"text"}]},{"type":"paragraph","content":[{"text":"Done when every command from step 1 runs through the CLI against the running app, ","type":"text"},{"text":"check-agent-cli.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":" prints ","type":"text"},{"text":"PASS agent CLI","type":"text","marks":[{"type":"code_inline"}]},{"text":", the app's repo has the agent skill from step 6, and a fresh Claude Code session can do a real task with it.","type":"text"}]}]},"metadata":{"date":"2026-11-02","name":"agent-cli","author":"@skillopedia","source":{"stars":1414,"repo_name":"bama","origin_url":"https://github.com/daveebbelaar/bama/blob/HEAD/skills/code/agent-cli/SKILL.md","repo_owner":"daveebbelaar","body_sha256":"e7ef0e6a94b2c6a280fd8bc465be6167ef9d5aebea12d9d9657234c5b0d0adb5","cluster_key":"e473012722cab3e0332bb33b4cf01a92fc93381272840c96111d84c5a021acbe","clean_bundle":{"format":"clean-skill-bundle-v1","source":"daveebbelaar/bama/skills/code/agent-cli/SKILL.md","text_bytes":46968,"source_commit":"639893c397278256f1ae204dd8f837eb82bd6527","skills_sh_rank":19291,"attachment_count":11,"text_attachments":11,"attachment_storage":"skill_attachments","binary_attachments":0},"cluster_size":1,"skill_md_path":"skills/code/agent-cli/SKILL.md","import_metadata":{"date":"2026-11-02","author":"@skillopedia","version":"v1","category":"integrations-apis","category_label":"Integrations"},"exact_dupes_collapsed_into_this":0},"version":"v1","category":"integrations-apis","import_tag":"skills-sh-top20k-2026-11","description":"Optional command line and MCP server for a macOS app, so Claude Code, Codex, and scripts can drive it (list, add, change, search, act) through the running app. Use when the user wants agents to work with their app or its data, asks for a CLI, an MCP server, or automation for the app, or says \"Claude should be able to use my app\".","argument-hint":"\u003ccli-name, like tasks or mail>"}},"renderedAt":1793671036595}

Agent CLI Give the app a command line, so an agent can do everything a person can do in the window: . The app binary is the CLI. Each command goes to the running app over a private socket, runs there like a button press, and the window updates live. CLI, MCP, or App Intents | Way in | Who uses it | Ship it | | --- | --- | --- | | CLI | Claude Code, Codex, scripts, people in Terminal | Always. Agents learn it from , and it costs no context until used | | MCP ( ) | Claude Desktop and other clients with no shell | Optional. The same commands, one tool each. One line to register | | App Intents |…