@nomac/cli 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/nomac.mjs CHANGED
@@ -5,6 +5,7 @@ main(process.argv.slice(2)).catch((err) => {
5
5
  const args = process.argv.slice(2);
6
6
  if (["start", "sync", "pull", "stop", "session", "transport", "exec", "job", "output"].includes(args[0]) && args.includes("--json")) {
7
7
  console.error(JSON.stringify({ error: { code: err?.code ?? "session_error", message: err?.friendly ?? err?.message ?? String(err), ...(err?.details ? { details: err.details } : {}) } }));
8
- } else console.error(err?.friendly ?? err?.message ?? String(err));
8
+ // An empty friendly message means the command already printed why it failed.
9
+ } else if (err?.friendly !== "") console.error(err?.friendly ?? err?.message ?? String(err));
9
10
  process.exit(err?.exitCode ?? 1);
10
11
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nomac/cli",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Cloud Mac sessions over SSH and HTTPS, plus iOS builds and publishing — built for agents.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
package/skill/SKILL.md CHANGED
@@ -6,7 +6,7 @@ description: >
6
6
  stop.
7
7
  ---
8
8
 
9
- # nomac — a Mac for your agent
9
+ # nomac: a Mac for your agent
10
10
 
11
11
  NoMac 2.0 provides a full macOS VM with administrator access. You choose the
12
12
  commands and tools. Mac time is prepaid credit, separate from Builder.
@@ -44,8 +44,9 @@ with the same request key.
44
44
  Keep the same request key and arguments after uncertain responses; inspect
45
45
  the original job before retrying. Output retrieval failure is not job failure.
46
46
  Managed jobs have a 600-second limit; SSH can run longer within session limits.
47
- CLI sync skips symlinks and preserves remote extra files; use native SSH/rsync
48
- when needed. See the screen with `nomac screenshot` (saves
47
+ CLI sync sends every file (.env and keys included) and symlinks as links, and
48
+ skips dependency and build folders, .git and folders .gitignore lists; a `!`
49
+ line in .nomacignore sends any of them. It preserves remote extra files. See the screen with `nomac screenshot` (saves
49
50
  `nomac-screenshot.jpg`; `--simulator` for the iOS Simulator) or MCP
50
51
  `screenshot_mac`, which returns an image you can look at. Record a video for
51
52
  the human with `nomac record`, do the work, then `nomac record stop` (saves the
@@ -58,6 +59,36 @@ Pipe xcodebuild through `xcbeautify`. Persistent disks are not currently offered
58
59
  Report failures using `report_issue` with `session_id`, optional `job_id` and
59
60
  a description without secrets. Read https://nomac.app/llms.txt for full limits.
60
61
 
62
+ ## Secrets, signing and shipping
63
+
64
+ Never ask for a secret in the chat. `nomac secrets request NAME --description
65
+ TEXT` (MCP `request_secret`) gives the human a link to enter it; they can also
66
+ run `nomac secrets set NAME` themselves. Every Mac receives the account's
67
+ secrets as variables and files, masked in command output; `nomac secrets
68
+ install` refreshes a running Mac.
69
+
70
+ The human connects Apple once at https://nomac.app/apple; the App Store
71
+ Connect key stays on NoMac's server. Then, on the Mac:
72
+
73
+ 1. `nomac sign [path]` (MCP `set_up_signing`) makes and installs the App Store
74
+ profiles the project needs and prints the files, the reserved build number
75
+ and the xcodebuild, Flutter and fastlane commands. The project is unchanged;
76
+ build with your own tools.
77
+ 2. `nomac testflight path/to/App.ipa` (MCP `send_to_testflight`) puts it on
78
+ TestFlight. Relay the human's answer to Apple's encryption question (asked
79
+ once per app), never guess.
80
+ 3. `nomac publish App.ipa --whats-new TEXT` stages the App Store version and
81
+ lists Apple's blockers; submit with the printed `--confirm` command only
82
+ after the human approves. MCP: `publish` with the send's project_id and
83
+ build_id.
84
+ 4. Mac apps: sign with the Developer ID certificate, `--options runtime
85
+ --timestamp`, then `nomac notarize App.app` (MCP `notarize`) notarizes and
86
+ staples it.
87
+
88
+ A step only a person can take (creating the app in App Store Connect,
89
+ agreements, an App Group) comes back as needs_human with its link: give it to
90
+ the human, then call again.
91
+
61
92
  ## Builder (existing subscribers only)
62
93
 
63
94
  The separate iOS Builder publishing product is closed to new subscriptions.
package/src/api.mjs CHANGED
@@ -65,13 +65,15 @@ export async function api(method, path, { body, raw, idempotencyKey } = {}) {
65
65
  throw new CliError(`API returned non-JSON (HTTP ${res.status})`, "The response could not be read. Reconcile the operation before retrying; reuse its request_key.", res.status, "bad_response", { code: "bad_response", ...(idempotencyKey ? { request_key: idempotencyKey } : {}) });
66
66
  }
67
67
  if (!res.ok) {
68
- const err = json?.error ?? {};
68
+ // Some answers carry more beside the error, such as publish's blockers and findings.
69
+ const { error, ...beside } = json && typeof json === "object" && !Array.isArray(json) ? json : {};
70
+ const err = error && typeof error === "object" ? error : {};
69
71
  throw new CliError(
70
72
  `HTTP ${res.status} ${err.code ?? ""}`,
71
73
  err.message ?? `API error (HTTP ${res.status})`,
72
74
  res.status,
73
75
  err.code,
74
- { ...err, ...(idempotencyKey ? { request_key: idempotencyKey } : {}) },
76
+ { ...beside, ...err, ...(idempotencyKey ? { request_key: idempotencyKey } : {}) },
75
77
  );
76
78
  }
77
79
  return json;
package/src/cli.mjs CHANGED
@@ -23,6 +23,11 @@ Use it:
23
23
  nomac screenshot save the Mac's screen as nomac-screenshot.jpg
24
24
  nomac record record a video; nomac record stop saves it with a share link
25
25
  nomac status state, cost so far and when it stops
26
+ nomac secrets variables and files every Mac receives (nomac secrets --help)
27
+ nomac sign [path] set up App Store signing for the project on the Mac
28
+ nomac testflight app.ipa send a signed .ipa on the Mac to TestFlight
29
+ nomac publish app.ipa submit it for App Review (--confirm sends it)
30
+ nomac notarize App.app notarize a Developer ID-signed Mac app and staple it
26
31
 
27
32
  Agents:
28
33
  nomac mcp MCP server for Claude Code, Codex, Cursor and others
@@ -30,6 +35,28 @@ Agents:
30
35
  More: nomac start --help, nomac whoami, nomac start --host user@mac (a Mac you own)
31
36
  Environment: NOMAC_API_KEY, NOMAC_API_URL`;
32
37
 
38
+ // The Builder commands act at once (a release build uses one of the plan's builds),
39
+ // so they take only their own flags and print usage for --help instead of running.
40
+ const LEGACY_HELP = {
41
+ push: "Usage: nomac push\n Upload this folder as a project snapshot (respects .gitignore; secrets are excluded).",
42
+ build: "Usage: nomac build [--smoke] [--testflight | --wait]\n Start a signed release build for TestFlight (uses one build from your plan).\n --smoke unsigned compile check instead\n --testflight wait until the build is on TestFlight (--wait for a smoke build)",
43
+ status: "Usage: nomac status [build-id]\n Show a build's state as JSON (the last build in this folder by default).",
44
+ logs: "Usage: nomac logs [build-id]\n Show a build's steps and failure excerpt (the last build in this folder by default).",
45
+ mcp: "Usage: nomac mcp\n Run the NoMac MCP server on stdin/stdout. Add it to your agent's MCP settings as the command `nomac mcp`.",
46
+ };
47
+ const isHelp = (args) => args.some((a) => a === "--help" || a === "-h" || a === "help");
48
+ function onlyFlags(args, allowed, command) {
49
+ const unknown = args.find((a) => !allowed.includes(a));
50
+ if (unknown !== undefined) throw new CliError(`unknown option: ${unknown}`, `Unknown option "${unknown}" for nomac ${command}.\n\n${LEGACY_HELP[command]}`);
51
+ }
52
+ /** A build ID argument: bld_..., or none for the folder's last build. Never a flag. */
53
+ function buildIdArg(args, command) {
54
+ const [first, ...more] = args.filter((a) => a !== "--json");
55
+ if (first === undefined) return undefined;
56
+ if (!/^bld_[A-Za-z0-9]+$/.test(first) || more.length) throw new CliError(`not a build id: ${first}`, `"${[first, ...more].join(" ")}" is not a build ID (bld_...).\n\n${LEGACY_HELP[command]}`);
57
+ return first;
58
+ }
59
+
33
60
  export async function main(argv) {
34
61
  const [cmd, ...rest] = argv;
35
62
  switch (cmd) {
@@ -51,7 +78,10 @@ export async function main(argv) {
51
78
  }
52
79
  case "status":
53
80
  // A legacy build ID, or a folder that only ever built with the old pipeline, keeps build status.
54
- if (/^bld_/.test(rest[0] ?? "") || (loadProjectState().last_build && !(await import("./session-store.mjs")).sessionStore().load())) return status(rest[0]);
81
+ if (/^bld_/.test(rest[0] ?? "") || (loadProjectState().last_build && !(await import("./session-store.mjs")).sessionStore().load())) {
82
+ if (isHelp(rest)) return console.log(LEGACY_HELP.status);
83
+ return status(buildIdArg(rest, "status"));
84
+ }
55
85
  // falls through: everyone else sees their Mac
56
86
  case "start":
57
87
  case "exec":
@@ -75,6 +105,31 @@ export async function main(argv) {
75
105
  // A Mac you own reports through session status.
76
106
  return cmd === "status" ? sessionCommand("session", ["status", ...rest]) : sessionCommand(cmd, rest);
77
107
  }
108
+ case "sign": {
109
+ const { signCommand } = await import("./sign.mjs");
110
+ const { sessionStore } = await import("./session-store.mjs");
111
+ return signCommand(rest, { store: sessionStore() });
112
+ }
113
+ case "testflight": {
114
+ const { testflightCommand } = await import("./testflight.mjs");
115
+ const { sessionStore } = await import("./session-store.mjs");
116
+ return testflightCommand(rest, { store: sessionStore() });
117
+ }
118
+ case "publish": {
119
+ const { publishCommand } = await import("./publish.mjs");
120
+ const { sessionStore } = await import("./session-store.mjs");
121
+ return publishCommand(rest, { store: sessionStore() });
122
+ }
123
+ case "notarize": {
124
+ const { notarizeCommand } = await import("./notarize.mjs");
125
+ const { sessionStore } = await import("./session-store.mjs");
126
+ return notarizeCommand(rest, { store: sessionStore() });
127
+ }
128
+ case "secrets": {
129
+ const { secretsCommand } = await import("./secrets.mjs");
130
+ const { sessionStore } = await import("./session-store.mjs");
131
+ return secretsCommand(rest, { store: sessionStore() });
132
+ }
78
133
  case "login": {
79
134
  const key = rest[0];
80
135
  if (key) {
@@ -84,19 +139,26 @@ export async function main(argv) {
84
139
  }
85
140
  saveApiKey(key);
86
141
  const me = await api("GET", "/api/v1/me");
87
- console.log(`✅ logged in — org "${me.org.name}" (${me.org.id})`);
142
+ console.log(`✅ logged in: org "${me.org.name}" (${me.org.id})`);
88
143
  return;
89
144
  }
90
145
  const { deviceLogin } = await import("./login.mjs");
91
146
  return deviceLogin();
92
147
  }
93
148
  case "push":
149
+ if (isHelp(rest)) return console.log(LEGACY_HELP.push);
150
+ onlyFlags(rest, [], "push");
94
151
  return push();
95
152
  case "build":
153
+ if (isHelp(rest)) return console.log(LEGACY_HELP.build);
154
+ onlyFlags(rest, ["--smoke", "--testflight", "--wait"], "build");
96
155
  return build(rest);
97
156
  case "logs":
98
- return logs(rest[0]);
157
+ if (isHelp(rest)) return console.log(LEGACY_HELP.logs);
158
+ return logs(buildIdArg(rest, "logs"));
99
159
  case "mcp": {
160
+ // Agents start this as a stdio server; --help must not start it and wait on stdin.
161
+ if (isHelp(rest)) return console.log(LEGACY_HELP.mcp);
100
162
  const { runMcpServer } = await import("./mcp.mjs");
101
163
  return runMcpServer();
102
164
  }
@@ -149,10 +211,10 @@ async function push() {
149
211
  const icon = res.lint.grade === "green" ? "🟢" : res.lint.grade === "yellow" ? "🟡" : "🔴";
150
212
  const n = res.lint.findings ?? 0;
151
213
  console.log(
152
- ` review lint: ${icon} ${res.lint.grade}${n ? ` — ${n} finding${n === 1 ? "" : "s"}` : ""}`,
214
+ ` review lint: ${icon} ${res.lint.grade}${n ? ` · ${n} finding${n === 1 ? "" : "s"}` : ""}`,
153
215
  );
154
216
  if (res.lint.grade === "red") {
155
- console.log(" ⚠ red findings will block publish — fix them before `nomac build`/publish.");
217
+ console.log(" ⚠ red findings will block publish: fix them before `nomac build`/publish.");
156
218
  }
157
219
  }
158
220
  if (res.warning) console.log(`⚠ ${res.warning}`);
@@ -162,7 +224,7 @@ async function push() {
162
224
  async function build(rest) {
163
225
  const state = loadProjectState();
164
226
  if (!state.project_id) {
165
- throw new CliError("no project", "No project here — run `nomac push` first.");
227
+ throw new CliError("no project", "No project here. Run `nomac push` first.");
166
228
  }
167
229
  const smoke = rest.includes("--smoke");
168
230
  const wait = rest.includes("--testflight") || rest.includes("--wait");
@@ -208,8 +270,8 @@ async function waitForBuild(id) {
208
270
  if (b.state === "ready") {
209
271
  console.log(
210
272
  b.workflow === "smoke"
211
- ? "\n🎉 smoke build passed — the project compiles clean on macOS."
212
- : "\n🎉 build ready — open TestFlight on your phone, the new build is live.",
273
+ ? "\n🎉 smoke build passed: the project compiles clean on macOS."
274
+ : "\n🎉 build ready: open TestFlight on your phone, the new build is live.",
213
275
  );
214
276
  return;
215
277
  }
@@ -121,7 +121,7 @@ async function waitForCommand(id, base, started, waitSeconds, { request, transpo
121
121
  for (let cursor = 0; ;) {
122
122
  const part = await hostedRequest(id, "GET", `${base}/jobs/${job.id}/output?cursor=${cursor}&stream=${stream}`, {}, { request, transport, cache });
123
123
  parts.push(Buffer.from(part.data_base64 ?? "", "base64"));
124
- if (part.eof && part.tail_base64) parts.push(Buffer.from(`\n[output over 8 MiB was not kept; its last part follows]\n`), Buffer.from(part.tail_base64, "base64"));
124
+ if (part.eof && part.tail_base64) parts.push(...tailAfter(part));
125
125
  if (part.eof || part.next_cursor === cursor) break;
126
126
  cursor = part.next_cursor;
127
127
  }
@@ -135,6 +135,24 @@ async function waitForCommand(id, base, started, waitSeconds, { request, transpo
135
135
  return job;
136
136
  }
137
137
 
138
+ /**
139
+ * The 8 MiB output budget is shared by both streams, and once it runs out each
140
+ * stream's final 64 KiB comes back as a tail. Keep only what the retained prefix
141
+ * does not already hold: nothing for a stream that was not cut, the rest after a
142
+ * gap note for one that was.
143
+ */
144
+ export function tailAfter(part) {
145
+ const tail = Buffer.from(part.tail_base64, "base64"), have = part.next_cursor;
146
+ const start = Number.isSafeInteger(part.tail_offset) ? part.tail_offset : have;
147
+ const total = Number.isSafeInteger(part.total_bytes) ? part.total_bytes : start + tail.length;
148
+ if (total <= have) return [];
149
+ if (start > have) return [Buffer.from(`\n[output over 8 MiB was not kept; its last part follows]\n`), tail];
150
+ return [tail.subarray(have - start)];
151
+ }
152
+ /** A command line the person can paste back: simple words as they are, the rest single-quoted. */
153
+ const shellWords = words => words.map(w => /^[A-Za-z0-9_\/.:=@%+,-]+$/.test(w) ? w : `'${w.replaceAll("'", "'\\''")}'`).join(" ");
154
+ /** Errors after which the command may or may not have started on the Mac. */
155
+ const uncertainOutcome = error => !Number.isInteger(error?.status) || error.status >= 500 || ["outcome_unknown", "operation_pending"].includes(error?.code);
138
156
  const usd = microunits => `$${(Number(microunits ?? 0) / 1_000_000).toFixed(2)}`;
139
157
  const clockUtc = ms => new Date(ms).toISOString().slice(11, 16) + " UTC";
140
158
  /** One readable paragraph for nomac status. */
@@ -168,11 +186,16 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
168
186
  const wait = ["start", "stop"].includes(command) ? integer(values.wait, command === "start" ? 300 : 60, 0, command === "start" ? 600 : 300) : 0;
169
187
  if (command === "start" && values["request-key"] !== undefined && !validRequestKey(values["request-key"])) throw new CliError("Use a request key of 8–128 letters, digits, underscores, periods, colons or hyphens.");
170
188
  const output = result => {
189
+ // Sync, pull and the other commands answer with their own result, which has no session.
190
+ if (values.json || !["start", "stop"].includes(command)) { print(JSON.stringify(result, null, 2)); return result; }
171
191
  const billing = result.session?.billing;
172
192
  const usage = billing?.mode === "weekly_included"
173
193
  ? `Included time used: ${billing.used_seconds} seconds. No extra charge.`
174
194
  : `Cost so far: $${(Number(result.session?.cost?.accrued_microunits ?? 0) / 1_000_000).toFixed(2)} ($0.80/hour, billed by the minute while the Mac is ready). Credit left: nomac credits`;
175
- print(values.json || !["start", "stop"].includes(command) ? JSON.stringify(result, null, 2) : `${result.session.id}: ${result.session.state}\n${result.session.state === "ready" ? "Next: nomac sync . → nomac ssh" : "Check: nomac session status --json"}\n${usage}`);
195
+ const next = result.session.state === "ready" ? "Next: nomac sync . → nomac ssh"
196
+ : result.session.failure_code === "capacity_unavailable" ? "No Mac was free within 5 minutes; nothing was charged. Run nomac start again in a few minutes."
197
+ : result.session.state === "queued" ? "Waiting for a free Mac (waiting is free). Check again: nomac start" : "Check: nomac session status --json";
198
+ print(`${result.session.id}: ${result.session.state}\n${next}\n${usage}`);
176
199
  return result;
177
200
  };
178
201
  const saveView = (local, view) => {
@@ -217,9 +240,11 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
217
240
  if (!positionals.length || !argv.includes("--")) throw new CliError("Use nomac exec -- command args");
218
241
  if (values["request-key"] !== undefined && !/^[A-Za-z0-9_.:-]{8,128}$/.test(values["request-key"])) throw new CliError("A request key is 8 to 128 letters, digits, underscores, periods, colons or hyphens.");
219
242
  // A person running a command once needs no key; scripts pass one so a lost response can be retried safely.
220
- // A long wait implies a long command, so the command's own limit follows it unless set.
243
+ // Without a key, a long wait implies a long command, so the command's own limit follows it unless set.
244
+ // With a key the limit must not depend on --wait: a retry with another --wait is the same command.
221
245
  const waitFor = integer(values.wait, 30, 0, 600);
222
- method = "POST"; path = `${base}/jobs`; params = { requestKey: values["request-key"] ?? `cli-${randomUUID()}`, body: { argv: positionals, timeout_seconds: integer(values.timeout, Math.max(300, waitFor), 1, 600) } };
246
+ const timeout = integer(values.timeout, values["request-key"] === undefined ? Math.max(300, waitFor) : 600, 1, 600);
247
+ method = "POST"; path = `${base}/jobs`; params = { requestKey: values["request-key"] ?? `cli-${randomUUID()}`, body: { argv: positionals, timeout_seconds: timeout } };
223
248
  } else {
224
249
  if (positionals.length !== 1 || !/^job_[a-f0-9]{40}$/.test(positionals[0])) throw new CliError("Supply the existing job ID.");
225
250
  path = `${base}/jobs/${positionals[0]}`;
@@ -233,6 +258,10 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
233
258
  const cache = {}, result = await hostedRequest(local.id, method, path, params, { request, transport, cache }).catch(error => {
234
259
  // The job exists; a busy Mac only delays its report.
235
260
  if (command === "job" && waiting && error?.code === "guest_busy") return { id: positionals[0], state: "running" };
261
+ // The key this CLI made up is the only safe way to retry: say it, with the exact same command.
262
+ if (command === "exec" && values["request-key"] === undefined && uncertainOutcome(error)) {
263
+ error.friendly = `${error.friendly ?? error.message}\nThe command may have started. To retry it without running it twice: nomac exec --request-key ${params.requestKey} --timeout ${params.body.timeout_seconds}${values.wait !== undefined ? ` --wait ${values.wait}` : ""} -- ${shellWords(positionals)}`;
264
+ }
236
265
  throw error;
237
266
  });
238
267
  if (!waiting) return output(result);
@@ -248,8 +277,12 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
248
277
  const local = localSession();
249
278
  if (positionals[0] === "stop" && positionals.length === 1) {
250
279
  const saved = await stopRecording(local.id, { saveTo: values.output ?? "nomac-recording.mp4" });
251
- const r = saved.recording;
252
- print(values.json ? JSON.stringify(saved, null, 2) : `${saved.saved_to ? `Saved ${saved.saved_to}` : `Saved on the Mac at ${r.path}`} (${Number(r.seconds).toFixed(1)} s, ${(r.size / 1024 / 1024).toFixed(1)} MB${r.ended_by === "time limit" ? ", stopped at its time limit" : ""}).\nShare it: ${saved.link.url} (works until ${saved.link.expires_at.slice(11, 16)} UTC, also after the Mac stops)`);
280
+ const r = saved.recording, link = saved.link;
281
+ // Only a ready link can be shared; a failed or unfinished copy says so instead.
282
+ const share = link?.state === "ready" ? `Share it: ${link.url} (works until ${link.expires_at.slice(11, 16)} UTC, also after the Mac stops)`
283
+ : link?.state === "failed" ? `No share link: ${link.error?.message ?? "the copy from the Mac failed"}${saved.saved_to ? "" : ` The video is still on the Mac at ${r.path} until the Mac stops.`}`
284
+ : `The share link is still being prepared: ${link?.url} (check it in a minute; it works until ${link?.expires_at?.slice(11, 16)} UTC).`;
285
+ print(values.json ? JSON.stringify(saved, null, 2) : `${saved.saved_to ? `Saved ${saved.saved_to}` : `Saved on the Mac at ${r.path}`} (${Number(r.seconds).toFixed(1)} s, ${(r.size / 1024 / 1024).toFixed(1)} MB${r.ended_by === "time limit" ? ", stopped at its time limit" : ""}).\n${share}`);
253
286
  return saved;
254
287
  }
255
288
  if (positionals.length) throw new CliError("Usage: nomac record [--simulator] [--max SECONDS], then nomac record stop [-o FILE]");
package/src/mac-fetch.mjs CHANGED
@@ -7,6 +7,8 @@ export const FETCH_SCRIPT = [
7
7
  "set -euo pipefail",
8
8
  'dest="$NOMAC_PATH"',
9
9
  'case "$dest" in "~") dest="$HOME" ;; "~/"*) dest="$HOME/${dest:2}" ;; esac',
10
+ // A plain download moved onto a folder would land inside it under a temporary name.
11
+ 'if [ -z "${NOMAC_EXTRACT:-}" ] && [ -d "$dest" ]; then echo "$dest is a folder. Give the path of the file to create, for example $dest/NAME, or use extract to unpack an archive into the folder." >&2; exit 2; fi',
10
12
  'tmp="$(mktemp "${TMPDIR:-/tmp}/nomac-fetch.XXXXXX")"',
11
13
  "trap 'rm -f \"$tmp\"' EXIT",
12
14
  'curl -fsSL --retry 3 --retry-all-errors --max-time 590 -o "$tmp" -- "$NOMAC_URL"',
package/src/mac-tools.mjs CHANGED
@@ -6,7 +6,7 @@ import { dirname, resolve } from "node:path";
6
6
  import { FETCH_SCRIPT, imageType } from "./mac-fetch.mjs";
7
7
  import { checkLink, createLink, saveFromLink, uploadThroughLink } from "./mac-links.mjs";
8
8
  import { screenshot, startRecording, stopRecording } from "./mac-screen.mjs";
9
- const MAX_WRITE_BYTES = 3 * 1024 * 1024, MAX_READ_BYTES = 4 * 1024 * 1024;
9
+ const MAX_WRITE_BYTES = 3 * 1024 * 1024, MAX_READ_BYTES = 3 * 1024 * 1024;
10
10
  const text = value => ({ content: [{ type: "text", text: JSON.stringify(value, null, 2) }] });
11
11
  const session = { type: "string", pattern: "^ses_[A-Za-z0-9_-]{1,80}$" };
12
12
  const job = { type: "string", pattern: "^job_[a-f0-9]{40}$" };
@@ -22,7 +22,13 @@ export const MAC_TOOLS = [
22
22
  { name: "get_mac_credits", description: "Read the prepaid Mac credit balance: spendable dollars, spendable_seconds (how long a Mac can run on it), amounts held by a running session, pricing (including bonus_tiers for bigger purchases), recent purchases and first_purchase (true until the first paid top-up). Credit never expires. If spendable_seconds is below what the task needs, call get_mac_checkout.", inputSchema: schema({}), handler: async () => text(await api("GET", "/api/v1/mac/credits")) },
23
23
  { name: "start_mac", description: "Allocate a full cloud Mac VM, billed from prepaid credit at $0.80 an hour by the minute. If it returns insufficient_credit, call get_mac_checkout, have the human pay, then retry with the same request_key. Supply a stable request_key; SSH public_key is optional for HTTPS-only agents; reuse both after a lost response. Inspect the returned offer and limits. Poll get_mac_session until ready. No project pipeline is imposed; stop_mac deletes the VM and files.", inputSchema: schema({ public_key: { type: "string" }, request_key: key, limits: { type: "object", properties: { max_duration_seconds: { type: "integer", minimum: 60, maximum: 28800 }, idle_timeout_seconds: { type: "integer", minimum: 60, maximum: 3600 }, spend_cap_microunits: { type: "string", pattern: "^[1-9][0-9]{0,17}$" } }, additionalProperties: false } }, ["request_key"]), handler: async ({ request_key, ...body }) => text(await api("POST", "/api/v1/mac/sessions", { body, idempotencyKey: request_key })) },
24
24
  { name: "list_mac_sessions", description: "Find existing Mac sessions, including after losing a response. Shows state, cost, limits and cleanup.", inputSchema: schema({}), handler: async () => text(await api("GET", "/api/v1/mac/sessions")) },
25
- { name: "get_mac_session", description: "Read live Mac allocation state, cost, funded time and cleanup confirmation. queued/provisioning means wait; stopping is not yet confirmed cleanup.", inputSchema: schema({ session_id: session }, ["session_id"]), handler: async args => text(await api("GET", base(args))) },
25
+ { name: "get_mac_session", description: "Read live Mac allocation state, cost, funded time and cleanup confirmation, and once ready the credentials installed on it (secrets, signing identities and profiles, by name and path, never values). queued/provisioning means wait; stopping is not yet confirmed cleanup.", inputSchema: schema({ session_id: session }, ["session_id"]), handler: async args => text(await api("GET", base(args))) },
26
+ { name: "list_secrets", description: "List the account's secrets by name: variables (set in every shell and command on the Mac) and files (with their path on the Mac), whether every Mac receives them or only when asked, sizes, and secrets an agent asked for that the person has not entered yet. Values are never returned.", inputSchema: schema({}), handler: async () => text(await api("GET", "/api/v1/secrets")) },
27
+ { name: "request_secret", description: "Get a link where the person enters a secret in their browser, so its value never passes through this chat: an API token, a .env value, a .p12 certificate or a config file. kind variable is one line of text, set as an environment variable on the Mac; kind file is stored at destination (under the home folder, like ~/.netrc; default ~/.nomac/secrets/<name>). Give the person the url. Then list_secrets shows it, new Macs receive it, and install_secrets puts it on a running Mac. With a shell on this machine, the person can also run nomac secrets set NAME themselves. Never ask for a secret in the chat.", inputSchema: schema({ name: { type: "string", minLength: 1, maxLength: 128 }, kind: { type: "string", enum: ["variable", "file"] }, description: { type: "string", minLength: 1, maxLength: 500, description: "What the person should enter and why; shown to them" }, destination: { type: "string", maxLength: 256 }, delivery: { type: "string", enum: ["every_mac", "on_request"] } }, ["name", "kind", "description"]), handler: async body => text(await api("POST", "/api/v1/secrets/requests", { body })) },
28
+ { name: "set_up_signing", description: "Set up App Store signing for a project on the Mac, with the App Store Connect key kept on NoMac's server: reads the project at path (its scheme, app and every extension, with entitlements), registers missing bundle IDs, switches on the capabilities Apple's API can, makes the App Store profiles, and installs them with the account's certificate in a keychain on the Mac. The project never changes. Returns a build settings file (.xcconfig) and export options with the exact xcodebuild, Flutter and fastlane commands. Creates a distribution certificate at Apple only when the account has none. Expo and Capacitor apps need their ios folder first. A step only a person can take comes back as needs_human with its link.", inputSchema: schema({ session_id: session, path: { type: "string", minLength: 1, maxLength: 1024, description: "The project folder on the Mac, relative to the workspace" }, scheme: { type: "string", minLength: 1, maxLength: 256 }, container: { type: "string", minLength: 1, maxLength: 1024 }, issuer_id: { type: "string", maxLength: 64 } }, ["session_id"]), handler: async ({ session_id, ...body }) => text(await api("POST", `${base({ session_id })}/signing`, { body })) },
29
+ { name: "send_to_testflight", description: "Send a signed .ipa on the Mac to TestFlight, with the App Store Connect key kept on NoMac's server: NoMac creates the upload at Apple and the Mac sends only the file. Apple then processes the build, usually for 5 to 15 minutes with no Mac needed, and NoMac sets the app's export-compliance answer and What to Test and puts it on TestFlight for the internal testers. Repeat the call with the returned request_key to follow it: sending, processing, on_testflight or failed. Once on_testflight, publish with the returned project_id and build_id submits it for App Review. Apple asks once per app whether it uses encryption beyond HTTPS and Apple's own: pass the person's answer as uses_non_exempt_encryption, never a guess. A build number Apple already has comes back with the next free one. A step only a person can take, such as creating the app in App Store Connect, comes back as needs_human with its link.", inputSchema: schema({ session_id: session, path: { type: "string", minLength: 1, maxLength: 1024, description: "The signed .ipa on the Mac, relative to the workspace or absolute" }, notes: { type: "string", minLength: 1, maxLength: 4000, description: "What to Test, shown to testers" }, uses_non_exempt_encryption: { type: "boolean", description: "The person's answer, asked once per app: does it use encryption beyond HTTPS and Apple's own?" }, issuer_id: { type: "string", maxLength: 64 }, request_key: { ...key, description: "Repeat with the request_key from the first answer to follow that send" }, wait_seconds: { type: "integer", minimum: 0, maximum: 50, description: "Wait up to this long for progress (50 while following)" } }, ["session_id", "path"]), handler: async ({ session_id, ...body }) => text(await api("POST", `${base({ session_id })}/testflight`, { body })) },
30
+ { name: "notarize", description: "Notarize a Developer ID-signed Mac app, disk image or installer on the Mac (.app, .dmg, .pkg or .zip), with the App Store Connect key kept on NoMac's server: the Mac zips an .app and sends the file to Apple with temporary credentials for this one submission. Apple checks it, usually for a few minutes, then the Mac staples the ticket so it opens without a warning, even offline. Repeat the call with the returned request_key to follow it: preparing, uploading, in_progress, stapling, then accepted, invalid or rejected with Apple's issues. Sign first with the Developer ID Application certificate (the person uploads it at nomac.app/apple), the hardened runtime and a secure timestamp: codesign --force --options runtime --timestamp --sign \"Developer ID Application: …\". Keep the Mac running until it is stapled.", inputSchema: schema({ session_id: session, path: { type: "string", minLength: 1, maxLength: 1024, description: "The .app, .dmg, .pkg or .zip on the Mac, relative to the workspace or absolute" }, issuer_id: { type: "string", maxLength: 64 }, request_key: { ...key, description: "Repeat with the request_key from the first answer to follow that notarization" }, wait_seconds: { type: "integer", minimum: 0, maximum: 50, description: "Wait up to this long for progress (50 while following)" } }, ["session_id", "path"]), handler: async ({ session_id, ...body }) => text(await api("POST", `${base({ session_id })}/notarize`, { body })) },
31
+ { name: "install_secrets", description: "Install the account's secrets and signing identities on a running Mac now: every-Mac secrets, the on-request secrets named in names, and what the Mac already has; a deleted secret is removed. Macs receive every-Mac secrets on their own when they become ready, so call this after the person adds or changes one.", inputSchema: schema({ session_id: session, names: { type: "array", items: { type: "string", minLength: 1, maxLength: 128 }, maxItems: 100 } }, ["session_id"]), handler: async ({ session_id, names = [] }) => text(await api("POST", `${base({ session_id })}/secrets`, { body: { names } })) },
26
32
  { name: "open_mac_desktop", description: "Open the Mac's full macOS desktop in the human's browser on this machine, with mouse and keyboard and nothing to install. Use it when the human wants to see or click something on the Mac, for example Xcode, the Simulator or a sign-in window. The session must be ready. If opened_in_browser is false, give the human the returned url; it opens after they sign in to nomac.app. The session stays busy while the desktop is open and the page disconnects after 15 minutes without input. To show the iOS Simulator there, boot a device with simctl, then run open \"$(xcode-select -p)/Applications/Simulator.app\" with exec_mac. macOS has no RDP.", inputSchema: schema({ session_id: session, open: { type: "boolean", default: true, description: "Open the link in this machine's browser." } }, ["session_id"]), handler: async ({ session_id, open = true }) => {
27
33
  const view = await api("GET", base({ session_id }));
28
34
  if (view?.session?.state !== "ready") return text({ error: { code: "session_not_ready", message: "The Mac is not ready. Wait until get_mac_session shows ready, then call open_mac_desktop again." } });
@@ -30,7 +36,7 @@ export const MAC_TOOLS = [
30
36
  return text({ session_id, url, opened_in_browser: opened, message: opened ? "The desktop opened in the human's browser; they may need to sign in to nomac.app." : "Give the human this link; it opens after they sign in to nomac.app." });
31
37
  } },
32
38
  { name: "stop_mac", description: "Stop billing by shutting down the VM and deleting its files. Retrieve files first. Poll get_mac_session until cleanup_confirmed; stopping only acknowledges the request.", inputSchema: schema({ session_id: session }, ["session_id"]), handler: async args => text(await api("DELETE", base(args))) },
33
- { name: "exec_mac", description: "Run any argv command in your cloud Mac workspace. Waits up to wait_seconds (default 20, max 50): a command that finishes in time returns its exit_code, stdout and stderr (first 64 KiB each, as text) in this one call, as soon as it finishes. For builds, tests and other slow commands pass wait_seconds: 50. A longer one returns its job ID: poll get_mac_job, then read_mac_output. Reuse request_key and identical arguments after lost responses; never rerun to get output. Use bash -lc explicitly for shell syntax. timeout_seconds defaults to 300 and caps at 600; raise it for slow work such as a first simulator boot (about 90 seconds). A command (argv plus env) can be at most 64 KiB: put files on the Mac with write_mac_file (local_path, up to 2 GiB) or fetch_to_mac.", inputSchema: schema({ session_id: session, request_key: key, argv: { type: "array", items: { type: "string" }, minItems: 1 }, timeout_seconds: { type: "integer", minimum: 1, maximum: 600 }, wait_seconds: { type: "integer", minimum: 0, maximum: 50 } }, ["session_id", "request_key", "argv"]), handler: async ({ session_id, request_key, wait_seconds, ...body }) => text(await api("POST", `${base({ session_id })}/jobs?wait=${wait_seconds ?? 20}`, { body, idempotencyKey: request_key })) },
39
+ { name: "exec_mac", description: "Run any argv command in your cloud Mac workspace. Waits up to wait_seconds (default 20, max 50): a command that finishes in time returns its exit_code, stdout and stderr (first 64 KiB each, as text) in this one call, as soon as it finishes. For builds, tests and other slow commands pass wait_seconds: 50. A longer one returns its job ID: poll get_mac_job, then read_mac_output. Reuse request_key and identical arguments after lost responses; never rerun to get output. Use bash -lc explicitly for shell syntax. timeout_seconds defaults to 300 and caps at 600; raise it for slow work such as a first simulator boot (about 90 seconds). A command can be at most 64 KiB in total: put files on the Mac with write_mac_file (local_path, up to 2 GiB) or fetch_to_mac.", inputSchema: schema({ session_id: session, request_key: key, argv: { type: "array", items: { type: "string" }, minItems: 1 }, timeout_seconds: { type: "integer", minimum: 1, maximum: 600 }, wait_seconds: { type: "integer", minimum: 0, maximum: 50 } }, ["session_id", "request_key", "argv"]), handler: async ({ session_id, request_key, wait_seconds, ...body }) => text(await api("POST", `${base({ session_id })}/jobs?wait=${wait_seconds ?? 20}`, { body, idempotencyKey: request_key })) },
34
40
  { name: "write_mac_file", description: "Write a file on the Mac: local_path uploads a file from this machine, up to 2 GiB, without its content passing through you; or give content as text, or content_base64 for binary, up to 3 MiB. Relative Mac paths are in the session workspace; ~ is the home folder. Creates missing folders and replaces an existing file in one step; set executable for scripts. Safe to repeat. For a whole project use nomac sync; for larger files fetch_to_mac.", inputSchema: schema({ session_id: session, path: { type: "string", minLength: 1, maxLength: 1024 }, local_path: { type: "string" }, content: { type: "string" }, content_base64: { type: "string" }, executable: { type: "boolean" } }, ["session_id", "path"]), handler: async ({ session_id, path, local_path, content, content_base64, executable }) => {
35
41
  const given = [local_path, content, content_base64].filter(v => v !== undefined).length;
36
42
  if (given !== 1) return text({ error: { code: "invalid_request", message: "Give exactly one of local_path, content or content_base64." } });
@@ -42,7 +48,7 @@ export const MAC_TOOLS = [
42
48
  }
43
49
  return text(await api("POST", `${base({ session_id })}/files`, { body: { path, ...(content !== undefined ? { content } : { content_base64 }), executable: Boolean(executable) } }));
44
50
  } },
45
- { name: "read_mac_file", description: "Read a file on the Mac, up to max_bytes (default 1 MiB, max 4 MiB). save_to writes it to this machine instead of returning it. Otherwise text comes back as text, binary as base64, and PNG, JPEG, GIF and WebP files as an image you can see, read whole up to 4 MiB. To see the screen or the Simulator, use screenshot_mac. Relative Mac paths are in the session workspace. Read-only on the Mac.", inputSchema: schema({ session_id: session, path: { type: "string", minLength: 1, maxLength: 1024 }, max_bytes: { type: "integer", minimum: 1, maximum: MAX_READ_BYTES }, save_to: { type: "string" } }, ["session_id", "path"]), handler: async ({ session_id, path, max_bytes, save_to }) => {
51
+ { name: "read_mac_file", description: "Read a file on the Mac, up to max_bytes (default 1 MiB, max 3 MiB). save_to writes the whole file to this machine instead of returning it (larger files come through a download link). Otherwise text comes back as text, binary as base64, and PNG, JPEG, GIF and WebP files as an image you can see, read whole up to 3 MiB. To see the screen or the Simulator, use screenshot_mac. Relative Mac paths are in the session workspace. Read-only on the Mac.", inputSchema: schema({ session_id: session, path: { type: "string", minLength: 1, maxLength: 1024 }, max_bytes: { type: "integer", minimum: 1, maximum: MAX_READ_BYTES }, save_to: { type: "string" } }, ["session_id", "path"]), handler: async ({ session_id, path, max_bytes, save_to }) => {
46
52
  const read = max => api("GET", `${base({ session_id })}/files?${new URLSearchParams({ path, ...(max ? { max_bytes: String(max) } : {}) })}`);
47
53
  const head = file => file.encoding === "base64" ? imageType(Buffer.from(String(file.content_base64 ?? "").slice(0, 32), "base64")) : undefined;
48
54
  // An image file, or one being saved here, is read whole in one go; others start with the default 1 MiB.
@@ -52,6 +58,13 @@ export const MAC_TOOLS = [
52
58
  const bytes = file.encoding === "utf8" ? Buffer.from(file.content ?? "", "utf8") : Buffer.from(file.content_base64 ?? "", "base64");
53
59
  const meta = Object.fromEntries(Object.entries(file).filter(([k]) => !["content", "content_base64"].includes(k)));
54
60
  if (save_to) {
61
+ // Never save part of a file as if it were whole: a file over one read comes through a download link.
62
+ if (file.truncated && max_bytes === undefined) {
63
+ let link = await createLink(session_id, { direction: "download", path, wait_seconds: 30 });
64
+ for (let checks = 0; link.state === "copying" && checks < 12; checks++) link = await checkLink(link.url, 50);
65
+ if (link.state !== "ready") return text({ ...link, next_step: link.state === "copying" ? "The file is still being copied from the Mac. Call get_mac_file_link with this url and save_to to finish." : "The file could not be copied from the Mac; see error." });
66
+ return text({ ...meta, truncated: false, ...await saveFromLink(link, save_to) });
67
+ }
55
68
  const target = resolve(save_to); mkdirSync(dirname(target), { recursive: true }); writeFileSync(target, bytes);
56
69
  return text({ ...meta, saved_to: target, saved_bytes: bytes.length });
57
70
  }
@@ -59,7 +72,7 @@ export const MAC_TOOLS = [
59
72
  if (mimeType) return { content: [{ type: "image", data: file.content_base64, mimeType }, { type: "text", text: JSON.stringify({ ...meta, mime_type: mimeType }, null, 2) }] };
60
73
  // Part of a binary file as base64 would only fill the context; say how to get it instead.
61
74
  if (file.truncated && max_bytes === undefined && file.encoding === "base64") return text({ ...meta, next_step: head(file)
62
- ? "This image is over 4 MiB. Make a smaller copy on the Mac and read that: exec_mac [\"sips\", \"-Z\", \"1568\", \"-s\", \"format\", \"jpeg\", PATH, \"--out\", \"small.jpg\"]. save_to or create_mac_download_link fetches the original."
75
+ ? "This image is over 3 MiB. Make a smaller copy on the Mac and read that: exec_mac [\"sips\", \"-Z\", \"1568\", \"-s\", \"format\", \"jpeg\", PATH, \"--out\", \"small.jpg\"]. save_to or create_mac_download_link fetches the original."
63
76
  : "This binary file is over 1 MiB. Pass save_to to download it to this machine, or max_bytes to read its first bytes as base64." });
64
77
  return text(file);
65
78
  } },
@@ -69,7 +82,7 @@ export const MAC_TOOLS = [
69
82
  const { image_base64: data, ...meta } = shot;
70
83
  return { content: [{ type: "image", data, mimeType: meta.mime_type }, { type: "text", text: JSON.stringify(meta, null, 2) }] };
71
84
  } },
72
- { name: "start_mac_recording", description: "Start recording a video of the Mac. target screen (default) records the whole desktop; target simulator records the booted iOS Simulator's display at full resolution. Returns at once while it records: then do what should be shown (with exec_mac: launch the app, run UI tests, open a URL) and call stop_mac_recording, which saves the MP4 and can download it here. Stops by itself after max_seconds (default 300, max 1800). One recording at a time per session; a stopped Mac deletes it.", inputSchema: schema({ session_id: session, target: { type: "string", enum: ["screen", "simulator"] }, max_seconds: { type: "integer", minimum: 1, maximum: 1800 }, path: { type: "string", minLength: 1, maxLength: 1024 } }, ["session_id"]), handler: async ({ session_id, target, max_seconds, path }) => text(await startRecording(session_id, { target, maxSeconds: max_seconds, path })) },
85
+ { name: "start_mac_recording", description: "Start recording a video of the Mac. target screen (default) records the whole desktop; target simulator records the iOS Simulator's window. Returns at once while it records: then do what should be shown (with exec_mac: launch the app, run UI tests, open a URL) and call stop_mac_recording, which saves the MP4 and can download it here. Stops by itself after max_seconds (default 300, max 1800). One recording at a time per session; a stopped Mac deletes it.", inputSchema: schema({ session_id: session, target: { type: "string", enum: ["screen", "simulator"] }, max_seconds: { type: "integer", minimum: 1, maximum: 1800 }, path: { type: "string", minLength: 1, maxLength: 1024 } }, ["session_id"]), handler: async ({ session_id, target, max_seconds, path }) => text(await startRecording(session_id, { target, maxSeconds: max_seconds, path })) },
73
86
  { name: "stop_mac_recording", description: "Stop the recording started with start_mac_recording and save it as an MP4. Returns its path, length in seconds and size, and a download link for the human (url). save_to downloads the video to this machine. If the recording already reached its max_seconds, returns that video. Safe to repeat.", inputSchema: schema({ session_id: session, save_to: { type: "string" } }, ["session_id"]), handler: async ({ session_id, save_to }) => text(await stopRecording(session_id, { saveTo: save_to })) },
74
87
  { name: "fetch_to_mac", description: "Download a URL straight onto the Mac: any size, as fast as the Mac's connection. path is the file to create, or with extract (zip or tar, including .tar.gz) the folder to unpack into. Relative paths are in the session workspace. Waits up to wait_seconds (default 30, max 50) like exec_mac; a longer download keeps running as a job. For a git repository, run git clone with exec_mac instead.", inputSchema: schema({ session_id: session, request_key: key, url: { type: "string", pattern: "^https?://", maxLength: 4096 }, path: { type: "string", minLength: 1, maxLength: 1024 }, extract: { type: "string", enum: ["zip", "tar"] }, wait_seconds: { type: "integer", minimum: 0, maximum: 50 } }, ["session_id", "request_key", "url", "path"]), handler: async ({ session_id, request_key, url, path, extract, wait_seconds }) => text(await api("POST", `${base({ session_id })}/jobs?wait=${wait_seconds ?? 30}`, { body: { argv: ["/bin/bash", "-c", FETCH_SCRIPT], env: { NOMAC_URL: url, NOMAC_PATH: path, NOMAC_EXTRACT: extract ?? "" }, timeout_seconds: 600 }, idempotencyKey: request_key })) },
75
88
  { name: "create_mac_upload_link", description: "Get a link that puts one file of up to 2 GiB on the Mac at path. With local_path, the file is uploaded from this machine now and placed on the Mac. Otherwise give url to the human (open: true opens it in this machine's browser): they choose a file in the browser and it lands on the Mac, no account needed. Then call get_mac_file_link until state is placed. With extract (zip or tar), path is the folder to unpack into. The link takes one file and expires after expires_in_minutes (default 60, max 1440).", inputSchema: schema({ session_id: session, path: { type: "string", minLength: 1, maxLength: 1024 }, local_path: { type: "string" }, executable: { type: "boolean" }, extract: { type: "string", enum: ["zip", "tar"] }, expires_in_minutes: { type: "integer", minimum: 1, maximum: 1440 }, open: { type: "boolean", default: false, description: "Open the link in this machine's browser for the human." } }, ["session_id", "path"]), handler: async ({ session_id, path, local_path, executable, extract, expires_in_minutes, open }) => {
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Checks MCP tool arguments against the tool's JSON Schema before calling the
3
+ * API, so a missing or misnamed argument (job_id sent as id) is named to the
4
+ * agent instead of becoming a request for /jobs/undefined.
5
+ *
6
+ * Deliberately lenient where being strict could break a call that works today:
7
+ * unknown extra keys are ignored and a number may arrive as a numeric string.
8
+ * Returns the first problem as a sentence, or undefined when the arguments fit.
9
+ */
10
+ export function argumentProblem(schema, value, path = "") {
11
+ if (!schema || typeof schema !== "object") return undefined;
12
+ const name = path || "arguments";
13
+ if (Array.isArray(schema.anyOf) && !schema.anyOf.some(option => argumentProblem(option, value, path) === undefined)) {
14
+ return argumentProblem(schema.anyOf[0], value, path) ?? `${name} does not match any allowed form.`;
15
+ }
16
+ if (value === undefined) return undefined;
17
+ if (Array.isArray(schema.enum) && !schema.enum.includes(value)) return `${name} must be one of ${schema.enum.map(v => JSON.stringify(v)).join(", ")}.`;
18
+ const types = Array.isArray(schema.type) ? schema.type : schema.type ? [schema.type] : [];
19
+ if (types.length && !types.some(type => fits(type, value))) return `${name} must be ${types.map(article).join(" or ")}.`;
20
+ if (typeof value === "string" || (typeof value === "number" && types.some(t => t === "integer" || t === "number"))) {
21
+ const number = typeof value === "number" ? value : Number(value);
22
+ if (types.some(t => t === "integer" || t === "number")) {
23
+ if (typeof schema.minimum === "number" && number < schema.minimum) return `${name} must be at least ${schema.minimum}.`;
24
+ if (typeof schema.maximum === "number" && number > schema.maximum) return `${name} must be at most ${schema.maximum}.`;
25
+ }
26
+ }
27
+ if (typeof value === "string" && types.includes("string")) {
28
+ if (typeof schema.minLength === "number" && value.length < schema.minLength) return `${name} must be at least ${schema.minLength} characters.`;
29
+ if (typeof schema.maxLength === "number" && value.length > schema.maxLength) return `${name} must be at most ${schema.maxLength} characters.`;
30
+ if (typeof schema.pattern === "string" && !matches(schema.pattern, value)) return `${name} has the wrong format (expected ${schema.pattern}).`;
31
+ }
32
+ if (Array.isArray(value)) {
33
+ if (typeof schema.minItems === "number" && value.length < schema.minItems) return `${name} needs at least ${schema.minItems} item${schema.minItems === 1 ? "" : "s"}.`;
34
+ if (typeof schema.maxItems === "number" && value.length > schema.maxItems) return `${name} can have at most ${schema.maxItems} items.`;
35
+ if (schema.items) for (let i = 0; i < value.length; i++) { const problem = argumentProblem(schema.items, value[i], `${name}[${i}]`); if (problem) return problem; }
36
+ }
37
+ if (value && typeof value === "object" && !Array.isArray(value)) {
38
+ for (const required of schema.required ?? []) if (value[required] === undefined) return `${path ? `${path}.` : ""}${required} is required.`;
39
+ for (const [key, property] of Object.entries(schema.properties ?? {})) {
40
+ const problem = argumentProblem(property, value[key], path ? `${path}.${key}` : key);
41
+ if (problem) return problem;
42
+ }
43
+ }
44
+ return undefined;
45
+ }
46
+
47
+ /** A pattern this checker cannot compile is the server's to enforce, never a reason to refuse a call. */
48
+ function matches(pattern, value) {
49
+ try { return new RegExp(pattern).test(value); } catch { return true; }
50
+ }
51
+ const numeric = value => typeof value === "string" && /^-?\d+(\.\d+)?$/.test(value);
52
+ function fits(type, value) {
53
+ switch (type) {
54
+ case "string": return typeof value === "string";
55
+ case "integer": return (typeof value === "number" && Number.isInteger(value)) || (numeric(value) && Number.isInteger(Number(value)));
56
+ case "number": return (typeof value === "number" && Number.isFinite(value)) || numeric(value);
57
+ case "boolean": return typeof value === "boolean";
58
+ case "array": return Array.isArray(value);
59
+ case "object": return value !== null && typeof value === "object" && !Array.isArray(value);
60
+ case "null": return value === null;
61
+ default: return true;
62
+ }
63
+ }
64
+ const article = type => ({ integer: "an integer", object: "an object", array: "an array", null: "null" })[type] ?? `a ${type}`;
package/src/mcp.mjs CHANGED
@@ -7,7 +7,8 @@ import { readFileSync } from "node:fs";
7
7
  const lifecycleSchemas = JSON.parse(readFileSync(new URL("./lifecycle-schemas.json", import.meta.url), "utf8"));
8
8
  import { createInterface } from "node:readline";
9
9
  import { api } from "./api.mjs";
10
- import { mcpClientName, mcpContext, toolScope } from "./client-info.mjs";
10
+ import { CLI_VERSION, mcpClientName, mcpContext, toolScope } from "./client-info.mjs";
11
+ import { argumentProblem } from "./mcp-args.mjs";
11
12
  import { loadProjectState, saveProjectState } from "./config.mjs";
12
13
  import { packTarball } from "./pack.mjs";
13
14
  import { uploadSourceSnapshot } from "./upload.mjs";
@@ -15,6 +16,16 @@ import { uploadStoreAsset } from "./store-upload.mjs";
15
16
  import { MAC_TOOLS } from "./mac-tools.mjs";
16
17
 
17
18
  const PROTOCOL_FALLBACK = "2025-06-18";
19
+ /** How the tools fit together, sent once at initialize (the hosted server sends the same kind of text). */
20
+ const INSTRUCTIONS = [
21
+ "NoMac gives you a cloud Mac with Xcode, billed from prepaid credit ($0.80 an hour, by the minute).",
22
+ "Typical flow: get_mac_credits, start_mac, poll get_mac_session until ready, put code there (exec_mac with git clone, write_mac_file, fetch_to_mac, create_mac_upload_link), run exec_mac, get results with read_mac_file or create_mac_download_link, then stop_mac.",
23
+ "exec_mac returns a quick command's output in the same call; pass wait_seconds: 50 for builds and tests. A command still running comes back as a job: poll get_mac_job with its job_id, then read_mac_output.",
24
+ "Never rerun a command to get its output; after a lost response, repeat the call with the same request_key and arguments.",
25
+ "Write files with write_mac_file, not heredocs in exec_mac: a command is at most 64 KiB.",
26
+ "Shipping without the Apple key on the Mac: set_up_signing on the project returns the files and commands that sign a build with the agent's own tools; send_to_testflight sends the signed .ipa to TestFlight; publish submits it for App Review; notarize notarizes and staples a Developer ID-signed Mac app. Never ask for a secret in the chat: request_secret gives the human a link, and every Mac receives the account's secrets.",
27
+ "Always stop_mac when finished, after reading any output you need, so credit is not spent on an idle Mac.",
28
+ ].join(" ");
18
29
 
19
30
  const text = (obj) => ({
20
31
  content: [{ type: "text", text: typeof obj === "string" ? obj : JSON.stringify(obj, null, 2) }],
@@ -25,7 +36,7 @@ async function resolveProjectId(args) {
25
36
  if (args?.project_id) return args.project_id;
26
37
  const state = loadProjectState(args?.directory ?? process.cwd());
27
38
  if (state.project_id) return state.project_id;
28
- throw new Error("no project here — run push_project first (or pass project_id)");
39
+ throw new Error("no project here: run push_project first (or pass project_id)");
29
40
  }
30
41
 
31
42
  export const TOOLS = [
@@ -126,7 +137,7 @@ export const TOOLS = [
126
137
  {
127
138
  name: "build",
128
139
  description:
129
- "Start a build. workflow=release (signed, uploads to TestFlight; costs one build from the plan quota) or smoke (unsigned compile check, fastest way to verify the project builds on macOS). Returns a build_id — poll with status.",
140
+ "Start a build. workflow=release (signed, uploads to TestFlight; costs one build from the plan quota) or smoke (unsigned compile check, fastest way to verify the project builds on macOS). Returns a build_id; poll it with status.",
130
141
  inputSchema: {
131
142
  type: "object",
132
143
  properties: {
@@ -288,12 +299,12 @@ export const TOOLS = [
288
299
  {
289
300
  name: "publish",
290
301
  description:
291
- "Submit for App Store review (3-step reviewSubmissions). Requires confirm:true with authorization for this app/version; run once without confirm first to see the staged result + any Apple blockers. Costs nothing; the review decision takes ~1-3 days. On pending, repeat unchanged arguments with the returned request_key; get_store_operation recovers a lost response.",
302
+ "Submit for App Store review (3-step reviewSubmissions). Takes the app's newest build on TestFlight, or build_id: a Builder build or one sent with send_to_testflight, whose answer gives project_id and build_id. Requires confirm:true with authorization for this app/version; run once without confirm first to see the staged result + any Apple blockers. Costs nothing; the review decision takes ~1-3 days. On pending, repeat unchanged arguments with the returned request_key; get_store_operation recovers a lost response.",
292
303
  inputSchema: {
293
304
  type: "object",
294
305
  properties: {
295
306
  project_id: { type: "string" },
296
- build_id: { type: "string", description: "Exact nomac release build to attach" },
307
+ build_id: { type: "string", description: "Exact NoMac build (bld_…) to attach, from send_to_testflight or build" },
297
308
  asc_submission_id: { type: "string", description: "Exact Apple submission from get_review" },
298
309
  resolve_rejection: { type: "boolean", default: false, description: "Assert this version rejection was fixed; mark its item ready for review" },
299
310
  confirm: { type: "boolean", default: false },
@@ -415,7 +426,8 @@ export async function runMcpServer() {
415
426
  reply({
416
427
  protocolVersion: params?.protocolVersion ?? PROTOCOL_FALLBACK,
417
428
  capabilities: { tools: {} },
418
- serverInfo: { name: "nomac", version: "0.1.0" },
429
+ serverInfo: { name: "nomac", version: CLI_VERSION },
430
+ instructions: INSTRUCTIONS,
419
431
  });
420
432
  break;
421
433
  case "notifications/initialized":
@@ -432,6 +444,11 @@ export async function runMcpServer() {
432
444
  case "tools/call": {
433
445
  const tool = TOOLS.find((t) => t.name === params?.name);
434
446
  if (!tool) return fail(-32602, `unknown tool: ${params?.name}`);
447
+ const problem = argumentProblem(tool.inputSchema, params?.arguments ?? {});
448
+ if (problem) {
449
+ reply({ content: [{ type: "text", text: JSON.stringify({ error: { code: "invalid_arguments", message: `${problem} Nothing was sent to the Mac; fix the arguments and call ${tool.name} again.` } }, null, 2) }], isError: true });
450
+ break;
451
+ }
435
452
  try {
436
453
  reply(await toolScope.run({ tool: tool.name }, () => tool.handler(params?.arguments ?? {})));
437
454
  } catch (err) {