@nomac/cli 0.6.1 → 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 +1 -1
- package/skill/SKILL.md +9 -5
- package/src/cli.mjs +44 -8
- package/src/hosted-sessions.mjs +75 -11
- package/src/mac-fetch.mjs +2 -0
- package/src/mac-screen.mjs +32 -0
- package/src/mac-tools.mjs +32 -7
- package/src/mcp-args.mjs +64 -0
- package/src/mcp.mjs +20 -4
- package/src/session-ssh.mjs +3 -1
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -6,7 +6,7 @@ description: >
|
|
|
6
6
|
stop.
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
-
# nomac
|
|
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.
|
|
@@ -45,10 +45,14 @@ 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
|
-
|
|
51
|
-
|
|
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
|
|
52
56
|
is installed: Ruby with CocoaPods, xcbeautify, xcodegen, gh, Git LFS, jq.
|
|
53
57
|
Pipe xcodebuild through `xcbeautify`. Persistent disks are not currently offered.
|
|
54
58
|
Report failures using `report_issue` with `session_id`, optional `job_id` and
|
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:
|
|
@@ -28,6 +30,28 @@ Agents:
|
|
|
28
30
|
More: nomac start --help, nomac whoami, nomac start --host user@mac (a Mac you own)
|
|
29
31
|
Environment: NOMAC_API_KEY, NOMAC_API_URL`;
|
|
30
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
|
+
|
|
31
55
|
export async function main(argv) {
|
|
32
56
|
const [cmd, ...rest] = argv;
|
|
33
57
|
switch (cmd) {
|
|
@@ -49,7 +73,10 @@ export async function main(argv) {
|
|
|
49
73
|
}
|
|
50
74
|
case "status":
|
|
51
75
|
// A legacy build ID, or a folder that only ever built with the old pipeline, keeps build status.
|
|
52
|
-
if (/^bld_/.test(rest[0] ?? "") || (loadProjectState().last_build && !(await import("./session-store.mjs")).sessionStore().load()))
|
|
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
|
+
}
|
|
53
80
|
// falls through: everyone else sees their Mac
|
|
54
81
|
case "start":
|
|
55
82
|
case "exec":
|
|
@@ -58,6 +85,8 @@ export async function main(argv) {
|
|
|
58
85
|
case "sync":
|
|
59
86
|
case "ssh":
|
|
60
87
|
case "desktop":
|
|
88
|
+
case "screenshot":
|
|
89
|
+
case "record":
|
|
61
90
|
case "pull":
|
|
62
91
|
case "stop":
|
|
63
92
|
case "session": {
|
|
@@ -80,19 +109,26 @@ export async function main(argv) {
|
|
|
80
109
|
}
|
|
81
110
|
saveApiKey(key);
|
|
82
111
|
const me = await api("GET", "/api/v1/me");
|
|
83
|
-
console.log(`✅ logged in
|
|
112
|
+
console.log(`✅ logged in: org "${me.org.name}" (${me.org.id})`);
|
|
84
113
|
return;
|
|
85
114
|
}
|
|
86
115
|
const { deviceLogin } = await import("./login.mjs");
|
|
87
116
|
return deviceLogin();
|
|
88
117
|
}
|
|
89
118
|
case "push":
|
|
119
|
+
if (isHelp(rest)) return console.log(LEGACY_HELP.push);
|
|
120
|
+
onlyFlags(rest, [], "push");
|
|
90
121
|
return push();
|
|
91
122
|
case "build":
|
|
123
|
+
if (isHelp(rest)) return console.log(LEGACY_HELP.build);
|
|
124
|
+
onlyFlags(rest, ["--smoke", "--testflight", "--wait"], "build");
|
|
92
125
|
return build(rest);
|
|
93
126
|
case "logs":
|
|
94
|
-
|
|
127
|
+
if (isHelp(rest)) return console.log(LEGACY_HELP.logs);
|
|
128
|
+
return logs(buildIdArg(rest, "logs"));
|
|
95
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);
|
|
96
132
|
const { runMcpServer } = await import("./mcp.mjs");
|
|
97
133
|
return runMcpServer();
|
|
98
134
|
}
|
|
@@ -145,10 +181,10 @@ async function push() {
|
|
|
145
181
|
const icon = res.lint.grade === "green" ? "🟢" : res.lint.grade === "yellow" ? "🟡" : "🔴";
|
|
146
182
|
const n = res.lint.findings ?? 0;
|
|
147
183
|
console.log(
|
|
148
|
-
` review lint: ${icon} ${res.lint.grade}${n ? `
|
|
184
|
+
` review lint: ${icon} ${res.lint.grade}${n ? ` · ${n} finding${n === 1 ? "" : "s"}` : ""}`,
|
|
149
185
|
);
|
|
150
186
|
if (res.lint.grade === "red") {
|
|
151
|
-
console.log(" ⚠ red findings will block publish
|
|
187
|
+
console.log(" ⚠ red findings will block publish: fix them before `nomac build`/publish.");
|
|
152
188
|
}
|
|
153
189
|
}
|
|
154
190
|
if (res.warning) console.log(`⚠ ${res.warning}`);
|
|
@@ -158,7 +194,7 @@ async function push() {
|
|
|
158
194
|
async function build(rest) {
|
|
159
195
|
const state = loadProjectState();
|
|
160
196
|
if (!state.project_id) {
|
|
161
|
-
throw new CliError("no project", "No project here
|
|
197
|
+
throw new CliError("no project", "No project here. Run `nomac push` first.");
|
|
162
198
|
}
|
|
163
199
|
const smoke = rest.includes("--smoke");
|
|
164
200
|
const wait = rest.includes("--testflight") || rest.includes("--wait");
|
|
@@ -204,8 +240,8 @@ async function waitForBuild(id) {
|
|
|
204
240
|
if (b.state === "ready") {
|
|
205
241
|
console.log(
|
|
206
242
|
b.workflow === "smoke"
|
|
207
|
-
? "\n🎉 smoke build passed
|
|
208
|
-
: "\n🎉 build ready
|
|
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.",
|
|
209
245
|
);
|
|
210
246
|
return;
|
|
211
247
|
}
|
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
|
}
|
|
@@ -111,7 +121,7 @@ async function waitForCommand(id, base, started, waitSeconds, { request, transpo
|
|
|
111
121
|
for (let cursor = 0; ;) {
|
|
112
122
|
const part = await hostedRequest(id, "GET", `${base}/jobs/${job.id}/output?cursor=${cursor}&stream=${stream}`, {}, { request, transport, cache });
|
|
113
123
|
parts.push(Buffer.from(part.data_base64 ?? "", "base64"));
|
|
114
|
-
if (part.eof && part.tail_base64) parts.push(
|
|
124
|
+
if (part.eof && part.tail_base64) parts.push(...tailAfter(part));
|
|
115
125
|
if (part.eof || part.next_cursor === cursor) break;
|
|
116
126
|
cursor = part.next_cursor;
|
|
117
127
|
}
|
|
@@ -125,6 +135,24 @@ async function waitForCommand(id, base, started, waitSeconds, { request, transpo
|
|
|
125
135
|
return job;
|
|
126
136
|
}
|
|
127
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);
|
|
128
156
|
const usd = microunits => `$${(Number(microunits ?? 0) / 1_000_000).toFixed(2)}`;
|
|
129
157
|
const clockUtc = ms => new Date(ms).toISOString().slice(11, 16) + " UTC";
|
|
130
158
|
/** One readable paragraph for nomac status. */
|
|
@@ -162,7 +190,10 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
162
190
|
const usage = billing?.mode === "weekly_included"
|
|
163
191
|
? `Included time used: ${billing.used_seconds} seconds. No extra charge.`
|
|
164
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`;
|
|
165
|
-
|
|
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}`);
|
|
166
197
|
return result;
|
|
167
198
|
};
|
|
168
199
|
const saveView = (local, view) => {
|
|
@@ -207,9 +238,11 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
207
238
|
if (!positionals.length || !argv.includes("--")) throw new CliError("Use nomac exec -- command args");
|
|
208
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.");
|
|
209
240
|
// A person running a command once needs no key; scripts pass one so a lost response can be retried safely.
|
|
210
|
-
//
|
|
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.
|
|
211
243
|
const waitFor = integer(values.wait, 30, 0, 600);
|
|
212
|
-
|
|
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 } };
|
|
213
246
|
} else {
|
|
214
247
|
if (positionals.length !== 1 || !/^job_[a-f0-9]{40}$/.test(positionals[0])) throw new CliError("Supply the existing job ID.");
|
|
215
248
|
path = `${base}/jobs/${positionals[0]}`;
|
|
@@ -219,11 +252,42 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
219
252
|
path += `/output?cursor=${cursor}&stream=${stream}`;
|
|
220
253
|
}
|
|
221
254
|
}
|
|
222
|
-
const cache = {}, result = await hostedRequest(local.id, method, path, params, { request, transport, cache });
|
|
223
255
|
const waiting = command === "exec" || (command === "job" && values.wait !== undefined);
|
|
256
|
+
const cache = {}, result = await hostedRequest(local.id, method, path, params, { request, transport, cache }).catch(error => {
|
|
257
|
+
// The job exists; a busy Mac only delays its report.
|
|
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
|
+
}
|
|
263
|
+
throw error;
|
|
264
|
+
});
|
|
224
265
|
if (!waiting) return output(result);
|
|
225
266
|
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) });
|
|
226
267
|
}
|
|
268
|
+
if (command === "screenshot") {
|
|
269
|
+
if (positionals.length) throw new CliError("Usage: nomac screenshot [--simulator] [-o FILE]");
|
|
270
|
+
const shot = await screenshot(localSession().id, { target: values.simulator ? "simulator" : "screen", saveTo: values.output ?? "nomac-screenshot.jpg" });
|
|
271
|
+
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` : ""})`);
|
|
272
|
+
return shot;
|
|
273
|
+
}
|
|
274
|
+
if (command === "record") {
|
|
275
|
+
const local = localSession();
|
|
276
|
+
if (positionals[0] === "stop" && positionals.length === 1) {
|
|
277
|
+
const saved = await stopRecording(local.id, { saveTo: values.output ?? "nomac-recording.mp4" });
|
|
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}`);
|
|
284
|
+
return saved;
|
|
285
|
+
}
|
|
286
|
+
if (positionals.length) throw new CliError("Usage: nomac record [--simulator] [--max SECONDS], then nomac record stop [-o FILE]");
|
|
287
|
+
const started = await startRecording(local.id, { target: values.simulator ? "simulator" : "screen", maxSeconds: values.max === undefined ? undefined : integer(values.max, 300, 1, 1800) });
|
|
288
|
+
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`);
|
|
289
|
+
return started;
|
|
290
|
+
}
|
|
227
291
|
if (command === "desktop") {
|
|
228
292
|
if (positionals.length) throw new CliError("Usage: nomac desktop [--no-open] [--json]");
|
|
229
293
|
return desktop(await ready(localSession()), { values, print });
|
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"',
|
|
@@ -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 = 3 * 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}$" };
|
|
@@ -29,7 +30,7 @@ export const MAC_TOOLS = [
|
|
|
29
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." });
|
|
30
31
|
} },
|
|
31
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))) },
|
|
32
|
-
{ 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
|
|
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 })) },
|
|
33
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 }) => {
|
|
34
35
|
const given = [local_path, content, content_base64].filter(v => v !== undefined).length;
|
|
35
36
|
if (given !== 1) return text({ error: { code: "invalid_request", message: "Give exactly one of local_path, content or content_base64." } });
|
|
@@ -41,18 +42,42 @@ 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
|
|
45
|
-
const
|
|
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
|
+
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) {
|
|
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
|
+
}
|
|
49
62
|
const target = resolve(save_to); mkdirSync(dirname(target), { recursive: true }); writeFileSync(target, bytes);
|
|
50
63
|
return text({ ...meta, saved_to: target, saved_bytes: bytes.length });
|
|
51
64
|
}
|
|
52
|
-
const mimeType = !file.truncated
|
|
53
|
-
if (
|
|
54
|
-
|
|
65
|
+
const mimeType = !file.truncated ? head(file) : undefined;
|
|
66
|
+
if (mimeType) return { content: [{ type: "image", data: file.content_base64, mimeType }, { type: "text", text: JSON.stringify({ ...meta, mime_type: mimeType }, null, 2) }] };
|
|
67
|
+
// Part of a binary file as base64 would only fill the context; say how to get it instead.
|
|
68
|
+
if (file.truncated && max_bytes === undefined && file.encoding === "base64") return text({ ...meta, next_step: head(file)
|
|
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."
|
|
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." });
|
|
71
|
+
return text(file);
|
|
55
72
|
} },
|
|
73
|
+
{ 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 }) => {
|
|
74
|
+
const shot = await screenshot(session_id, { target, saveTo: save_to });
|
|
75
|
+
if (save_to) return text(shot);
|
|
76
|
+
const { image_base64: data, ...meta } = shot;
|
|
77
|
+
return { content: [{ type: "image", data, mimeType: meta.mime_type }, { type: "text", text: JSON.stringify(meta, null, 2) }] };
|
|
78
|
+
} },
|
|
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 })) },
|
|
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 })) },
|
|
56
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 })) },
|
|
57
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 }) => {
|
|
58
83
|
if (local_path !== undefined) return text(await uploadThroughLink(session_id, local_path, { path, executable: Boolean(executable), extract }));
|
package/src/mcp-args.mjs
ADDED
|
@@ -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
|
|
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
|
|
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:
|
|
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) {
|
package/src/session-ssh.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|