@nomac/cli 0.7.0 → 0.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nomac/cli",
3
- "version": "0.7.0",
3
+ "version": "0.8.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.
package/src/cli.mjs CHANGED
@@ -30,6 +30,28 @@ Agents:
30
30
  More: nomac start --help, nomac whoami, nomac start --host user@mac (a Mac you own)
31
31
  Environment: NOMAC_API_KEY, NOMAC_API_URL`;
32
32
 
33
+ // The Builder commands act at once (a release build uses one of the plan's builds),
34
+ // so they take only their own flags and print usage for --help instead of running.
35
+ const LEGACY_HELP = {
36
+ push: "Usage: nomac push\n Upload this folder as a project snapshot (respects .gitignore; secrets are excluded).",
37
+ 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)",
38
+ status: "Usage: nomac status [build-id]\n Show a build's state as JSON (the last build in this folder by default).",
39
+ logs: "Usage: nomac logs [build-id]\n Show a build's steps and failure excerpt (the last build in this folder by default).",
40
+ 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`.",
41
+ };
42
+ const isHelp = (args) => args.some((a) => a === "--help" || a === "-h" || a === "help");
43
+ function onlyFlags(args, allowed, command) {
44
+ const unknown = args.find((a) => !allowed.includes(a));
45
+ if (unknown !== undefined) throw new CliError(`unknown option: ${unknown}`, `Unknown option "${unknown}" for nomac ${command}.\n\n${LEGACY_HELP[command]}`);
46
+ }
47
+ /** A build ID argument: bld_..., or none for the folder's last build. Never a flag. */
48
+ function buildIdArg(args, command) {
49
+ const [first, ...more] = args.filter((a) => a !== "--json");
50
+ if (first === undefined) return undefined;
51
+ 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]}`);
52
+ return first;
53
+ }
54
+
33
55
  export async function main(argv) {
34
56
  const [cmd, ...rest] = argv;
35
57
  switch (cmd) {
@@ -51,7 +73,10 @@ export async function main(argv) {
51
73
  }
52
74
  case "status":
53
75
  // 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]);
76
+ if (/^bld_/.test(rest[0] ?? "") || (loadProjectState().last_build && !(await import("./session-store.mjs")).sessionStore().load())) {
77
+ if (isHelp(rest)) return console.log(LEGACY_HELP.status);
78
+ return status(buildIdArg(rest, "status"));
79
+ }
55
80
  // falls through: everyone else sees their Mac
56
81
  case "start":
57
82
  case "exec":
@@ -84,19 +109,26 @@ export async function main(argv) {
84
109
  }
85
110
  saveApiKey(key);
86
111
  const me = await api("GET", "/api/v1/me");
87
- console.log(`✅ logged in — org "${me.org.name}" (${me.org.id})`);
112
+ console.log(`✅ logged in: org "${me.org.name}" (${me.org.id})`);
88
113
  return;
89
114
  }
90
115
  const { deviceLogin } = await import("./login.mjs");
91
116
  return deviceLogin();
92
117
  }
93
118
  case "push":
119
+ if (isHelp(rest)) return console.log(LEGACY_HELP.push);
120
+ onlyFlags(rest, [], "push");
94
121
  return push();
95
122
  case "build":
123
+ if (isHelp(rest)) return console.log(LEGACY_HELP.build);
124
+ onlyFlags(rest, ["--smoke", "--testflight", "--wait"], "build");
96
125
  return build(rest);
97
126
  case "logs":
98
- return logs(rest[0]);
127
+ if (isHelp(rest)) return console.log(LEGACY_HELP.logs);
128
+ return logs(buildIdArg(rest, "logs"));
99
129
  case "mcp": {
130
+ // Agents start this as a stdio server; --help must not start it and wait on stdin.
131
+ if (isHelp(rest)) return console.log(LEGACY_HELP.mcp);
100
132
  const { runMcpServer } = await import("./mcp.mjs");
101
133
  return runMcpServer();
102
134
  }
@@ -149,10 +181,10 @@ async function push() {
149
181
  const icon = res.lint.grade === "green" ? "🟢" : res.lint.grade === "yellow" ? "🟡" : "🔴";
150
182
  const n = res.lint.findings ?? 0;
151
183
  console.log(
152
- ` review lint: ${icon} ${res.lint.grade}${n ? ` — ${n} finding${n === 1 ? "" : "s"}` : ""}`,
184
+ ` review lint: ${icon} ${res.lint.grade}${n ? ` · ${n} finding${n === 1 ? "" : "s"}` : ""}`,
153
185
  );
154
186
  if (res.lint.grade === "red") {
155
- console.log(" ⚠ red findings will block publish — fix them before `nomac build`/publish.");
187
+ console.log(" ⚠ red findings will block publish: fix them before `nomac build`/publish.");
156
188
  }
157
189
  }
158
190
  if (res.warning) console.log(`⚠ ${res.warning}`);
@@ -162,7 +194,7 @@ async function push() {
162
194
  async function build(rest) {
163
195
  const state = loadProjectState();
164
196
  if (!state.project_id) {
165
- throw new CliError("no project", "No project here — run `nomac push` first.");
197
+ throw new CliError("no project", "No project here. Run `nomac push` first.");
166
198
  }
167
199
  const smoke = rest.includes("--smoke");
168
200
  const wait = rest.includes("--testflight") || rest.includes("--wait");
@@ -208,8 +240,8 @@ async function waitForBuild(id) {
208
240
  if (b.state === "ready") {
209
241
  console.log(
210
242
  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.",
243
+ ? "\n🎉 smoke build passed: the project compiles clean on macOS."
244
+ : "\n🎉 build ready: open TestFlight on your phone, the new build is live.",
213
245
  );
214
246
  return;
215
247
  }
@@ -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. */
@@ -172,7 +190,10 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
172
190
  const usage = billing?.mode === "weekly_included"
173
191
  ? `Included time used: ${billing.used_seconds} seconds. No extra charge.`
174
192
  : `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}`);
193
+ const next = result.session.state === "ready" ? "Next: nomac sync . → nomac ssh"
194
+ : result.session.failure_code === "capacity_unavailable" ? "No Mac was free within 5 minutes; nothing was charged. Run nomac start again in a few minutes."
195
+ : result.session.state === "queued" ? "Waiting for a free Mac (waiting is free). Check again: nomac start" : "Check: nomac session status --json";
196
+ print(values.json || !["start", "stop"].includes(command) ? JSON.stringify(result, null, 2) : `${result.session.id}: ${result.session.state}\n${next}\n${usage}`);
176
197
  return result;
177
198
  };
178
199
  const saveView = (local, view) => {
@@ -217,9 +238,11 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
217
238
  if (!positionals.length || !argv.includes("--")) throw new CliError("Use nomac exec -- command args");
218
239
  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
240
  // 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.
241
+ // Without a key, a long wait implies a long command, so the command's own limit follows it unless set.
242
+ // With a key the limit must not depend on --wait: a retry with another --wait is the same command.
221
243
  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) } };
244
+ const timeout = integer(values.timeout, values["request-key"] === undefined ? Math.max(300, waitFor) : 600, 1, 600);
245
+ method = "POST"; path = `${base}/jobs`; params = { requestKey: values["request-key"] ?? `cli-${randomUUID()}`, body: { argv: positionals, timeout_seconds: timeout } };
223
246
  } else {
224
247
  if (positionals.length !== 1 || !/^job_[a-f0-9]{40}$/.test(positionals[0])) throw new CliError("Supply the existing job ID.");
225
248
  path = `${base}/jobs/${positionals[0]}`;
@@ -233,6 +256,10 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
233
256
  const cache = {}, result = await hostedRequest(local.id, method, path, params, { request, transport, cache }).catch(error => {
234
257
  // The job exists; a busy Mac only delays its report.
235
258
  if (command === "job" && waiting && error?.code === "guest_busy") return { id: positionals[0], state: "running" };
259
+ // The key this CLI made up is the only safe way to retry: say it, with the exact same command.
260
+ if (command === "exec" && values["request-key"] === undefined && uncertainOutcome(error)) {
261
+ 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)}`;
262
+ }
236
263
  throw error;
237
264
  });
238
265
  if (!waiting) return output(result);
@@ -248,8 +275,12 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
248
275
  const local = localSession();
249
276
  if (positionals[0] === "stop" && positionals.length === 1) {
250
277
  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)`);
278
+ const r = saved.recording, link = saved.link;
279
+ // Only a ready link can be shared; a failed or unfinished copy says so instead.
280
+ const share = link?.state === "ready" ? `Share it: ${link.url} (works until ${link.expires_at.slice(11, 16)} UTC, also after the Mac stops)`
281
+ : 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.`}`
282
+ : `The share link is still being prepared: ${link?.url} (check it in a minute; it works until ${link?.expires_at?.slice(11, 16)} UTC).`;
283
+ 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
284
  return saved;
254
285
  }
255
286
  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}$" };
@@ -30,7 +30,7 @@ export const MAC_TOOLS = [
30
30
  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
31
  } },
32
32
  { 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 })) },
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 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
34
  { 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
35
  const given = [local_path, content, content_base64].filter(v => v !== undefined).length;
36
36
  if (given !== 1) return text({ error: { code: "invalid_request", message: "Give exactly one of local_path, content or content_base64." } });
@@ -42,7 +42,7 @@ export const MAC_TOOLS = [
42
42
  }
43
43
  return text(await api("POST", `${base({ session_id })}/files`, { body: { path, ...(content !== undefined ? { content } : { content_base64 }), executable: Boolean(executable) } }));
44
44
  } },
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 }) => {
45
+ { 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
46
  const read = max => api("GET", `${base({ session_id })}/files?${new URLSearchParams({ path, ...(max ? { max_bytes: String(max) } : {}) })}`);
47
47
  const head = file => file.encoding === "base64" ? imageType(Buffer.from(String(file.content_base64 ?? "").slice(0, 32), "base64")) : undefined;
48
48
  // An image file, or one being saved here, is read whole in one go; others start with the default 1 MiB.
@@ -52,6 +52,13 @@ export const MAC_TOOLS = [
52
52
  const bytes = file.encoding === "utf8" ? Buffer.from(file.content ?? "", "utf8") : Buffer.from(file.content_base64 ?? "", "base64");
53
53
  const meta = Object.fromEntries(Object.entries(file).filter(([k]) => !["content", "content_base64"].includes(k)));
54
54
  if (save_to) {
55
+ // Never save part of a file as if it were whole: a file over one read comes through a download link.
56
+ if (file.truncated && max_bytes === undefined) {
57
+ let link = await createLink(session_id, { direction: "download", path, wait_seconds: 30 });
58
+ for (let checks = 0; link.state === "copying" && checks < 12; checks++) link = await checkLink(link.url, 50);
59
+ 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." });
60
+ return text({ ...meta, truncated: false, ...await saveFromLink(link, save_to) });
61
+ }
55
62
  const target = resolve(save_to); mkdirSync(dirname(target), { recursive: true }); writeFileSync(target, bytes);
56
63
  return text({ ...meta, saved_to: target, saved_bytes: bytes.length });
57
64
  }
@@ -59,7 +66,7 @@ export const MAC_TOOLS = [
59
66
  if (mimeType) return { content: [{ type: "image", data: file.content_base64, mimeType }, { type: "text", text: JSON.stringify({ ...meta, mime_type: mimeType }, null, 2) }] };
60
67
  // Part of a binary file as base64 would only fill the context; say how to get it instead.
61
68
  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."
69
+ ? "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
70
  : "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
71
  return text(file);
65
72
  } },
@@ -69,7 +76,7 @@ export const MAC_TOOLS = [
69
76
  const { image_base64: data, ...meta } = shot;
70
77
  return { content: [{ type: "image", data, mimeType: meta.mime_type }, { type: "text", text: JSON.stringify(meta, null, 2) }] };
71
78
  } },
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 })) },
79
+ { 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
80
  { 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
81
  { 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
82
  { 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,15 @@ 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
+ "Always stop_mac when finished, after reading any output you need, so credit is not spent on an idle Mac.",
27
+ ].join(" ");
18
28
 
19
29
  const text = (obj) => ({
20
30
  content: [{ type: "text", text: typeof obj === "string" ? obj : JSON.stringify(obj, null, 2) }],
@@ -25,7 +35,7 @@ async function resolveProjectId(args) {
25
35
  if (args?.project_id) return args.project_id;
26
36
  const state = loadProjectState(args?.directory ?? process.cwd());
27
37
  if (state.project_id) return state.project_id;
28
- throw new Error("no project here — run push_project first (or pass project_id)");
38
+ throw new Error("no project here: run push_project first (or pass project_id)");
29
39
  }
30
40
 
31
41
  export const TOOLS = [
@@ -126,7 +136,7 @@ export const TOOLS = [
126
136
  {
127
137
  name: "build",
128
138
  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.",
139
+ "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
140
  inputSchema: {
131
141
  type: "object",
132
142
  properties: {
@@ -415,7 +425,8 @@ export async function runMcpServer() {
415
425
  reply({
416
426
  protocolVersion: params?.protocolVersion ?? PROTOCOL_FALLBACK,
417
427
  capabilities: { tools: {} },
418
- serverInfo: { name: "nomac", version: "0.1.0" },
428
+ serverInfo: { name: "nomac", version: CLI_VERSION },
429
+ instructions: INSTRUCTIONS,
419
430
  });
420
431
  break;
421
432
  case "notifications/initialized":
@@ -432,6 +443,11 @@ export async function runMcpServer() {
432
443
  case "tools/call": {
433
444
  const tool = TOOLS.find((t) => t.name === params?.name);
434
445
  if (!tool) return fail(-32602, `unknown tool: ${params?.name}`);
446
+ const problem = argumentProblem(tool.inputSchema, params?.arguments ?? {});
447
+ if (problem) {
448
+ 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 });
449
+ break;
450
+ }
435
451
  try {
436
452
  reply(await toolScope.run({ tool: tool.name }, () => tool.handler(params?.arguments ?? {})));
437
453
  } catch (err) {
@@ -35,7 +35,9 @@ export function multiplexArgs(session, { platform = process.platform, home = hom
35
35
  const dir = join(home, ".nomac", "cm");
36
36
  const name = createHash("sha256").update(`${session.id}:${session.generation ?? 0}`).digest("hex").slice(0, 20);
37
37
  const path = join(dir, name);
38
- if (path.length > 100) return off; // Unix socket paths are limited to about 104 bytes
38
+ // macOS socket paths hold 103 bytes, and ssh first binds a temporary socket at the path
39
+ // plus a 17-byte suffix (".XXXXXXXXXXXXXXXX"): longer paths make ssh exit 255.
40
+ if (Buffer.byteLength(path) > 86) return off;
39
41
  try { mkdirSync(dir, { recursive: true, mode: 0o700 }); chmodSync(dir, 0o700); } catch { return off; }
40
42
  return ["-o", "ControlMaster=auto", "-o", `ControlPath=${path}`, "-o", "ControlPersist=120"];
41
43
  }