@nomac/cli 0.6.0 → 0.7.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 +10 -3
- package/src/cli.mjs +4 -0
- package/src/hosted-sessions.mjs +46 -11
- package/src/mac-screen.mjs +32 -0
- package/src/mac-tools.mjs +24 -6
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -45,9 +45,16 @@ Keep the same request key and arguments after uncertain responses; inspect
|
|
|
45
45
|
the original job before retrying. Output retrieval failure is not job failure.
|
|
46
46
|
Managed jobs have a 600-second limit; SSH can run longer within session limits.
|
|
47
47
|
CLI sync skips symlinks and preserves remote extra files; use native SSH/rsync
|
|
48
|
-
when needed.
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
when needed. See the screen with `nomac screenshot` (saves
|
|
49
|
+
`nomac-screenshot.jpg`; `--simulator` for the iOS Simulator) or MCP
|
|
50
|
+
`screenshot_mac`, which returns an image you can look at. Record a video for
|
|
51
|
+
the human with `nomac record`, do the work, then `nomac record stop` (saves the
|
|
52
|
+
MP4 and prints a share link); MCP `start_mac_recording`, `stop_mac_recording`.
|
|
53
|
+
Commands drive any app with AppleScript without a permission prompt; drive the
|
|
54
|
+
Simulator with simctl, and give the human `open_mac_desktop` when a screen
|
|
55
|
+
needs a person. `get_mac_session` (and `nomac status --json`) lists what
|
|
56
|
+
is installed: Ruby with CocoaPods, xcbeautify, xcodegen, gh, Git LFS, jq.
|
|
57
|
+
Pipe xcodebuild through `xcbeautify`. Persistent disks are not currently offered.
|
|
51
58
|
Report failures using `report_issue` with `session_id`, optional `job_id` and
|
|
52
59
|
a description without secrets. Read https://nomac.app/llms.txt for full limits.
|
|
53
60
|
|
package/src/cli.mjs
CHANGED
|
@@ -20,6 +20,8 @@ Use it:
|
|
|
20
20
|
nomac sync . upload changed project files
|
|
21
21
|
nomac pull ./build download a folder from the Mac
|
|
22
22
|
nomac desktop the Mac's desktop in your browser
|
|
23
|
+
nomac screenshot save the Mac's screen as nomac-screenshot.jpg
|
|
24
|
+
nomac record record a video; nomac record stop saves it with a share link
|
|
23
25
|
nomac status state, cost so far and when it stops
|
|
24
26
|
|
|
25
27
|
Agents:
|
|
@@ -58,6 +60,8 @@ export async function main(argv) {
|
|
|
58
60
|
case "sync":
|
|
59
61
|
case "ssh":
|
|
60
62
|
case "desktop":
|
|
63
|
+
case "screenshot":
|
|
64
|
+
case "record":
|
|
61
65
|
case "pull":
|
|
62
66
|
case "stop":
|
|
63
67
|
case "session": {
|
package/src/hosted-sessions.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { forwardingArg, runProcess, sshArgs, workspaceCommand } from "./session-
|
|
|
8
8
|
import { pullProject, syncProject } from "./session-transfer.mjs";
|
|
9
9
|
import { macRequest, sessionId, transportOptions } from "./mac-transport.mjs";
|
|
10
10
|
import { desktop, DESKTOP_HELP } from "./desktop.mjs";
|
|
11
|
+
import { screenshot, startRecording, stopRecording } from "./mac-screen.mjs";
|
|
11
12
|
|
|
12
13
|
const HELP = `Nomac cloud Mac sessions
|
|
13
14
|
nomac start [--wait 300] [--max-duration 7200] [--idle-timeout 600] [--max-cost 5] [--json]
|
|
@@ -23,6 +24,12 @@ const HELP = `Nomac cloud Mac sessions
|
|
|
23
24
|
nomac job JOB_ID [--wait 600] wait for a command that outlasted --wait
|
|
24
25
|
nomac output JOB_ID [--cursor 0] [--stream stdout|stderr]
|
|
25
26
|
nomac pull ./output [--from PATH] retrieve files before stopping
|
|
27
|
+
nomac screenshot [--simulator] [-o FILE]
|
|
28
|
+
save the screen (or the Simulator) as a JPEG,
|
|
29
|
+
nomac-screenshot.jpg unless -o
|
|
30
|
+
nomac record [--simulator] [--max 300]
|
|
31
|
+
start a screen video; nomac record stop saves it
|
|
32
|
+
as nomac-recording.mp4 (or -o) with a share link
|
|
26
33
|
nomac status [--json] state, cost so far and when it stops
|
|
27
34
|
nomac stop [--wait 60] [--json] stop the Mac: deletes it and its files
|
|
28
35
|
|
|
@@ -39,7 +46,7 @@ const options = {
|
|
|
39
46
|
"max-cost": { type: "string" }, "request-key": { type: "string" }, timeout: { type: "string" },
|
|
40
47
|
overwrite: { type: "boolean" }, "dry-run": { type: "boolean" }, from: { type: "string" },
|
|
41
48
|
forward: { type: "string", multiple: true }, cursor: { type: "string" }, stream: { type: "string" },
|
|
42
|
-
"no-open": { type: "boolean" },
|
|
49
|
+
"no-open": { type: "boolean" }, simulator: { type: "boolean" }, output: { type: "string", short: "o" }, max: { type: "string" },
|
|
43
50
|
};
|
|
44
51
|
function budget(value = "5") {
|
|
45
52
|
if (!/^(?:0|[1-9][0-9]{0,5})(?:\.[0-9]{1,6})?$/.test(value)) throw new CliError("--max-cost must be a positive USD amount, for example 5 or 2.50.");
|
|
@@ -83,7 +90,7 @@ async function waitForCommand(id, base, started, waitSeconds, { request, transpo
|
|
|
83
90
|
let job = started;
|
|
84
91
|
const deadline = now() + waitSeconds * 1000;
|
|
85
92
|
const begun = now();
|
|
86
|
-
let announced = false;
|
|
93
|
+
let announced = false, busy = false;
|
|
87
94
|
for (let polls = 0; waitSeconds && !["exited", "unknown"].includes(job.state) && now() < deadline; polls++) {
|
|
88
95
|
// A slow command can outlive the shell waiting for it (agent shells time out); leave a way back.
|
|
89
96
|
if (!json && !announced && now() - begun >= NOTICE_MS && deadline - now() > 1000) {
|
|
@@ -91,16 +98,19 @@ async function waitForCommand(id, base, started, waitSeconds, { request, transpo
|
|
|
91
98
|
stderr(`nomac: ${job.id} is still running; waiting up to ${waitSeconds} s. If interrupted: nomac job ${job.id} --wait 600\n`);
|
|
92
99
|
}
|
|
93
100
|
await sleep(Math.min(POLL_MS[polls] ?? 1000, Math.max(0, deadline - now())));
|
|
94
|
-
|
|
101
|
+
// A saturated Mac (a booting Simulator) answers late; the command still runs, so keep waiting.
|
|
102
|
+
try { job = await hostedRequest(id, "GET", `${base}/jobs/${job.id}`, {}, { request, transport, cache }); busy = false; }
|
|
103
|
+
catch (error) { if (error?.code !== "guest_busy") throw error; busy = true; }
|
|
95
104
|
}
|
|
96
105
|
if (job.state !== "exited") {
|
|
97
106
|
if (!waitSeconds) { print(JSON.stringify(job, null, 2)); return job; }
|
|
98
107
|
const unknown = job.state === "unknown";
|
|
108
|
+
const busyNote = busy ? "The Mac is busy (for example, the iOS Simulator is booting) and answers slowly. " : "";
|
|
99
109
|
const next = unknown ? `The Mac could not confirm whether ${job.id} finished. Inspect it with nomac job ${job.id}; do not rerun it.`
|
|
100
|
-
:
|
|
101
|
-
if (json) print(JSON.stringify({ ...job, next_step: next }, null, 2));
|
|
110
|
+
: `${busyNote}Still running after ${waitSeconds} s; it keeps running on the Mac. Wait for it: nomac job ${job.id} --wait 600. Next time wait up front: nomac exec --wait 600 -- command. Over 10 minutes: nomac ssh -- command. Do not rerun it.`;
|
|
111
|
+
if (json) print(JSON.stringify({ ...job, ...(busy ? { busy: true } : {}), next_step: next }, null, 2));
|
|
102
112
|
else if (unknown) stderr(`nomac: ${next}\n`);
|
|
103
|
-
else stderr(`nomac: still running after ${waitSeconds} s (${job.id}); it keeps running on the Mac.\n wait for it: nomac job ${job.id} --wait 600\n next time, wait longer: nomac exec --wait 600 -- command\n over 10 minutes: nomac ssh -- command\n`);
|
|
113
|
+
else stderr(`nomac: ${busy ? "the Mac is busy (for example, the iOS Simulator is booting); " : ""}still running after ${waitSeconds} s (${job.id}); it keeps running on the Mac.\n wait for it: nomac job ${job.id} --wait 600\n next time, wait longer: nomac exec --wait 600 -- command\n over 10 minutes: nomac ssh -- command\n`);
|
|
104
114
|
exitWith(unknown ? 1 : STILL_RUNNING);
|
|
105
115
|
return job;
|
|
106
116
|
}
|
|
@@ -128,8 +138,9 @@ async function waitForCommand(id, base, started, waitSeconds, { request, transpo
|
|
|
128
138
|
const usd = microunits => `$${(Number(microunits ?? 0) / 1_000_000).toFixed(2)}`;
|
|
129
139
|
const clockUtc = ms => new Date(ms).toISOString().slice(11, 16) + " UTC";
|
|
130
140
|
/** One readable paragraph for nomac status. */
|
|
131
|
-
export function statusSummary(session) {
|
|
141
|
+
export function statusSummary(session, image) {
|
|
132
142
|
const lines = [`${session.id}: ${session.state}`];
|
|
143
|
+
if (image?.macos) lines.push(`Mac: macOS ${image.macos.split(" ")[0]}, Xcode ${image.xcode}${(image.simulator_runtimes ?? []).map(r => `, ${r} Simulator`).join("")}`);
|
|
133
144
|
if (["ready", "provisioning", "queued"].includes(session.state)) lines.push(`Cost so far: ${usd(session.cost?.accrued_microunits)} ($0.80/hour, billed by the minute while ready). Credit left: nomac credits`);
|
|
134
145
|
const a = session.activity ?? {};
|
|
135
146
|
if (session.state === "ready") {
|
|
@@ -193,9 +204,10 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
193
204
|
if (positionals.length) throw new CliError("Usage: nomac status [--json]");
|
|
194
205
|
const loaded = store.load();
|
|
195
206
|
if (!loaded) { print(values.json ? JSON.stringify({ session: null }) : "No Mac in this folder. Start one: nomac start"); return null; }
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
print(
|
|
207
|
+
const local = localSession(), result = await request("GET", `/api/v1/mac/sessions/${sessionId(local.id)}`);
|
|
208
|
+
const session = saveView(local, result.session);
|
|
209
|
+
if (values.json) { print(JSON.stringify({ session, ...(result.image ? { image: result.image } : {}) }, null, 2)); return session; }
|
|
210
|
+
print(statusSummary(session, result.image));
|
|
199
211
|
return session;
|
|
200
212
|
}
|
|
201
213
|
if (["exec", "job", "output"].includes(command)) {
|
|
@@ -217,11 +229,34 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
217
229
|
path += `/output?cursor=${cursor}&stream=${stream}`;
|
|
218
230
|
}
|
|
219
231
|
}
|
|
220
|
-
const cache = {}, result = await hostedRequest(local.id, method, path, params, { request, transport, cache });
|
|
221
232
|
const waiting = command === "exec" || (command === "job" && values.wait !== undefined);
|
|
233
|
+
const cache = {}, result = await hostedRequest(local.id, method, path, params, { request, transport, cache }).catch(error => {
|
|
234
|
+
// The job exists; a busy Mac only delays its report.
|
|
235
|
+
if (command === "job" && waiting && error?.code === "guest_busy") return { id: positionals[0], state: "running" };
|
|
236
|
+
throw error;
|
|
237
|
+
});
|
|
222
238
|
if (!waiting) return output(result);
|
|
223
239
|
return waitForCommand(local.id, base, result, integer(values.wait, 30, 0, 600), { request, transport, cache, print, stdout, stderr, exitWith, sleep, now, json: Boolean(values.json) });
|
|
224
240
|
}
|
|
241
|
+
if (command === "screenshot") {
|
|
242
|
+
if (positionals.length) throw new CliError("Usage: nomac screenshot [--simulator] [-o FILE]");
|
|
243
|
+
const shot = await screenshot(localSession().id, { target: values.simulator ? "simulator" : "screen", saveTo: values.output ?? "nomac-screenshot.jpg" });
|
|
244
|
+
print(values.json ? JSON.stringify(shot, null, 2) : `Saved ${shot.saved_to} (${shot.width}x${shot.height}${shot.front_app ? `, ${shot.front_app} in front` : ""})`);
|
|
245
|
+
return shot;
|
|
246
|
+
}
|
|
247
|
+
if (command === "record") {
|
|
248
|
+
const local = localSession();
|
|
249
|
+
if (positionals[0] === "stop" && positionals.length === 1) {
|
|
250
|
+
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)`);
|
|
253
|
+
return saved;
|
|
254
|
+
}
|
|
255
|
+
if (positionals.length) throw new CliError("Usage: nomac record [--simulator] [--max SECONDS], then nomac record stop [-o FILE]");
|
|
256
|
+
const started = await startRecording(local.id, { target: values.simulator ? "simulator" : "screen", maxSeconds: values.max === undefined ? undefined : integer(values.max, 300, 1, 1800) });
|
|
257
|
+
print(values.json ? JSON.stringify(started, null, 2) : `Recording the ${values.simulator ? "Simulator" : "screen"}; it stops by itself after ${started.max_seconds} s.\nSave it: nomac record stop`);
|
|
258
|
+
return started;
|
|
259
|
+
}
|
|
225
260
|
if (command === "desktop") {
|
|
226
261
|
if (positionals.length) throw new CliError("Usage: nomac desktop [--no-open] [--json]");
|
|
227
262
|
return desktop(await ready(localSession()), { values, print });
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { api } from "./api.mjs";
|
|
4
|
+
import { checkLink, saveFromLink } from "./mac-links.mjs";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Seeing and recording a session's screen through the NoMac API: a screenshot
|
|
8
|
+
* comes back as base64 JPEG; a recording is started, then stopped, which saves
|
|
9
|
+
* the MP4 on the Mac and makes a download link for it.
|
|
10
|
+
*/
|
|
11
|
+
const base = id => `/api/v1/mac/sessions/${encodeURIComponent(id)}`;
|
|
12
|
+
|
|
13
|
+
export async function screenshot(sessionId, { target = "screen", saveTo } = {}) {
|
|
14
|
+
const shot = await api("POST", `${base(sessionId)}/screenshot`, { body: { target } });
|
|
15
|
+
if (!saveTo) return shot;
|
|
16
|
+
const { image_base64: data, ...meta } = shot, file = resolve(saveTo);
|
|
17
|
+
mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, Buffer.from(data, "base64"));
|
|
18
|
+
return { ...meta, saved_to: file };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function startRecording(sessionId, { target = "screen", maxSeconds, path } = {}) {
|
|
22
|
+
return api("POST", `${base(sessionId)}/recording`, { body: { target, ...(maxSeconds ? { max_seconds: maxSeconds } : {}), ...(path ? { path } : {}) } });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Stops the recording; with saveTo, also downloads the video to this machine once its link is ready. */
|
|
26
|
+
export async function stopRecording(sessionId, { saveTo } = {}) {
|
|
27
|
+
const result = await api("DELETE", `${base(sessionId)}/recording`);
|
|
28
|
+
if (!saveTo) return result;
|
|
29
|
+
let link = result.link;
|
|
30
|
+
for (let checks = 0; link.state === "copying" && checks < 12; checks++) link = await checkLink(link.url, 50);
|
|
31
|
+
return { ...result, link, ...(link.state === "ready" ? await saveFromLink(link, saveTo) : {}) };
|
|
32
|
+
}
|
package/src/mac-tools.mjs
CHANGED
|
@@ -5,7 +5,8 @@ import { readFileSync, statSync, writeFileSync, mkdirSync } from "node:fs";
|
|
|
5
5
|
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
10
|
const text = value => ({ content: [{ type: "text", text: JSON.stringify(value, null, 2) }] });
|
|
10
11
|
const session = { type: "string", pattern: "^ses_[A-Za-z0-9_-]{1,80}$" };
|
|
11
12
|
const job = { type: "string", pattern: "^job_[a-f0-9]{40}$" };
|
|
@@ -41,18 +42,35 @@ export const MAC_TOOLS = [
|
|
|
41
42
|
}
|
|
42
43
|
return text(await api("POST", `${base({ session_id })}/files`, { body: { path, ...(content !== undefined ? { content } : { content_base64 }), executable: Boolean(executable) } }));
|
|
43
44
|
} },
|
|
44
|
-
{ 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
|
|
45
|
-
const
|
|
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 }) => {
|
|
46
|
+
const read = max => api("GET", `${base({ session_id })}/files?${new URLSearchParams({ path, ...(max ? { max_bytes: String(max) } : {}) })}`);
|
|
47
|
+
const head = file => file.encoding === "base64" ? imageType(Buffer.from(String(file.content_base64 ?? "").slice(0, 32), "base64")) : undefined;
|
|
48
|
+
// An image file, or one being saved here, is read whole in one go; others start with the default 1 MiB.
|
|
49
|
+
let file = await read(max_bytes ?? (save_to || /\.(png|jpe?g|gif|webp)$/i.test(path) ? MAX_READ_BYTES : undefined));
|
|
50
|
+
// Screenshots are often over the default 1 MiB: read an image whole rather than return part of it as base64.
|
|
51
|
+
if (file.truncated && max_bytes === undefined && head(file) && file.size <= MAX_READ_BYTES) file = await read(MAX_READ_BYTES);
|
|
46
52
|
const bytes = file.encoding === "utf8" ? Buffer.from(file.content ?? "", "utf8") : Buffer.from(file.content_base64 ?? "", "base64");
|
|
47
53
|
const meta = Object.fromEntries(Object.entries(file).filter(([k]) => !["content", "content_base64"].includes(k)));
|
|
48
54
|
if (save_to) {
|
|
49
55
|
const target = resolve(save_to); mkdirSync(dirname(target), { recursive: true }); writeFileSync(target, bytes);
|
|
50
56
|
return text({ ...meta, saved_to: target, saved_bytes: bytes.length });
|
|
51
57
|
}
|
|
52
|
-
const mimeType = !file.truncated
|
|
53
|
-
if (
|
|
54
|
-
|
|
58
|
+
const mimeType = !file.truncated ? head(file) : undefined;
|
|
59
|
+
if (mimeType) return { content: [{ type: "image", data: file.content_base64, mimeType }, { type: "text", text: JSON.stringify({ ...meta, mime_type: mimeType }, null, 2) }] };
|
|
60
|
+
// Part of a binary file as base64 would only fill the context; say how to get it instead.
|
|
61
|
+
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."
|
|
63
|
+
: "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
|
+
return text(file);
|
|
55
65
|
} },
|
|
66
|
+
{ name: "screenshot_mac", description: "See the Mac's screen: returns a screenshot as an image you can look at. target screen (default) is the whole macOS desktop at 1440x900, where image pixels are screen coordinates; target simulator is the booted iOS Simulator's display. Use it to check an app's UI, Xcode or system dialogs and error messages, or what the human sees in open_mac_desktop. Also returns front_app (the app in front) and path, the JPEG on the Mac. save_to writes the image to this machine instead of returning it.", inputSchema: schema({ session_id: session, target: { type: "string", enum: ["screen", "simulator"] }, save_to: { type: "string" } }, ["session_id"]), handler: async ({ session_id, target, save_to }) => {
|
|
67
|
+
const shot = await screenshot(session_id, { target, saveTo: save_to });
|
|
68
|
+
if (save_to) return text(shot);
|
|
69
|
+
const { image_base64: data, ...meta } = shot;
|
|
70
|
+
return { content: [{ type: "image", data, mimeType: meta.mime_type }, { type: "text", text: JSON.stringify(meta, null, 2) }] };
|
|
71
|
+
} },
|
|
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 })) },
|
|
73
|
+
{ 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 })) },
|
|
56
74
|
{ 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 })) },
|
|
57
75
|
{ 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 }) => {
|
|
58
76
|
if (local_path !== undefined) return text(await uploadThroughLink(session_id, local_path, { path, executable: Boolean(executable), extract }));
|