@nomac/cli 0.12.0 → 0.13.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
@@ -1,5 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { main } from "../src/cli.mjs";
3
+ import { runNewerIfInstalled } from "../src/self-update.mjs";
4
+
5
+ // A newer CLI this one installed in the background runs the command instead (self-update.mjs).
6
+ const delegated = await runNewerIfInstalled(process.argv.slice(2)).catch(() => undefined);
7
+ if (delegated !== undefined) process.exit(delegated);
3
8
 
4
9
  main(process.argv.slice(2)).catch((err) => {
5
10
  const args = process.argv.slice(2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nomac/cli",
3
- "version": "0.12.0",
3
+ "version": "0.13.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
@@ -8,8 +8,8 @@ description: >
8
8
 
9
9
  # nomac: a Mac for your agent
10
10
 
11
- NoMac 2.0 provides a full macOS VM with administrator access. You choose the
12
- commands and tools. Mac time is prepaid credit, separate from Builder.
11
+ NoMac provides a full macOS VM with administrator access, Xcode and the iOS
12
+ Simulator. You choose the commands and tools. Mac time is prepaid credit.
13
13
 
14
14
  ## Cloud Mac setup and workflow
15
15
 
@@ -20,6 +20,8 @@ commands and tools. Mac time is prepaid credit, separate from Builder.
20
20
  give them the printed link, and ask them to pay. Never enter payment
21
21
  details yourself. Poll credits until the balance appears; a checkout URL is
22
22
  not a payment. No Apple Developer account is needed for an unsigned build.
23
+ Under an hour left, answers say `low_credit` with a `buy_url`: tell the
24
+ human before a long build (a refill of 3 packs, $30, earns bonus credit).
23
25
  2. Install `npm install -g @nomac/cli`, run `nomac login`, and give the human
24
26
  the printed device URL and code. With a key the human made at
25
27
  https://nomac.app/keys: `nomac login --with-token < key.txt`, or set
@@ -49,7 +51,17 @@ name>`, which is kept between sessions; stopping deletes everything else on
49
51
  the Mac, logs included. Download what you need from outside `~/drive` before
50
52
  stopping. SSH disconnects
51
53
  do not stop the session. Ready time includes idle time; queue/setup are free.
54
+ A Mac stops at its time limit (2 hours unless `nomac start --max-duration`
55
+ set another), its spend cap, when credit runs out, or when idle; a build left
56
+ running in the background keeps it awake while it uses the CPU. In its last
57
+ 15 minutes answers carry `stop_notice` (when and why) and the CLI prints it:
58
+ `nomac extend 60` (MCP `extend_mac`) adds an hour, up to 8 hours in all; for
59
+ `reason: credit`, the human adds credit. The CLI updates itself in the
60
+ background (`NOMAC_NO_AUTO_UPDATE=1` turns it off; `nomac update` now).
52
61
  One-minute minimum, then by the minute. One session at a time; no overage.
62
+ For work that needs two Macs at once, `nomac start --parallel` from another
63
+ folder (MCP `start_mac` with `parallel: true`) starts a second, for accounts
64
+ that bought credit while NoMac has a Mac to spare; it runs without `~/drive`.
53
65
  If start returns `insufficient_credit`, buy credit as in step 1, then retry
54
66
  with the same request key.
55
67
  Keep the same request key and arguments after uncertain responses; inspect
@@ -82,6 +94,18 @@ Mac, in the Simulator either: test it on a device.
82
94
  Report failures using `report_issue` with `session_id`, optional `job_id` and
83
95
  a description without secrets. Read https://nomac.app/llms.txt for full limits.
84
96
 
97
+ ## Your editor, an agent on the Mac, Mac-only MCP servers
98
+
99
+ After `nomac start`, `nomac ssh-config` (CLI 0.12+) names the folder's Mac
100
+ `nomac` in the human's SSH config, and each later start or attach points the
101
+ name at the new Mac. Then VS Code or Cursor Remote-SSH and Claude Code's SSH
102
+ sessions connect to `nomac`; `ssh -t nomac 'cd ~/drive/src/<folder> && npx -y
103
+ @anthropic-ai/claude-code'` runs an agent on the Mac itself (keep its API key
104
+ as a NoMac secret); and an MCP server that needs a Mac runs from Windows or
105
+ Linux over SSH: `claude mcp add xcode -- ssh nomac npx -y mobilebuildmcp@latest
106
+ mcp` (MobileBuildMCP, formerly XcodeBuildMCP). An open SSH connection keeps
107
+ the Mac running until its time or spend limit: `nomac stop` when done.
108
+
85
109
  ## A Mac fully enabled for you
86
110
 
87
111
  The Mac is yours to do anything on: the `agent` account is an administrator,
@@ -132,7 +156,8 @@ the human, then call again.
132
156
 
133
157
  The separate iOS Builder publishing product is closed to new subscriptions.
134
158
  If `get_billing_status` shows a Builder plan, its tools are listed and
135
- https://nomac.app/llms.txt describes them. Otherwise use the cloud Mac above.
159
+ https://nomac.app/llms-legacy.txt describes them. Otherwise use the cloud Mac
160
+ above.
136
161
 
137
162
  ## When stuck
138
163
 
@@ -143,3 +168,11 @@ tool returns a Private Relay warning, explain that Apple relay addresses may
143
168
  reject later mail and offer a non-relay address. Never show that warning for a
144
169
  normal address such as `@icloud.com`. Never retry-loop an identical failure
145
170
  more than twice.
171
+
172
+ ## Feedback
173
+
174
+ When you stop a Mac, its answer asks for feedback. If anything got in your
175
+ way, failed, was missing from the Mac or the tools, or would have saved you
176
+ time, send it in a sentence or two: `report_issue` with `kind: "feedback"` and
177
+ the `session_id`. Leave out code, secrets and personal data; skip it if nothing
178
+ did. Feedback needs no answer.
package/src/api.mjs CHANGED
@@ -1,5 +1,25 @@
1
1
  import { loadApiKey, loadApiUrl } from "./config.mjs";
2
2
  import { clientHeaders } from "./client-info.mjs";
3
+ import { rememberLatest, updateNotice } from "./self-update.mjs";
4
+
5
+ /** Lines for the person at the terminal, on stderr (never stdout, which commands' output owns), once each. */
6
+ const said = new Set();
7
+ function say(key, text) {
8
+ if (said.has(key)) return;
9
+ said.add(key);
10
+ process.stderr.write(`${text}\n`);
11
+ }
12
+ /** What every answer may carry besides its own result: the latest CLI, and a Mac about to stop. */
13
+ function noteAnswer(res, json) {
14
+ const latest = res.headers.get("x-nomac-cli-latest");
15
+ if (latest) {
16
+ rememberLatest(latest);
17
+ const notice = updateNotice();
18
+ if (notice) say("update", notice);
19
+ }
20
+ const stop = json && typeof json === "object" ? json.stop_notice : undefined;
21
+ if (stop && typeof stop.message === "string") say(`stop:${stop.stops_at}:${stop.minutes_left <= 2 ? "last" : "soon"}`, `NoMac: ${stop.message.replace(/call extend_mac \(CLI: (nomac extend)\)/, "run $1")}`);
22
+ }
3
23
 
4
24
  export class CliError extends Error {
5
25
  constructor(message, friendly, status, code, details) {
@@ -27,6 +47,7 @@ export async function apiPublic(method, path, body) {
27
47
  } catch {
28
48
  throw new CliError(`API returned non-JSON (HTTP ${res.status}): ${text.slice(0, 200)}`, undefined, res.status);
29
49
  }
50
+ noteAnswer(res, json);
30
51
  return { status: res.status, ok: res.ok, json };
31
52
  }
32
53
 
@@ -64,6 +85,7 @@ export async function api(method, path, { body, raw, idempotencyKey } = {}) {
64
85
  } catch {
65
86
  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
87
  }
88
+ noteAnswer(res, json);
67
89
  if (!res.ok) {
68
90
  // Some answers carry more beside the error, such as publish's blockers and findings.
69
91
  const { error, ...beside } = json && typeof json === "object" && !Array.isArray(json) ? json : {};
package/src/cli.mjs CHANGED
@@ -32,6 +32,7 @@ Use it:
32
32
  nomac drive the account's persistent drive at ~/drive, kept between sessions
33
33
  nomac record record a video; nomac record stop saves it with a share link
34
34
  nomac status state, cost so far and when it stops
35
+ nomac extend 60 keep the Mac running another hour (8 hours in all)
35
36
  nomac secrets variables and files every Mac receives (nomac secrets --help)
36
37
  nomac apple whether Apple is connected: team key, certificates, profiles
37
38
  nomac sign [path] set up App Store signing for the project on the Mac
@@ -42,8 +43,8 @@ Use it:
42
43
  Agents:
43
44
  nomac mcp MCP server for Claude Code, Codex, Cursor and others
44
45
 
45
- More: nomac start --help, nomac whoami, nomac --version, nomac start --host user@mac (a Mac you own)
46
- Environment: NOMAC_API_KEY, NOMAC_API_URL`;
46
+ More: nomac start --help, nomac whoami, nomac --version, nomac update, nomac start --host user@mac (a Mac you own)
47
+ Environment: NOMAC_API_KEY, NOMAC_API_URL, NOMAC_NO_AUTO_UPDATE=1 (the CLI updates itself otherwise)`;
47
48
 
48
49
  // The Builder commands act at once (a release build uses one of the plan's builds),
49
50
  // so they take only their own flags and print usage for --help instead of running.
@@ -106,6 +107,7 @@ export async function main(argv) {
106
107
  // falls through: everyone else sees their Mac
107
108
  case "start":
108
109
  case "attach":
110
+ case "extend":
109
111
  case "exec":
110
112
  case "job":
111
113
  case "output":
@@ -208,6 +210,10 @@ export async function main(argv) {
208
210
  console.log(`auth: ${me.auth?.via ?? "?"}${me.auth?.key_id ? ` (${me.auth.key_id})` : ""}`);
209
211
  return;
210
212
  }
213
+ case "update": {
214
+ const { updateCommand } = await import("./update-command.mjs");
215
+ return updateCommand(rest);
216
+ }
211
217
  case "--version":
212
218
  case "-v":
213
219
  case "version": {
@@ -12,6 +12,8 @@ import { screenshot, startRecording, stopRecording } from "./mac-screen.mjs";
12
12
  import { refreshSshConfig, sshConfigBlock, writeSshConfig } from "./ssh-config.mjs";
13
13
 
14
14
  const WORKSPACE = "/Users/agent/workspace";
15
+ /** How long the CLI keeps trying to put a project on the drive before it gives up for now. */
16
+ const PLACE_BUDGET_MS = 120_000;
15
17
 
16
18
  const HELP = `Nomac cloud Mac sessions
17
19
  nomac start [--wait 300] [--max-duration 7200] [--idle-timeout 600] [--max-cost 5] [--json]
@@ -74,6 +76,8 @@ const HELP = `Nomac cloud Mac sessions
74
76
  start a screen video; nomac record stop saves it
75
77
  as nomac-recording.mp4 (or -o) with a share link
76
78
  nomac status [--json] state, cost so far and when it stops
79
+ nomac extend [MINUTES] [--json] keep the Mac running longer: its time limit goes up by
80
+ MINUTES (60 by default; 8 hours in all), its spend cap with it
77
81
  nomac attach [SESSION_ID] use the account's running Mac from this folder, when your
78
82
  agent started it elsewhere (MCP, the dashboard); start does it too
79
83
  nomac stop [--wait 60] [--json] stop the Mac: deletes it and its files, not ~/drive
@@ -333,7 +337,7 @@ export function statusSummary(session, image) {
333
337
  const a = session.activity ?? {};
334
338
  if (session.state === "ready") {
335
339
  const stops = [a.idle_deadline_at ? `when idle, at ${clockUtc(a.idle_deadline_at)}` : null, a.hard_deadline_at ? `at ${clockUtc(a.hard_deadline_at)} at the latest` : null].filter(Boolean);
336
- if (stops.length) lines.push(`Stops by itself ${stops.join(", or ")}. Stop now: nomac stop`);
340
+ if (stops.length) lines.push(`Stops by itself ${stops.join(", or ")}. Longer: nomac extend 60. Stop now: nomac stop`);
337
341
  }
338
342
  const d = session.drive;
339
343
  if (d && ["mounted", "saving", "saved", "unavailable", "not_saved"].includes(d.state)) {
@@ -424,18 +428,42 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
424
428
  // A folder another local folder claimed, or one made on the Mac some other way, is left alone.
425
429
  + 'if { [ -e "$claims/$d" ] && [ "$(cat "$claims/$d")" != "$ORIGIN" ]; } || { [ ! -e "$claims/$d" ] && [ -n "$(ls -A ~/drive/src/"$d" 2>/dev/null)" ]; }; then d="$NAME-$ORIGIN"; fi; '
426
430
  + 'printf %s "$ORIGIN" > "$claims/$d"; mkdir -p ~/drive/src/"$d"; cd ~/drive/src/"$d"; pwd';
427
- const job = await request("POST", `/api/v1/mac/sessions/${sessionId(local.id)}/jobs?wait=30`, { idempotencyKey: `cli-place-${local.id.slice(4, 24)}-${origin}`,
428
- body: { argv: ["/bin/bash", "-c", script], env: { NAME: name, ORIGIN: origin }, timeout_seconds: 30 } }).catch(() => null);
431
+ const job = await placeOnDrive(local, script, { NAME: name, ORIGIN: origin }, `cli-place-${local.id.slice(4, 24)}-${origin}`);
429
432
  const path = String(job?.stdout ?? "").trim();
430
433
  if (job?.exit_code === 0 && path.startsWith("/Users/agent/drive/src/") && !path.slice(23).includes("/")) {
431
434
  // The same folder on the drive as last time: what was synced there then is still there.
432
435
  if (local.previousDriveWorkspace?.path === path) store.saveBaseline(local.previousDriveWorkspace.baseline);
433
436
  Object.assign(local, { workspace: validateWorkspace(path), workspaceOnDrive: true });
437
+ } else {
438
+ // The drive itself refused (full, or not writable): this session works in ~/workspace, and says so.
439
+ const why = !job ? "the Mac refused the request" : job.exit_code === 0 ? "the Mac named another folder" : `exit code ${job.exit_code}${String(job.stderr ?? "").trim() ? `: ${String(job.stderr).trim().split("\n").pop().slice(0, 120)}` : ""}`;
440
+ console.error(`NoMac: this project could not be put on the drive (${why}), so this session works in ${WORKSPACE}, which is deleted when the Mac stops. Copy anything to keep into ~/drive, or check the drive: nomac drive.`);
434
441
  }
435
442
  }
436
443
  delete local.previousDriveWorkspace;
437
444
  local.workspaceDecided = true; store.save(local);
438
445
  }
446
+ /**
447
+ * Runs the placement script until it answers, retrying what is passing with the same request
448
+ * key: a Mac is often slow in its first minutes, and a lost or busy answer put whole sessions'
449
+ * work in ~/workspace, deleted at stop (October 2026). An answer that never comes within the
450
+ * budget fails the command, with the project still unplaced, so the next command tries again.
451
+ */
452
+ async function placeOnDrive(local, script, env, key) {
453
+ const started = now(), base = `/api/v1/mac/sessions/${sessionId(local.id)}/jobs`;
454
+ const passing = error => ["guest_busy", "host_unavailable", "outcome_unknown", "operation_unconfirmed", "bad_response", "operation_pending"].includes(error?.code) || error?.status === 429 || error?.status >= 500;
455
+ for (;;) {
456
+ try {
457
+ let job = await request("POST", `${base}?wait=30`, { idempotencyKey: key, body: { argv: ["/bin/bash", "-c", script], env, timeout_seconds: 30 } });
458
+ while (job?.state !== "exited" && job?.id && now() - started < PLACE_BUDGET_MS) { await sleep(2000); job = await request("GET", `${base}/${job.id}`); }
459
+ if (job?.state === "exited") return job;
460
+ } catch (error) {
461
+ if (!passing(error)) return null;
462
+ }
463
+ if (now() - started + 5000 > PLACE_BUDGET_MS) throw new CliError("The Mac did not answer in time.", `The Mac did not answer while this project was being put on its drive (~/drive/src); nothing was uploaded. It is often slow in its first minutes: run the same command again.`, undefined, "drive_placement_pending");
464
+ await sleep(5000);
465
+ }
466
+ }
439
467
  /**
440
468
  * A Mac started elsewhere (by an agent over MCP, the dashboard or another folder) took no key
441
469
  * from this folder: add this folder's own to its authorized keys with an ordinary command, so
@@ -482,6 +510,15 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
482
510
  if (positionals.length !== 1 || !["status", "info"].includes(positionals[0])) throw new CliError("Usage: nomac status [--json]");
483
511
  const local = localSession(); return output({ session: await refresh(local) });
484
512
  }
513
+ if (command === "extend") {
514
+ if (positionals.length > 1) throw new CliError("Usage: nomac extend [MINUTES] [--json]");
515
+ const minutes = integer(positionals[0], 60, 1, 480);
516
+ const local = localSession();
517
+ const result = await request("POST", `/api/v1/mac/sessions/${sessionId(local.id)}/extend`, { body: { minutes } });
518
+ saveView(local, result.session);
519
+ print(values.json ? JSON.stringify(result, null, 2) : result.message);
520
+ return result;
521
+ }
485
522
  if (command === "status") {
486
523
  if (positionals.length) throw new CliError("Usage: nomac status [--json]");
487
524
  const loaded = store.load();
@@ -616,7 +653,7 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
616
653
  if (command === "permit") {
617
654
  if (positionals.length < 2) throw new CliError("Usage: nomac permit APP PERMISSION... (or all), for example nomac permit com.example.App camera microphone");
618
655
  const id = sessionId(localSession().id);
619
- const job = await request("POST", `/api/v1/mac/sessions/${id}/jobs?wait=30`, { idempotencyKey: `cli-${randomUUID()}`, body: { argv: ["/opt/homebrew/bin/node", "/usr/local/bin/nomac-permit", ...positionals], timeout_seconds: 60 } });
656
+ const job = await request("POST", `/api/v1/mac/sessions/${id}/jobs?wait=30`, { idempotencyKey: `cli-${randomUUID()}`, body: { argv: ["/usr/local/bin/nomac-permit", ...positionals], timeout_seconds: 60 } });
620
657
  let answer;
621
658
  try { answer = JSON.parse(String(job.stdout ?? "").trim().split("\n").pop()); } catch { throw new CliError("The Mac gave no answer for the grant.", "Check that the Mac is ready (nomac status), then try again."); }
622
659
  if (!answer.ok) throw new CliError(answer.message, undefined, undefined, answer.error);
package/src/mac-tools.mjs CHANGED
@@ -25,6 +25,7 @@ export const MAC_TOOLS = [
25
25
  permissions: { type: "object", description: "By bundle ID: a list of permissions (camera, microphone, contacts, calendars, reminders, photos, desktop, documents, downloads and more) or \"all\"", additionalProperties: { anyOf: [{ type: "string", enum: ["all"] }, { type: "array", items: { type: "string" }, minItems: 1 }] } } }, additionalProperties: false } }, ["request_key"]), handler: async ({ request_key, ...body }) => text(await api("POST", "/api/v1/mac/sessions", { body, idempotencyKey: request_key })) },
26
26
  { 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")) },
27
27
  { 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) and account: the Mac's user, whether its login password is set (in $NOMAC_MAC_PASSWORD on the Mac), the access choices, and how to type and click (mac_input). 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))) },
28
+ { name: "extend_mac", description: "Keep a running Mac going longer: raises its time limit by minutes (default 60), up to 8 hours since it started, and its spend cap with it. Call it when an answer's stop_notice says the Mac stops soon at its time limit or spend cap, or before a build or test that would not finish in time. It adds no credit: when stop_notice's reason is credit, the human adds some (get_mac_checkout). The Mac still bills $0.80 an hour while it runs.", inputSchema: schema({ session_id: session, minutes: { type: "integer", minimum: 1, maximum: 480, default: 60, description: "Minutes to add to the time limit, 1-480. Default 60." } }, ["session_id"]), handler: async ({ session_id, minutes = 60 }) => text(await api("POST", `${base({ session_id })}/extend`, { body: { minutes } })) },
28
29
  { name: "get_mac_drive", description: "Read the account's persistent drive: a disk kept between sessions and mounted at ~/drive on every Mac (50 GB). What is there stays for the next session: keep projects in ~/drive/src for incremental builds; package and build caches (DerivedData, Swift packages, CocoaPods, npm) are kept there already. NoMac saves it when a Mac stops, with nothing to call. Shows the space used, the newest saved version, a save still in progress, recent versions, and the date it is deleted after 90 days without a Mac.", inputSchema: schema({}), handler: async () => text(await api("GET", "/api/v1/mac/drive")) },
29
30
  { name: "reset_mac_drive", description: "Empty the account's persistent drive: the next Mac starts with a new, empty ~/drive, and everything on the old drive is deleted, with no way back. Stop any running session first (stop_mac). For a single Mac without the drive, start_mac with access drive none instead.", inputSchema: schema({}), handler: async () => text(await api("DELETE", "/api/v1/mac/drive")) },
30
31
  { 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")) },
package/src/mcp.mjs CHANGED
@@ -19,6 +19,7 @@ const PROTOCOL_FALLBACK = "2025-06-18";
19
19
  /** How the tools fit together, sent once at initialize (the hosted server sends the same kind of text). */
20
20
  const INSTRUCTIONS = [
21
21
  "NoMac gives you a cloud Mac with Xcode, billed from prepaid credit ($0.80 an hour, by the minute).",
22
+ "Use it when work needs macOS that this machine does not have (Windows, Linux or a cloud sandbox): building, running or testing an iOS, iPadOS, watchOS, visionOS or macOS app (Swift, SwiftUI, React Native, Expo, Flutter, Capacitor), the iOS Simulator, Xcode, code signing, TestFlight or App Store submission.",
22
23
  "Typical flow: get_mac_credits, start_mac, poll get_mac_session until ready, put code there (exec_mac with git clone into ~/drive/src, 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
24
  "The account's drive at ~/drive (50 GB) is on every Mac and kept between sessions; everything outside it is deleted at stop. Clone and build in ~/drive/src/<repo>, so the next session rebuilds only what changed.",
24
25
  "When an answer says low_credit (under an hour of Mac time left), tell the human before a long build and offer its buy_url: a Mac stops when the credit runs out. For a refill, suggest 3 packs ($30, +5%).",
@@ -28,6 +29,8 @@ const INSTRUCTIONS = [
28
29
  "Write files with write_mac_file, not heredocs in exec_mac: a command is at most 64 KiB.",
29
30
  "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.",
30
31
  "The Mac is fully enabled for you: sudo needs no password, macOS asks for no administrator password, the privacy prompts and password dialogs it still shows are answered on the Mac within seconds, the screen never locks, and the Mac's login password is in $NOMAC_MAC_PASSWORD on it (for the login keychain or any prompt). nomac-permit APP camera (run with exec_mac) grants an app privacy permissions before it asks. To type or click as a person would, also on the lock screen and in password dialogs, call mac_input: its answer has a screenshot. When screenshot_mac reports screen_locked or a dialog, follow its next_step.",
32
+ "A Mac stops at its time limit (2 hours unless start_mac set max_duration_seconds), its spend cap, when the account's credit runs out, or after its idle timeout; everything outside ~/drive is deleted then. In its last 15 minutes every answer about it carries stop_notice, with when and why: call extend_mac to keep it running (time limit or spend cap), or ask the human to add credit (reason credit). Before a long build, check that it fits or extend first.",
33
+ "When an answer carries cli_update, this NoMac CLI is out of date: tell the human, who updates it with npm install -g @nomac/cli@latest (or nomac update) and restarts this MCP server.",
31
34
  "Always stop_mac when finished, after reading any output you need, so credit is not spent on an idle Mac.",
32
35
  ].join(" ");
33
36
 
@@ -370,7 +373,7 @@ export const TOOLS = [
370
373
  {
371
374
  name: "report_issue",
372
375
  description:
373
- "File an unknown/unfixable error with nomac support. For a Mac use session_id and optional job_id; for a build use ref_id (bld_…). Attaches account-scoped context and deduplicates repeats. An optional email requests a direct resolution update; replies are always available by calling with no description.",
376
+ "File an unknown/unfixable error with nomac support. For a Mac use session_id and optional job_id; for a build use ref_id (bld_…). Attaches account-scoped context and deduplicates repeats. An optional email requests a direct resolution update; replies are always available by calling with no description. kind feedback sends NoMac a note instead: what got in the way, failed or was missing.",
374
377
  inputSchema: {
375
378
  type: "object",
376
379
  properties: {
@@ -384,6 +387,11 @@ export const TOOLS = [
384
387
  maxLength: 320,
385
388
  description: "optional follow-up email for a direct resolution update",
386
389
  },
390
+ kind: {
391
+ type: "string",
392
+ enum: ["problem", "feedback"],
393
+ description: "problem (default): an error for support to answer. feedback: a note for NoMac about what got in the way, failed or was missing; it needs no answer. Leave out code, secrets and personal data.",
394
+ },
387
395
  },
388
396
  additionalProperties: false,
389
397
  },
@@ -0,0 +1,25 @@
1
+ // Installs one NoMac CLI version into its own folder, in the background (self-update.mjs):
2
+ // node self-update-worker.mjs <version> <dir>. It is ready only once it answers --version
3
+ // with that number; until then the running CLI keeps handling commands itself.
4
+ import { spawnSync } from "node:child_process";
5
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { installedBin, pruneInstalls } from "./self-update.mjs";
8
+
9
+ const [version, dir] = process.argv.slice(2);
10
+ if (!/^\d{1,4}\.\d{1,4}\.\d{1,4}$/.test(version ?? "") || !dir) process.exit(2);
11
+ const windows = process.platform === "win32";
12
+ try {
13
+ rmSync(dir, { recursive: true, force: true });
14
+ mkdirSync(dir, { recursive: true });
15
+ const npm = spawnSync(windows ? "npm.cmd" : "npm", ["install", "--prefix", dir, "--no-audit", "--no-fund", "--omit=dev", "--loglevel=error", `@nomac/cli@${version}`],
16
+ { stdio: "ignore", timeout: 10 * 60_000, shell: windows, windowsHide: true });
17
+ if (npm.status !== 0) throw new Error("npm install failed");
18
+ const check = spawnSync(process.execPath, [installedBin(version), "--version"], { encoding: "utf8", timeout: 30_000, env: { ...process.env, NOMAC_CLI_DELEGATED: "check", NOMAC_NO_AUTO_UPDATE: "1" }, windowsHide: true });
19
+ if (check.status !== 0 || !check.stdout.includes(version)) throw new Error("the installed CLI did not run");
20
+ writeFileSync(join(dir, ".nomac-ready"), `${new Date().toISOString()}\n`);
21
+ pruneInstalls(version);
22
+ } catch {
23
+ rmSync(dir, { recursive: true, force: true });
24
+ process.exit(1);
25
+ }
@@ -0,0 +1,112 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { CLI_VERSION } from "./client-info.mjs";
7
+
8
+ /**
9
+ * Keeping the CLI current. nomac.app names the latest CLI on every answer (x-nomac-cli-latest);
10
+ * this CLI remembers it. When a newer one is out, it installs it in the background into
11
+ * ~/.nomac/cli/<version> (never over the running files) and, from the next command, hands
12
+ * every command to it. A global npm install or an npx cache is never touched, so it works the
13
+ * same for both, and a failed install leaves this version running. On October 15, 2026 most
14
+ * accounts using the CLI ran 0.5 to 0.11, missing months of fixes, and nothing told them.
15
+ * NOMAC_NO_AUTO_UPDATE=1 turns it off; then it only says an update is out.
16
+ */
17
+ const DIR = () => join(homedir(), ".nomac");
18
+ const STATE = () => join(DIR(), "cli-update.json");
19
+ export const installDir = (version) => join(DIR(), "cli", version);
20
+ export const installedBin = (version) => join(installDir(version), "node_modules", "@nomac", "cli", "bin", "nomac.mjs");
21
+ /** Written once the version installed and answered --version with its own number. */
22
+ const marker = (version) => join(installDir(version), ".nomac-ready");
23
+ const VERSION = /^\d{1,4}\.\d{1,4}\.\d{1,4}$/;
24
+ /** How long a failed or unfinished background install waits before it is tried again. */
25
+ const RETRY_MS = 6 * 3600_000;
26
+ /** How often a person is told about an update this CLI cannot install itself. */
27
+ const NOTICE_MS = 24 * 3600_000;
28
+
29
+ /** Whether version a is newer than b (x.y.z). */
30
+ export function newer(a, b) {
31
+ const pa = String(a).split(".").map(Number), pb = String(b).split(".").map(Number);
32
+ for (let i = 0; i < 3; i++) if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) > (pb[i] ?? 0);
33
+ return false;
34
+ }
35
+ export const autoUpdateOff = () => process.env.NOMAC_NO_AUTO_UPDATE === "1";
36
+
37
+ export function readState() {
38
+ try { return JSON.parse(readFileSync(STATE(), "utf8")); } catch { return {}; }
39
+ }
40
+ function writeState(state) {
41
+ try { mkdirSync(DIR(), { recursive: true }); writeFileSync(STATE(), JSON.stringify(state, null, 2) + "\n"); } catch { /* best effort */ }
42
+ }
43
+
44
+ /** Records the latest version nomac.app named (from a response header). Never throws. */
45
+ export function rememberLatest(version) {
46
+ if (typeof version !== "string" || !VERSION.test(version)) return;
47
+ const state = readState();
48
+ if (state.latest !== version) writeState({ ...state, latest: version, seen_at: new Date().toISOString() });
49
+ }
50
+
51
+ /** The newest installed version this CLI should hand commands to, if any. */
52
+ export function delegateTarget(state = readState(), own = CLI_VERSION) {
53
+ if (autoUpdateOff() || process.env.NOMAC_CLI_DELEGATED) return undefined;
54
+ const ready = installedVersions().filter(v => newer(v, own) && existsSync(installedBin(v)));
55
+ return ready.sort((a, b) => (newer(a, b) ? -1 : 1))[0];
56
+ }
57
+ export function installedVersions() {
58
+ try { return readdirSync(join(DIR(), "cli")).filter(v => VERSION.test(v) && existsSync(marker(v))); } catch { return []; }
59
+ }
60
+
61
+ /**
62
+ * At startup: runs the command with a newer installed CLI when there is one (and returns its
63
+ * exit code), else starts the background install of a newer one nomac.app named. Returns
64
+ * undefined when this CLI should run the command itself.
65
+ */
66
+ export async function runNewerIfInstalled(argv) {
67
+ const state = readState(), target = delegateTarget(state);
68
+ if (target) {
69
+ // The original entry point: ssh-config's ProxyCommand and the like keep pointing at it,
70
+ // so they keep working after this installed version is replaced by a newer one.
71
+ const entry = process.env.NOMAC_CLI_ENTRY ?? process.argv[1];
72
+ const child = spawn(process.execPath, [installedBin(target), ...argv], { stdio: "inherit",
73
+ env: { ...process.env, NOMAC_CLI_DELEGATED: CLI_VERSION, ...(entry ? { NOMAC_CLI_ENTRY: entry } : {}) } });
74
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(signal, () => child.kill(signal));
75
+ return new Promise(resolve => {
76
+ child.on("error", () => resolve(undefined));
77
+ child.on("exit", (code, signal) => resolve(code ?? (signal ? 128 : 1)));
78
+ });
79
+ }
80
+ if (!autoUpdateOff() && !process.env.NOMAC_CLI_DELEGATED && state.latest && newer(state.latest, CLI_VERSION)) startInstall(state.latest, state);
81
+ return undefined;
82
+ }
83
+
84
+ /** Installs version in the background, at most once per RETRY_MS. */
85
+ export function startInstall(version, state = readState()) {
86
+ if (!VERSION.test(version) || existsSync(marker(version))) return false;
87
+ if (state.attempted === version && Date.now() - Date.parse(state.attempted_at ?? 0) < RETRY_MS) return false;
88
+ writeState({ ...state, attempted: version, attempted_at: new Date().toISOString() });
89
+ try {
90
+ const worker = fileURLToPath(new URL("./self-update-worker.mjs", import.meta.url));
91
+ spawn(process.execPath, [worker, version, installDir(version)], { detached: true, stdio: "ignore", windowsHide: true }).unref();
92
+ return true;
93
+ } catch { return false; }
94
+ }
95
+
96
+ /**
97
+ * One line for a person (never on stdout, never in `nomac mcp`): a newer CLI is out. Said when
98
+ * this CLI will not install it itself (NOMAC_NO_AUTO_UPDATE=1), at most once a day.
99
+ */
100
+ export function updateNotice(state = readState(), now = Date.now()) {
101
+ if (!state.latest || !newer(state.latest, CLI_VERSION)) return undefined;
102
+ if (!autoUpdateOff()) return undefined;
103
+ if (state.noticed_at && now - Date.parse(state.noticed_at) < NOTICE_MS) return undefined;
104
+ writeState({ ...state, noticed_at: new Date(now).toISOString() });
105
+ return `NoMac CLI ${state.latest} is out (this is ${CLI_VERSION}): npm install -g @nomac/cli@latest, or run nomac update.`;
106
+ }
107
+
108
+ /** Removes installed versions older than keep's predecessor (one older version stays for running commands). */
109
+ export function pruneInstalls(keep) {
110
+ const versions = installedVersions().filter(v => v !== keep).sort((a, b) => (newer(a, b) ? -1 : 1));
111
+ for (const old of versions.slice(1)) { try { rmSync(installDir(old), { recursive: true, force: true }); } catch { /* in use: next time */ } }
112
+ }
@@ -3,8 +3,8 @@ import { createHash } from "node:crypto";
3
3
  import { chmodSync, mkdirSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import { isAbsolute, join } from "node:path";
6
- import { fileURLToPath } from "node:url";
7
6
  import { CliError } from "./api.mjs";
7
+ import { cliEntry } from "./ssh-config.mjs";
8
8
 
9
9
  export const shellQuote = (value) => `'${String(value).replaceAll("'", "'\\''")}'`;
10
10
 
@@ -51,7 +51,7 @@ export function sshArgs(session, { batch = true } = {}) {
51
51
  }
52
52
  return [
53
53
  ...(session.mode === "hosted" ? ["-F", "/dev/null", "-o", `UserKnownHostsFile=${session.knownHosts}`, "-o", "GlobalKnownHostsFile=/dev/null",
54
- "-o", `HostKeyAlias=nomac-${session.id}`, "-o", `ProxyCommand=${[process.execPath, fileURLToPath(new URL("../bin/nomac.mjs", import.meta.url)), "tunnel", session.id].map(shellQuote).join(" ")}`] : []),
54
+ "-o", `HostKeyAlias=nomac-${session.id}`, "-o", `ProxyCommand=${[process.execPath, cliEntry(), "tunnel", session.id].map(shellQuote).join(" ")}`] : []),
55
55
  "-o", "StrictHostKeyChecking=yes", "-o", "ForwardAgent=no",
56
56
  "-o", "ConnectTimeout=15", "-o", "ServerAliveInterval=15", "-o", "ServerAliveCountMax=3",
57
57
  ...multiplexArgs(session),
@@ -5,6 +5,12 @@ import { join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { CliError } from "./api.mjs";
7
7
 
8
+ /**
9
+ * The nomac this person installed: a CLI that a newer installed one runs on its behalf names the
10
+ * original (self-update.mjs), so a ProxyCommand outlives the next update.
11
+ */
12
+ export const cliEntry = () => process.env.NOMAC_CLI_ENTRY ?? fileURLToPath(new URL("../bin/nomac.mjs", import.meta.url));
13
+
8
14
  /**
9
15
  * nomac ssh-config: a Host entry for this folder's Mac in the person's own SSH config, so any
10
16
  * SSH client reaches it by a name: VS Code and Cursor Remote-SSH, Claude Code's SSH sessions,
@@ -28,7 +34,7 @@ export function sshConfigPaths(home = homedir()) {
28
34
  }
29
35
 
30
36
  /** The Host entry for a ready hosted session (target, identity and known_hosts set by nomac's ready step). */
31
- export function sshConfigBlock(local, alias, project, { platform = process.platform, node = process.execPath, bin = fileURLToPath(new URL("../bin/nomac.mjs", import.meta.url)) } = {}) {
37
+ export function sshConfigBlock(local, alias, project, { platform = process.platform, node = process.execPath, bin = cliEntry() } = {}) {
32
38
  if (!validAlias(alias)) throw new CliError("--alias takes letters, digits, dots, underscores and hyphens, starting with a letter or digit.");
33
39
  if (!/^ses_[A-Za-z0-9_-]{1,80}$/.test(local.id ?? "") || !local.identity || !local.knownHosts) throw new CliError("This folder's Mac is not ready for SSH yet.", "Run nomac start (or nomac attach), then nomac ssh-config.");
34
40
  return [
@@ -0,0 +1,35 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { fileURLToPath } from "node:url";
3
+ import { CliError } from "./api.mjs";
4
+ import { CLI_VERSION } from "./client-info.mjs";
5
+ import { installDir, installedVersions, newer, rememberLatest } from "./self-update.mjs";
6
+
7
+ const HELP = `Usage: nomac update [--check]
8
+ Install the newest NoMac CLI now. nomac also does this by itself in the background
9
+ (NOMAC_NO_AUTO_UPDATE=1 turns that off) and uses it from the next command.
10
+ --check only say whether a newer one is out`;
11
+
12
+ /** The newest @nomac/cli on npm. */
13
+ async function latestOnNpm() {
14
+ const response = await fetch("https://registry.npmjs.org/@nomac%2fcli/latest", { headers: { accept: "application/json" } }).catch(() => undefined);
15
+ const version = response?.ok ? (await response.json().catch(() => ({}))).version : undefined;
16
+ if (typeof version !== "string" || !/^\d+\.\d+\.\d+$/.test(version)) throw new CliError("npm unreachable", "Could not reach npm to look up the newest NoMac CLI. Try again, or run npm install -g @nomac/cli@latest.");
17
+ return version;
18
+ }
19
+
20
+ export async function updateCommand(args) {
21
+ if (args.some(arg => arg === "--help" || arg === "-h")) return console.log(HELP);
22
+ if (args.some(arg => arg !== "--check")) throw new CliError("update usage", HELP);
23
+ const latest = await latestOnNpm();
24
+ rememberLatest(latest);
25
+ const running = process.env.NOMAC_CLI_DELEGATED ? `${CLI_VERSION} (installed by nomac; your own install is ${process.env.NOMAC_CLI_DELEGATED})` : CLI_VERSION;
26
+ if (!newer(latest, CLI_VERSION)) return console.log(`NoMac CLI ${running} is the newest.`);
27
+ if (args.includes("--check")) return console.log(`NoMac CLI ${latest} is out (this is ${running}). Install it: nomac update`);
28
+ if (!installedVersions().includes(latest)) {
29
+ console.error(`Installing NoMac CLI ${latest}…`);
30
+ const worker = fileURLToPath(new URL("./self-update-worker.mjs", import.meta.url));
31
+ const run = spawnSync(process.execPath, [worker, latest, installDir(latest)], { stdio: "inherit" });
32
+ if (run.status !== 0) throw new CliError("update failed", `Could not install NoMac CLI ${latest} with npm. Run npm install -g @nomac/cli@latest instead.`);
33
+ }
34
+ console.log(`NoMac CLI ${latest} is installed; nomac uses it from your next command. To update your own install too: npm install -g @nomac/cli@latest`);
35
+ }