@nomac/cli 0.15.0 → 0.17.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.15.0",
3
+ "version": "0.17.0",
4
4
  "description": "A real cloud Mac for AI agents: Xcode, the iOS Simulator, signing, TestFlight and App Store publishing, by the minute, over MCP, SSH and HTTPS.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
package/skill/SKILL.md CHANGED
@@ -22,7 +22,10 @@ alone; or `npx simulator-use cloud start` from a terminal
22
22
  (https://nomac.app/simulator). `phone_app_run` builds from a git link, or from
23
23
  the project here: in `nomac mcp`, `local_path` sends the folder (only what
24
24
  changed); on the hosted server, `phone_upload_link` answers one command that
25
- sends it.
25
+ sends it. `phone_ui_look` with `screenshot_file` saves the screen as a
26
+ full-size PNG (in `nomac mcp`, a file here; hosted, a download link). For App
27
+ Store screenshots start the phone with `device: "iphone-pro-max"` (6.9-inch,
28
+ 1320x2868); `upload_screenshots` takes the hosted links.
26
29
 
27
30
  ## Cloud Mac setup and workflow
28
31
 
package/src/mcp.mjs CHANGED
@@ -162,7 +162,7 @@ export const TOOLS = [
162
162
  {
163
163
  name: "upload_screenshots",
164
164
  description:
165
- "Start a screenshot replacement for one display type and iOS version. PNGs are validated and prior images are backed up. Reuse request_key and the original body when retrying. Poll get_screenshot_upload until complete, rolled_back or needs_attention; accepted means still in progress. images: [{filename, link}] with a create_mac_download_link url for each PNG on the Mac (any size; preferred), or [{filename, data: base64 PNG}] for small images (a request is capped near 4.5 MB in all).",
165
+ "Start a screenshot replacement for one display type and iOS version. PNGs are validated and prior images are backed up. Reuse request_key and the original body when retrying. Poll get_screenshot_upload until complete, rolled_back or needs_attention; accepted means still in progress. images: [{filename, link}] with a create_mac_download_link url for each PNG on the Mac, or a cloud iPhone's screenshot_file link (phone_ui_look; an iphone-pro-max phone gives APP_IPHONE_67's 1320x2868), any size, preferred; or [{filename, data: base64 PNG}] for small images (a request is capped near 4.5 MB in all).",
166
166
  inputSchema: {
167
167
  type: "object",
168
168
  properties: {
@@ -160,6 +160,10 @@
160
160
  "description": "Also return a JPEG sized to points",
161
161
  "type": "boolean"
162
162
  },
163
+ "screenshot_file": {
164
+ "description": "Also save the screen at full resolution as a PNG, answered as a download link (open it before the phone stops)",
165
+ "type": "boolean"
166
+ },
163
167
  "full": {
164
168
  "description": "Also save the whole accessibility tree to a JSON file",
165
169
  "type": "boolean"
@@ -531,11 +535,12 @@
531
535
  "pattern": "^[A-Za-z0-9._-]{1,64}$"
532
536
  },
533
537
  "device": {
534
- "description": "start: iphone (iPhone 17 Pro), ipad (iPad Pro 11-inch (M5)); the first is quickest",
538
+ "description": "start: iphone (iPhone 17 Pro), ipad (iPad Pro 11-inch (M5)), iphone-pro-max (iPhone 17 Pro Max); the first is quickest",
535
539
  "type": "string",
536
540
  "enum": [
537
541
  "iphone",
538
- "ipad"
542
+ "ipad",
543
+ "iphone-pro-max"
539
544
  ]
540
545
  },
541
546
  "appearance": {
@@ -4,8 +4,12 @@
4
4
  // each call goes through the REST API with the account's key, as the hosted tools do (apps/web/lib/phone-mcp.ts).
5
5
 
6
6
  import { randomBytes } from "node:crypto";
7
- import { readFileSync } from "node:fs";
8
- import { join } from "node:path";
7
+ import { createWriteStream, readFileSync } from "node:fs";
8
+ import { mkdtemp, rm, unlink } from "node:fs/promises";
9
+ import { tmpdir } from "node:os";
10
+ import { basename, join } from "node:path";
11
+ import { Readable } from "node:stream";
12
+ import { pipeline } from "node:stream/promises";
9
13
  import { api } from "./api.mjs";
10
14
  import { projectRoot, syncApp, syncFolder } from "./phone-sync.mjs";
11
15
 
@@ -47,6 +51,51 @@ function phoneReply(answer, tool, key) {
47
51
  return { content, ...(result.isError ? { isError: true } : {}) };
48
52
  }
49
53
 
54
+ /** A file link in a phone's answer (fileLinkUrl in apps/web/lib/phones.ts), never the sentence's period. */
55
+ const FILE_LINK = /https?:\/\/[^\s"'<>]+?\/api\/v1\/simulator\/files\/([A-Za-z0-9_-]+)\.[A-Za-z0-9_-]+/g;
56
+ const KEEP_BYTES = 200 * 1024 * 1024;
57
+
58
+ /** The file's name as its link carries it (the payload's n), safe for a folder. */
59
+ function linkName(payload) {
60
+ try {
61
+ const name = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")).n;
62
+ return typeof name === "string" ? basename(name).replace(/[^A-Za-z0-9._-]/g, "_").replace(/^\.+/, "") || undefined : undefined;
63
+ } catch {
64
+ return undefined;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * The files phone_ui_look saved on the phone (screenshot_file's PNG, full's tree), saved on this machine as
70
+ * simulator-use's own tool saves them: each link in the answer becomes the file's path. One that cannot be saved
71
+ * stays a link.
72
+ */
73
+ async function keepFiles(reply) {
74
+ const links = new Map();
75
+ for (const c of reply.content) if (c.type === "text") for (const [link, payload] of c.text.matchAll(FILE_LINK)) links.set(link, payload);
76
+ if (!links.size) return reply;
77
+ const dir = await mkdtemp(join(tmpdir(), "nomac-phone-"));
78
+ const saved = new Map();
79
+ for (const [link, payload] of links) {
80
+ const file = join(dir, linkName(payload) ?? `file-${saved.size + 1}`);
81
+ try {
82
+ const response = await fetch(link, { signal: AbortSignal.timeout(120_000) });
83
+ if (!response.ok || !response.body) throw new Error(`HTTP ${response.status}`);
84
+ let size = 0;
85
+ await pipeline(Readable.fromWeb(response.body).map(chunk => {
86
+ size += chunk.length;
87
+ if (size > KEEP_BYTES) throw new Error("larger than a screenshot or a tree");
88
+ return chunk;
89
+ }), createWriteStream(file));
90
+ saved.set(link, file);
91
+ } catch {
92
+ await unlink(file).catch(() => undefined);
93
+ }
94
+ }
95
+ if (!saved.size) await rm(dir, { recursive: true, force: true });
96
+ return { ...reply, content: reply.content.map(c => (c.type === "text" ? { ...c, text: [...saved].reduce((text, [link, file]) => text.split(link).join(file), c.text) } : c)) };
97
+ }
98
+
50
99
  /** Fetches a git repository or unpacks an upload into the phone's workspace, as the hosted tools do before a run. */
51
100
  async function place(phone, input, args, tool, key, fits) {
52
101
  const { git, ref, secret, upload, name } = input;
@@ -95,7 +144,8 @@ const simulatorTool = (listed, bare) => async input => {
95
144
  const placed = await place(phone, { git, ref, secret, upload, name }, args, listed, key, fits);
96
145
  if (placed) return placed;
97
146
  if ((git || upload || sent) && !fits(1)) return asText({ state: "running", request_key: key, ...(sent ? { sent } : {}), next: `The project is on the phone. Call ${listed} again with the same arguments and request_key ${key} to run it${sent ? " (only what changed is sent again)" : ""}.` });
98
- const reply = phoneReply(await api("POST", `${phones}/${phone}/tools/${bare}`, { body: args, idempotencyKey: key }), listed, key);
147
+ let reply = phoneReply(await api("POST", `${phones}/${phone}/tools/${bare}`, { body: args, idempotencyKey: key }), listed, key);
148
+ if (bare === "ui_look" && (args.screenshot_file || args.full) && !reply.isError) reply = await keepFiles(reply);
99
149
  if (!sent) return reply;
100
150
  // What was sent comes first, as the local tool's note does; after an error, which stays the answer's first part.
101
151
  return { ...reply, content: reply.isError ? [...reply.content, { type: "text", text: sent }] : [{ type: "text", text: sent }, ...reply.content] };
@@ -167,9 +217,16 @@ const LOCAL_PATH = {
167
217
  phone_tests_run: "Instead of git or upload: the project's folder on this machine (or its .xcodeproj or .xcworkspace), sent to the phone first, only the files it lacks; path is then a folder inside it",
168
218
  phone_app_control: "install: a .app built for the Simulator on this machine, sent to the phone first",
169
219
  };
220
+ /** What phone_ui_look saves comes to this machine, as a file. */
221
+ const SAVED_HERE = { screenshot_file: "Also save the screen at full resolution to a PNG file on this machine; the answer gives its path" };
222
+ const properties = (name, inputSchema) => ({
223
+ ...inputSchema.properties,
224
+ ...(name === "phone_ui_look" ? Object.fromEntries(Object.entries(SAVED_HERE).map(([key, description]) => [key, { ...inputSchema.properties[key], description }])) : {}),
225
+ ...(LOCAL_PATH[name] ? { local_path: { type: "string", minLength: 1, maxLength: 4096, description: LOCAL_PATH[name] } } : {}),
226
+ });
170
227
  export const PHONE_TOOLS = LISTED.map(({ name, description, inputSchema }) => ({
171
228
  name,
172
229
  description: LOCAL_PATH[name] ? `${description} Here, local_path sends ${name === "phone_app_control" ? "a .app" : "a project folder"} from this machine in the same call.` : description,
173
- inputSchema: LOCAL_PATH[name] ? { ...inputSchema, properties: { ...inputSchema.properties, local_path: { type: "string", minLength: 1, maxLength: 4096, description: LOCAL_PATH[name] } } } : inputSchema,
230
+ inputSchema: { ...inputSchema, properties: properties(name, inputSchema) },
174
231
  handler: name === "phone" ? cloud : name === "phone_upload_link" ? uploadLink : simulatorTool(name, bareOf(name)),
175
232
  }));