@nomac/cli 0.15.0 → 0.16.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 +1 -1
- package/skill/SKILL.md +2 -1
- package/src/phone-tools.json +4 -0
- package/src/phone-tools.mjs +61 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nomac/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.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,8 @@ 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).
|
|
26
27
|
|
|
27
28
|
## Cloud Mac setup and workflow
|
|
28
29
|
|
package/src/phone-tools.json
CHANGED
|
@@ -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"
|
package/src/phone-tools.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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:
|
|
230
|
+
inputSchema: { ...inputSchema, properties: properties(name, inputSchema) },
|
|
174
231
|
handler: name === "phone" ? cloud : name === "phone_upload_link" ? uploadLink : simulatorTool(name, bareOf(name)),
|
|
175
232
|
}));
|