@nomac/cli 0.4.0 → 0.5.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/package.json +2 -2
- package/skill/SKILL.md +11 -5
- package/src/cli.mjs +2 -1
- package/src/hosted-sessions.mjs +6 -5
- package/src/mac-credits.mjs +69 -7
- package/src/mac-tools.mjs +8 -3
- package/src/mcp.mjs +1 -6
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nomac/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Cloud Mac sessions over SSH and HTTPS, plus iOS builds and publishing
|
|
3
|
+
"version": "0.5.1",
|
|
4
|
+
"description": "Cloud Mac sessions over SSH and HTTPS, plus iOS builds and publishing — built for agents.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
package/skill/SKILL.md
CHANGED
|
@@ -10,13 +10,17 @@ description: >
|
|
|
10
10
|
# nomac — a Mac for your agent
|
|
11
11
|
|
|
12
12
|
NoMac 2.0 provides a full macOS VM with administrator access. You choose the
|
|
13
|
-
commands and tools.
|
|
13
|
+
commands and tools. Mac time is prepaid credit, separate from Builder.
|
|
14
14
|
|
|
15
15
|
## Cloud Mac setup and workflow
|
|
16
16
|
|
|
17
|
-
1.
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
1. Check credit with `nomac credits --json` or `get_mac_credits`. Mac time is
|
|
18
|
+
$0.80 an hour, billed by the minute; $10 buys 12.5 hours; credit never
|
|
19
|
+
expires. Too little? Run `nomac credits buy --packs N --open` or call
|
|
20
|
+
`get_mac_checkout` (packs 1-10). Tell the human a payment page opened, or
|
|
21
|
+
give them the printed link, and ask them to pay. Never enter payment
|
|
22
|
+
details yourself. Poll credits until the balance appears; a checkout URL is
|
|
23
|
+
not a payment. No Apple Developer account is needed for an unsigned build.
|
|
20
24
|
2. Install `npm install -g @nomac/cli`, run `nomac login`, and give the human
|
|
21
25
|
the printed device URL and code. MCP: `claude mcp add nomac -- npx @nomac/cli mcp`.
|
|
22
26
|
Hosted MCP: https://mcp.nomac.app/mcp with `Authorization: Bearer <nmk_key>`.
|
|
@@ -29,7 +33,9 @@ commands and tools. A weekly Mac subscription is separate from Builder.
|
|
|
29
33
|
|
|
30
34
|
Stopping deletes files and logs. Download before stopping. SSH disconnects
|
|
31
35
|
do not stop the session. Ready time includes idle time; queue/setup are free.
|
|
32
|
-
|
|
36
|
+
One-minute minimum, then by the minute. One session at a time; no overage.
|
|
37
|
+
If start returns `insufficient_credit`, buy credit as in step 1, then retry
|
|
38
|
+
with the same request key.
|
|
33
39
|
Keep the same request key and arguments after uncertain responses; inspect
|
|
34
40
|
the original job before retrying. Output retrieval failure is not job failure.
|
|
35
41
|
Managed jobs have a 600-second limit; SSH can run longer within session limits.
|
package/src/cli.mjs
CHANGED
|
@@ -7,7 +7,8 @@ import { uploadSourceSnapshot } from "./upload.mjs";
|
|
|
7
7
|
const HELP = `nomac — Mac workspaces for agents / iOS app publishing
|
|
8
8
|
|
|
9
9
|
Nomac 2.0 cloud Macs:
|
|
10
|
-
nomac credits
|
|
10
|
+
nomac credits Mac credit left ($0.80/hour, by the minute)
|
|
11
|
+
nomac credits buy --packs N payment link for $10 packs, opened in the browser
|
|
11
12
|
nomac start allocate or reconnect to your Mac
|
|
12
13
|
nomac exec --request-key KEY -- command args
|
|
13
14
|
nomac job JOB_ID inspect a submitted command
|
package/src/hosted-sessions.mjs
CHANGED
|
@@ -22,8 +22,9 @@ const HELP = `Nomac cloud Mac sessions
|
|
|
22
22
|
|
|
23
23
|
Login once with nomac login. The CLI creates a local SSH key; it sends only the
|
|
24
24
|
public key. Sessions use the port-443 gateway and verified guest host keys.
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
Credit is used at $0.80 an hour, billed by the minute, while the VM is ready,
|
|
26
|
+
including between commands. Sessions run 2 hours by default (--max-duration,
|
|
27
|
+
up to 8 hours) and spend at most $5 (--max-cost). No credit? nomac credits buy. Stop removes the VM and its files; pull first.
|
|
27
28
|
A wait timeout leaves the session attached: inspect status or run stop.
|
|
28
29
|
Use nomac start --host user@mac to connect an existing Mac instead.`;
|
|
29
30
|
const options = {
|
|
@@ -49,7 +50,7 @@ const validRequestKey = value => typeof value === "string" && /^[A-Za-z0-9_.:-]{
|
|
|
49
50
|
// Only explicit pre-allocation rejections permit a fresh intent. A timeout,
|
|
50
51
|
// unreadable response or idempotency conflict may still own a real session.
|
|
51
52
|
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
|
+
"weekly_time_unavailable", "weekly_time_expired", "insufficient_compute_credit", "insufficient_credit", "compute_account_blocked",
|
|
53
54
|
"session_limit_reached", "organization_not_found", "compute_unavailable", "unauthenticated", "invalid_origin"]);
|
|
54
55
|
export async function sessionAccess(id, request = api) {
|
|
55
56
|
sessionId(id);
|
|
@@ -75,7 +76,7 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
75
76
|
const billing = result.session?.billing;
|
|
76
77
|
const usage = billing?.mode === "weekly_included"
|
|
77
78
|
? `Included time used: ${billing.used_seconds} seconds. No extra charge.`
|
|
78
|
-
: `Cost:
|
|
79
|
+
: `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`;
|
|
79
80
|
print(values.json || !["start", "stop"].includes(command) ? JSON.stringify(result, null, 2) : `${result.session.id}: ${result.session.state}\n${result.session.state === "ready" ? "Next: nomac sync . → nomac ssh" : "Check: nomac session status --json"}\n${usage}`);
|
|
80
81
|
return result;
|
|
81
82
|
};
|
|
@@ -131,7 +132,7 @@ export async function hostedSessionCommand(command, argv, { store = sessionStore
|
|
|
131
132
|
if (positionals.length) throw new CliError("Usage: nomac start [--wait SECONDS] [--json]");
|
|
132
133
|
let local = store.load();
|
|
133
134
|
if (local && local.mode !== "hosted") throw new CliError("An existing Mac workspace is attached.");
|
|
134
|
-
const limits = { max_duration_seconds: integer(values["max-duration"], 7200, 60,
|
|
135
|
+
const limits = { max_duration_seconds: integer(values["max-duration"], 7200, 60, 28800), idle_timeout_seconds: integer(values["idle-timeout"], 600, 60, 3600), spend_cap_microunits: budget(values["max-cost"]) };
|
|
135
136
|
// Older CLIs persisted invalid keys before contacting the API. Such a key
|
|
136
137
|
// cannot pass server validation and therefore cannot have allocated a VM.
|
|
137
138
|
if (local && !local.id && !validRequestKey(local.requestKey)) { local.state = "failed"; store.save(local); }
|
package/src/mac-credits.mjs
CHANGED
|
@@ -1,10 +1,72 @@
|
|
|
1
1
|
import { parseArgs } from "node:util";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
2
4
|
import { api, CliError } from "./api.mjs";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
|
|
6
|
+
const HELP = `nomac credits [--json]
|
|
7
|
+
Show your Mac credit: dollars left and how long a Mac can run on it.
|
|
8
|
+
|
|
9
|
+
nomac credits buy [--packs N] [--open] [--request-key KEY] [--json]
|
|
10
|
+
Get a payment link for the human. $10 packs, 1 to 10 per purchase;
|
|
11
|
+
each pack is 12.5 hours. $0.80 an hour, billed by the minute, never expires.
|
|
12
|
+
--open opens the link in this machine's browser (default when run by a person).
|
|
13
|
+
Reuse --request-key after a lost response; a new key is a new purchase.
|
|
14
|
+
|
|
15
|
+
Agents: run \`nomac credits buy --packs N --open\`, tell the human to pay in the
|
|
16
|
+
browser tab that opened (or give them the printed link), then poll
|
|
17
|
+
\`nomac credits --json\` until spendable_seconds covers the work.`;
|
|
18
|
+
|
|
19
|
+
const usd = microunits => `$${(Number(microunits) / 1_000_000).toFixed(2)}`;
|
|
20
|
+
const clock = seconds => {
|
|
21
|
+
const minutes = Math.floor(seconds / 60);
|
|
22
|
+
return minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, "0")}m`;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Best effort: an agent without a desktop still has the printed link. */
|
|
26
|
+
export function openUrl(url, { platform = process.platform, run = spawn } = {}) {
|
|
27
|
+
const [command, args] = platform === "darwin" ? ["open", [url]] : platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
|
|
28
|
+
try {
|
|
29
|
+
const child = run(command, args, { stdio: "ignore", detached: true });
|
|
30
|
+
child.on?.("error", () => {});
|
|
31
|
+
child.unref?.();
|
|
32
|
+
return true;
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function creditsCommand(argv, { request = api, print = console.log, open = openUrl, interactive = Boolean(process.stdout.isTTY) } = {}) {
|
|
39
|
+
const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: {
|
|
40
|
+
packs: { type: "string" }, plan: { type: "string" }, open: { type: "boolean" }, "no-open": { type: "boolean" },
|
|
41
|
+
"request-key": { type: "string" }, json: { type: "boolean" }, help: { type: "boolean" } } });
|
|
42
|
+
if (values.help) return print(HELP);
|
|
43
|
+
// `--plan` is the retired weekly-plan spelling; it now buys one pack.
|
|
44
|
+
const buying = positionals[0] === "buy" || values.plan !== undefined || values.packs !== undefined;
|
|
45
|
+
if (positionals.length > (positionals[0] === "buy" ? 1 : 0)) throw new CliError("Unexpected credits argument. Use `nomac credits` or `nomac credits buy --packs N`.");
|
|
46
|
+
|
|
47
|
+
if (!buying) {
|
|
48
|
+
const { credits, purchases } = await request("GET", "/api/v1/mac/credits");
|
|
49
|
+
if (values.json) return print(JSON.stringify({ credits, purchases }, null, 2));
|
|
50
|
+
const lines = [`Mac credit: ${usd(credits.spendable_microunits)} (${clock(credits.spendable_seconds)} of Mac time at $0.80/hour, billed by the minute)`];
|
|
51
|
+
if (BigInt(credits.reserved_microunits) > 0n) lines.push(`Held for a running session: ${usd(credits.reserved_microunits)}`);
|
|
52
|
+
if (credits.spending_blocked) lines.push("Mac usage is paused. Contact support@nomac.app.");
|
|
53
|
+
else if (credits.spendable_seconds < 60) lines.push("No credit left. Buy some: nomac credits buy --packs 1");
|
|
54
|
+
return print(lines.join("\n"));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const packs = values.packs === undefined ? 1 : Number(values.packs);
|
|
58
|
+
if (!Number.isInteger(packs) || packs < 1 || packs > 10) throw new CliError("--packs must be a whole number from 1 to 10 ($10 each).");
|
|
59
|
+
const key = values["request-key"] ?? `cli-${randomUUID()}`;
|
|
60
|
+
if (!/^[A-Za-z0-9_.:-]{8,128}$/.test(key)) throw new CliError("--request-key must be 8-128 letters, digits or . _ : -");
|
|
61
|
+
const result = await request("POST", "/api/v1/mac/credits/checkout", { idempotencyKey: key, body: { packs } });
|
|
62
|
+
const shouldOpen = result.action === "open_checkout_url" && result.url && !values["no-open"] && (values.open || interactive);
|
|
63
|
+
const opened = shouldOpen ? open(result.url) : false;
|
|
64
|
+
if (values.json) return print(JSON.stringify({ ...result, opened_in_browser: opened }, null, 2));
|
|
65
|
+
if (result.action !== "open_checkout_url") return print(`${result.message}\nrequest key: ${result.request_key}`);
|
|
66
|
+
return print([
|
|
67
|
+
`${packs} × $10 = $${result.amount_usd} for ${result.credit_hours} hours of Mac time.`,
|
|
68
|
+
opened ? "Opened the payment page in your browser. Pay there to add the credit." : "Open this link to pay:",
|
|
69
|
+
result.url,
|
|
70
|
+
`Credit appears within a minute of payment; check with \`nomac credits\`. Request key (reuse if this response was lost): ${result.request_key}`,
|
|
71
|
+
].join("\n"));
|
|
10
72
|
}
|
package/src/mac-tools.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { api } from "./api.mjs";
|
|
2
|
+
import { openUrl } from "./mac-credits.mjs";
|
|
2
3
|
const text = value => ({ content: [{ type: "text", text: JSON.stringify(value, null, 2) }] });
|
|
3
4
|
const session = { type: "string", pattern: "^ses_[A-Za-z0-9_-]{1,80}$" };
|
|
4
5
|
const job = { type: "string", pattern: "^job_[a-f0-9]{40}$" };
|
|
@@ -6,9 +7,13 @@ const key = { type: "string", pattern: "^[A-Za-z0-9_.:-]{8,128}$" };
|
|
|
6
7
|
const schema = (properties, required = []) => ({ type: "object", properties, required, additionalProperties: false });
|
|
7
8
|
const base = args => `/api/v1/mac/sessions/${encodeURIComponent(args.session_id)}`;
|
|
8
9
|
export const MAC_TOOLS = [
|
|
9
|
-
{ name: "get_mac_checkout", description: "
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
{ name: "get_mac_checkout", description: "Get a payment link for Mac credit and open it in the human's browser on this machine. Credit is $0.80 an hour, billed by the minute, sold in $10 packs (12.5 hours each), 1 to 10 packs per purchase, and never expires. Call this when get_mac_credits shows too little credit or start_mac returns insufficient_credit. Then tell the human a payment page opened (or give them the returned url if opened_in_browser is false) and ask them to pay. Never enter payment details yourself. Poll get_mac_credits until the balance appears, then start the Mac. Reuse request_key after a lost response; a new key is a new purchase.", inputSchema: schema({ packs: { type: "integer", minimum: 1, maximum: 10, default: 1, description: "Number of $10 packs, 1-10. Pick from the human's budget or expected hours." }, open: { type: "boolean", default: true, description: "Open the link in this machine's browser." }, request_key: key }, ["request_key"]), handler: async ({ packs = 1, open = true, request_key }) => {
|
|
11
|
+
const result = await api("POST", "/api/v1/mac/credits/checkout", { body: { packs }, idempotencyKey: request_key });
|
|
12
|
+
const opened = open && result.action === "open_checkout_url" && result.url ? openUrl(result.url) : false;
|
|
13
|
+
return text({ ...result, opened_in_browser: opened });
|
|
14
|
+
} },
|
|
15
|
+
{ name: "get_mac_credits", description: "Read the prepaid Mac credit balance: spendable dollars, spendable_seconds (how long a Mac can run on it), amounts held by a running session, pricing and recent purchases. Credit never expires. If spendable_seconds is below what the task needs, call get_mac_checkout.", inputSchema: schema({}), handler: async () => text(await api("GET", "/api/v1/mac/credits")) },
|
|
16
|
+
{ name: "start_mac", description: "Allocate a full cloud Mac VM, billed from prepaid credit at $0.80 an hour by the minute. If it returns insufficient_credit, call get_mac_checkout, have the human pay, then retry with the same request_key. Supply a stable request_key; SSH public_key is optional for HTTPS-only agents; reuse both after a lost response. Inspect the returned offer and limits. Poll get_mac_session until ready. No project pipeline is imposed; stop_mac deletes the VM and files.", inputSchema: schema({ public_key: { type: "string" }, request_key: key, limits: { type: "object", properties: { max_duration_seconds: { type: "integer", minimum: 60, maximum: 28800 }, idle_timeout_seconds: { type: "integer", minimum: 60, maximum: 3600 }, spend_cap_microunits: { type: "string", pattern: "^[1-9][0-9]{0,17}$" } }, additionalProperties: false } }, ["request_key"]), handler: async ({ request_key, ...body }) => text(await api("POST", "/api/v1/mac/sessions", { body, idempotencyKey: request_key })) },
|
|
12
17
|
{ name: "list_mac_sessions", description: "Find existing Mac sessions, including after losing a response. Shows state, cost, limits and cleanup.", inputSchema: schema({}), handler: async () => text(await api("GET", "/api/v1/mac/sessions")) },
|
|
13
18
|
{ name: "get_mac_session", description: "Read live Mac allocation state, cost, funded time and cleanup confirmation. queued/provisioning means wait; stopping is not yet confirmed cleanup.", inputSchema: schema({ session_id: session }, ["session_id"]), handler: async args => text(await api("GET", base(args))) },
|
|
14
19
|
{ 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))) },
|
package/src/mcp.mjs
CHANGED
|
@@ -45,12 +45,7 @@ export const TOOLS = [
|
|
|
45
45
|
handler: async args => text(await api("POST", "/api/v1/billing/checkout", { body: args })),
|
|
46
46
|
},
|
|
47
47
|
{
|
|
48
|
-
name: "
|
|
49
|
-
inputSchema: { type: "object", properties: { confirm: { type: "boolean", default: false }, request_id: { type: "string" }, reason: { type: "string", maxLength: 1000 } }, additionalProperties: false },
|
|
50
|
-
handler: async args => text(await api("POST", "/api/v1/billing/refund", { body: args })),
|
|
51
|
-
},
|
|
52
|
-
{
|
|
53
|
-
name: "get_refund_status", description: "Read the saved refund and cancellation progress. Does not submit a refund or cancel anything.",
|
|
48
|
+
name: "get_refund_status", description: "Read the progress of a refund the account owner requested at nomac.app/usage. Agents cannot request refunds; point the human to that page. Does not submit a refund or cancel anything.",
|
|
54
49
|
inputSchema: { type: "object", properties: { request_id: { type: "string" } }, required: ["request_id"], additionalProperties: false },
|
|
55
50
|
handler: async args => text(await api("GET", `/api/v1/billing/refund?request_id=${encodeURIComponent(args.request_id)}`)),
|
|
56
51
|
},
|