@nomac/cli 0.3.0-beta.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/nomac.mjs +1 -1
- package/package.json +1 -1
- package/skill/SKILL.md +36 -7
- package/src/hosted-sessions.mjs +24 -4
- package/src/mac-tools.mjs +1 -1
- package/src/mcp.mjs +3 -1
package/bin/nomac.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { main } from "../src/cli.mjs";
|
|
|
3
3
|
|
|
4
4
|
main(process.argv.slice(2)).catch((err) => {
|
|
5
5
|
const args = process.argv.slice(2);
|
|
6
|
-
if (["start", "sync", "pull", "stop", "session", "transport"].includes(args[0]) && args.includes("--json")) {
|
|
6
|
+
if (["start", "sync", "pull", "stop", "session", "transport", "exec", "job", "output"].includes(args[0]) && args.includes("--json")) {
|
|
7
7
|
console.error(JSON.stringify({ error: { code: err?.code ?? "session_error", message: err?.friendly ?? err?.message ?? String(err), ...(err?.details ? { details: err.details } : {}) } }));
|
|
8
8
|
} else console.error(err?.friendly ?? err?.message ?? String(err));
|
|
9
9
|
process.exit(err?.exitCode ?? 1);
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -1,15 +1,44 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: nomac
|
|
3
3
|
description: >
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
any directory containing an Xcode project (SwiftUI vibe-code exports
|
|
9
|
-
included).
|
|
4
|
+
Use a full cloud Mac over SSH, HTTPS or MCP for macOS tools, Xcode and iOS
|
|
5
|
+
development. Start sessions, sync files, run commands, retrieve work and
|
|
6
|
+
stop. Also supports the separate iOS Builder product for TestFlight and
|
|
7
|
+
App Store publishing, signing, screenshots, metadata and review replies.
|
|
10
8
|
---
|
|
11
9
|
|
|
12
|
-
# nomac —
|
|
10
|
+
# nomac — a Mac for your agent
|
|
11
|
+
|
|
12
|
+
NoMac 2.0 provides a full macOS VM with administrator access. You choose the
|
|
13
|
+
commands and tools. A weekly Mac subscription is separate from Builder.
|
|
14
|
+
|
|
15
|
+
## Cloud Mac setup and workflow
|
|
16
|
+
|
|
17
|
+
1. Sign in at https://nomac.app/mac. The human chooses $5/week for 5 hours
|
|
18
|
+
or $20/week for 25 hours. Verify with `get_mac_credits`; a checkout URL
|
|
19
|
+
is not a payment. No Apple Developer account is needed for an unsigned build.
|
|
20
|
+
2. Install `npm install -g @nomac/cli`, run `nomac login`, and give the human
|
|
21
|
+
the printed device URL and code. MCP: `claude mcp add nomac -- npx @nomac/cli mcp`.
|
|
22
|
+
Hosted MCP: https://mcp.nomac.app/mcp with `Authorization: Bearer <nmk_key>`.
|
|
23
|
+
3. `nomac start --json`, wait until ready, `nomac sync .`, then `nomac ssh`.
|
|
24
|
+
For managed commands: `nomac exec --request-key stable-key-0001 -- COMMAND`,
|
|
25
|
+
then `nomac job JOB_ID` and `nomac output JOB_ID`. MCP equivalents are
|
|
26
|
+
`start_mac`, `exec_mac`, `get_mac_job`, `read_mac_output`, `stop_mac`.
|
|
27
|
+
4. Download work with `nomac pull ./build` (use `--from PATH` for another
|
|
28
|
+
remote directory), then `nomac stop --json`. Confirm `cleanup_confirmed`.
|
|
29
|
+
|
|
30
|
+
Stopping deletes files and logs. Download before stopping. SSH disconnects
|
|
31
|
+
do not stop the session. Ready time includes idle time; queue/setup are free.
|
|
32
|
+
Unused hours expire weekly. One session at a time; no automatic overage.
|
|
33
|
+
Keep the same request key and arguments after uncertain responses; inspect
|
|
34
|
+
the original job before retrying. Output retrieval failure is not job failure.
|
|
35
|
+
Managed jobs have a 600-second limit; SSH can run longer within session limits.
|
|
36
|
+
CLI sync skips symlinks and preserves remote extra files; use native SSH/rsync
|
|
37
|
+
when needed. GUI/simulator control and persistent disks are not currently offered.
|
|
38
|
+
Report failures using `report_issue` with `session_id`, optional `job_id` and
|
|
39
|
+
a description without secrets. Read https://nomac.app/llms.txt for full limits.
|
|
40
|
+
|
|
41
|
+
## Separate Builder product
|
|
13
42
|
|
|
14
43
|
nomac is the hands; you are the brain. It builds, signs, uploads, lints for
|
|
15
44
|
review-readiness, writes store metadata, manages pricing/products and submits for review —
|
package/src/hosted-sessions.mjs
CHANGED
|
@@ -45,6 +45,12 @@ function integer(value, fallback, min, max) {
|
|
|
45
45
|
if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value)) || Number(value) < min || Number(value) > max) throw new CliError("Invalid numeric option.");
|
|
46
46
|
return Number(value);
|
|
47
47
|
}
|
|
48
|
+
const validRequestKey = value => typeof value === "string" && /^[A-Za-z0-9_.:-]{8,128}$/.test(value);
|
|
49
|
+
// Only explicit pre-allocation rejections permit a fresh intent. A timeout,
|
|
50
|
+
// unreadable response or idempotency conflict may still own a real session.
|
|
51
|
+
const startRejections = new Set(["invalid_request", "invalid_public_key", "profile_unavailable", "session_cap_too_low",
|
|
52
|
+
"weekly_time_unavailable", "weekly_time_expired", "insufficient_compute_credit", "compute_account_blocked",
|
|
53
|
+
"session_limit_reached", "organization_not_found", "compute_unavailable", "unauthenticated", "invalid_origin"]);
|
|
48
54
|
export async function sessionAccess(id, request = api) {
|
|
49
55
|
sessionId(id);
|
|
50
56
|
const access = await request("POST", `/api/v1/mac/sessions/${id}/access`);
|
|
@@ -63,6 +69,8 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
63
69
|
catch (error) { throw new CliError(error.message); }
|
|
64
70
|
const { values, positionals } = parsed;
|
|
65
71
|
if (values.help) return print(HELP);
|
|
72
|
+
const wait = ["start", "stop"].includes(command) ? integer(values.wait, command === "start" ? 300 : 60, 0, command === "start" ? 600 : 300) : 0;
|
|
73
|
+
if (command === "start" && values["request-key"] !== undefined && !validRequestKey(values["request-key"])) throw new CliError("Use a request key of 8–128 letters, digits, underscores, periods, colons or hyphens.");
|
|
66
74
|
const output = result => {
|
|
67
75
|
const billing = result.session?.billing;
|
|
68
76
|
const usage = billing?.mode === "weekly_included"
|
|
@@ -124,6 +132,9 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
124
132
|
let local = store.load();
|
|
125
133
|
if (local && local.mode !== "hosted") throw new CliError("An existing Mac workspace is attached.");
|
|
126
134
|
const limits = { max_duration_seconds: integer(values["max-duration"], 7200, 60, 86400), idle_timeout_seconds: integer(values["idle-timeout"], 600, 60, 3600), spend_cap_microunits: budget(values["max-cost"]) };
|
|
135
|
+
// Older CLIs persisted invalid keys before contacting the API. Such a key
|
|
136
|
+
// cannot pass server validation and therefore cannot have allocated a VM.
|
|
137
|
+
if (local && !local.id && !validRequestKey(local.requestKey)) { local.state = "failed"; store.save(local); }
|
|
127
138
|
if (local && !["stopped", "failed"].includes(local.state)) {
|
|
128
139
|
for (const [flag, field] of [["max-duration", "max_duration_seconds"], ["idle-timeout", "idle_timeout_seconds"], ["max-cost", "spend_cap_microunits"]]) {
|
|
129
140
|
if (values[flag] !== undefined && limits[field] !== local.request.limits[field]) throw new CliError("This session already has different limits. Stop it and start a fresh session to change them.");
|
|
@@ -133,14 +144,23 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
133
144
|
if (!local || ["stopped", "failed"].includes(local.state)) {
|
|
134
145
|
const identity = join(store.directory, `id_ed25519_${randomUUID()}`);
|
|
135
146
|
await run("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-C", "nomac-session", "-f", identity]);
|
|
136
|
-
local = { mode: "hosted", state: "requesting", identity, requestKey: values["request-key"] ?? `cli-${randomUUID()}`, request: { profile: "standard", public_key: readFileSync(`${identity}.pub`, "utf8").trim(), limits } };
|
|
147
|
+
local = { mode: "hosted", state: "requesting", outcomeUnknown: false, identity, requestKey: values["request-key"] ?? `cli-${randomUUID()}`, request: { profile: "standard", public_key: readFileSync(`${identity}.pub`, "utf8").trim(), limits } };
|
|
137
148
|
store.save(local); store.saveBaseline({});
|
|
138
149
|
}
|
|
139
150
|
if (!local.id) {
|
|
140
|
-
const
|
|
151
|
+
const previouslyUncertain = local.outcomeUnknown !== false;
|
|
152
|
+
local.outcomeUnknown = true; store.save(local); // A crash after dispatch must reconcile this intent.
|
|
153
|
+
let result;
|
|
154
|
+
try { result = await request("POST", "/api/v1/mac/sessions", { idempotencyKey: local.requestKey, body: local.request }); }
|
|
155
|
+
catch (error) {
|
|
156
|
+
if (!previouslyUncertain && startRejections.has(error.code) && Number.isInteger(error.status) && error.status >= 400 && error.status <= 503) {
|
|
157
|
+
local.state = "failed"; local.rejection = error.code; store.save(local);
|
|
158
|
+
}
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
141
161
|
saveView(local, result.session);
|
|
142
162
|
}
|
|
143
|
-
const deadline = now() +
|
|
163
|
+
const deadline = now() + wait * 1000;
|
|
144
164
|
for (;;) {
|
|
145
165
|
await refresh(local);
|
|
146
166
|
if (!["queued", "provisioning"].includes(local.state) || now() >= deadline) break;
|
|
@@ -153,7 +173,7 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
153
173
|
if (command === "stop") {
|
|
154
174
|
if (positionals.length) throw new CliError("Usage: nomac stop [--wait SECONDS] [--json]");
|
|
155
175
|
saveView(local, (await request("DELETE", `/api/v1/mac/sessions/${sessionId(local.id)}`)).session);
|
|
156
|
-
const deadline = now() +
|
|
176
|
+
const deadline = now() + wait * 1000;
|
|
157
177
|
while (!["stopped", "failed"].includes(local.state) && now() < deadline) { await sleep(1000); await refresh(local); }
|
|
158
178
|
return output({ session: local.snapshot });
|
|
159
179
|
}
|
package/src/mac-tools.mjs
CHANGED
|
@@ -14,5 +14,5 @@ export const MAC_TOOLS = [
|
|
|
14
14
|
{ 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))) },
|
|
15
15
|
{ name: "exec_mac", description: "Run any argv command in your cloud Mac workspace. Returns a durable job ID; poll get_mac_job and read_mac_output. Reuse request_key and identical arguments after lost responses; never submit a new key blindly. Use bash -lc explicitly for shell syntax.", inputSchema: schema({ session_id: session, request_key: key, argv: { type: "array", items: { type: "string" }, minItems: 1 }, timeout_seconds: { type: "integer", minimum: 1, maximum: 600 } }, ["session_id", "request_key", "argv"]), handler: async ({ session_id, request_key, ...body }) => text(await api("POST", `${base({ session_id })}/jobs`, { body, idempotencyKey: request_key })) },
|
|
16
16
|
{ name: "get_mac_job", description: "Inspect the existing command without rerunning it. Retain its job ID until completion and retrieve output before stopping the VM.", inputSchema: schema({ session_id: session, job_id: job }, ["session_id", "job_id"]), handler: async args => text(await api("GET", `${base(args)}/jobs/${encodeURIComponent(args.job_id)}`)) },
|
|
17
|
-
{ name: "read_mac_output", description: "Read
|
|
17
|
+
{ name: "read_mac_output", description: "Read stdout/stderr as base64 chunks; resume from next_cursor. If truncated, the final response includes tail_base64 and its absolute tail_offset. Retrieve promptly: only recent completed jobs retain output. output_expired preserves the job receipt and never means rerun. Retrieve before stopping the VM.", inputSchema: schema({ session_id: session, job_id: job, cursor: { type: "integer", minimum: 0 }, stream: { type: "string", enum: ["stdout", "stderr"] } }, ["session_id", "job_id"]), handler: async args => text(await api("GET", `${base(args)}/jobs/${encodeURIComponent(args.job_id)}/output?${new URLSearchParams({ cursor: String(args.cursor ?? 0), stream: args.stream ?? "stdout" })}`)) },
|
|
18
18
|
];
|
package/src/mcp.mjs
CHANGED
|
@@ -363,12 +363,14 @@ export const TOOLS = [
|
|
|
363
363
|
{
|
|
364
364
|
name: "report_issue",
|
|
365
365
|
description:
|
|
366
|
-
"File an unknown/unfixable error with nomac support.
|
|
366
|
+
"File an unknown/unfixable error with nomac support. For a Mac use session_id and optional job_id; for a build use ref_id (bld_…). Attaches account-scoped context and deduplicates repeats. An optional email requests a direct resolution update; replies are always available by calling with no description.",
|
|
367
367
|
inputSchema: {
|
|
368
368
|
type: "object",
|
|
369
369
|
properties: {
|
|
370
370
|
description: { type: "string", description: "what went wrong + what you tried (≥10 chars). Omit to read existing tickets/replies." },
|
|
371
371
|
ref_id: { type: "string" },
|
|
372
|
+
session_id: { type: "string", pattern: "^ses_[A-Za-z0-9_-]{1,80}$" },
|
|
373
|
+
job_id: { type: "string", pattern: "^job_[a-f0-9]{40}$" },
|
|
372
374
|
email: {
|
|
373
375
|
type: "string",
|
|
374
376
|
format: "email",
|