@nomac/cli 0.5.2 → 0.5.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nomac/cli",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
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",
package/src/api.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { loadApiKey, loadApiUrl } from "./config.mjs";
2
+ import { clientHeaders } from "./client-info.mjs";
2
3
 
3
4
  export class CliError extends Error {
4
5
  constructor(message, friendly, status, code, details) {
@@ -16,7 +17,7 @@ export async function apiPublic(method, path, body) {
16
17
  const url = `${loadApiUrl()}${path}`;
17
18
  const res = await fetch(url, {
18
19
  method,
19
- headers: body !== undefined ? { "content-type": "application/json" } : {},
20
+ headers: { ...clientHeaders(), ...(body !== undefined ? { "content-type": "application/json" } : {}) },
20
21
  body: body !== undefined ? JSON.stringify(body) : undefined,
21
22
  });
22
23
  const text = await res.text();
@@ -41,6 +42,7 @@ export async function api(method, path, { body, raw, idempotencyKey } = {}) {
41
42
  const res = await fetch(url, {
42
43
  method,
43
44
  headers: {
45
+ ...clientHeaders(),
44
46
  authorization: `Bearer ${key}`,
45
47
  ...(raw
46
48
  ? { "content-type": "application/gzip" }
@@ -0,0 +1,33 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { readFileSync } from "node:fs";
3
+
4
+ /**
5
+ * How requests identify this CLI to nomac.app for product analytics: version,
6
+ * OS and, under `nomac mcp`, the agent and tool making the call. No hostnames,
7
+ * paths or arguments.
8
+ */
9
+ export const CLI_VERSION = (() => {
10
+ try { return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version; } catch { return "0.0.0"; }
11
+ })();
12
+ export const USER_AGENT = `nomac-cli/${CLI_VERSION} (${process.platform}; node ${process.versions.node})`;
13
+
14
+ /** Set by the stdio MCP server: the connected agent (from initialize) and the running tool. */
15
+ export const mcpContext = { client: undefined };
16
+ export const toolScope = new AsyncLocalStorage();
17
+
18
+ export function clientHeaders() {
19
+ const tool = toolScope.getStore()?.tool;
20
+ return {
21
+ "user-agent": USER_AGENT,
22
+ ...(mcpContext.client ? { "x-nomac-mcp-client": mcpContext.client } : {}),
23
+ ...(tool ? { "x-nomac-tool": tool } : {}),
24
+ };
25
+ }
26
+
27
+ /** "claude-code/2.1.4" from an MCP clientInfo, or undefined. */
28
+ export function mcpClientName(info) {
29
+ const name = typeof info?.name === "string" ? info.name.toLowerCase().replace(/[^a-z0-9._+-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) : "";
30
+ if (!name) return undefined;
31
+ const version = typeof info?.version === "string" ? /^\d{1,4}(\.\d{1,6}){0,3}/.exec(info.version)?.[0] : undefined;
32
+ return version ? `${name}/${version}` : name;
33
+ }
@@ -9,6 +9,7 @@ const HELP = `nomac credits [--json]
9
9
  nomac credits buy [--packs N] [--open] [--request-key KEY] [--json]
10
10
  Get a payment link for the human. $10 packs, 1 to 10 per purchase;
11
11
  each pack is 12.5 hours. $0.80 an hour, billed by the minute, never expires.
12
+ Bigger purchases earn bonus credit: +5% from $30, +10% from $50, +20% at $100.
12
13
  --open opens the link in this machine's browser (default when run by a person).
13
14
  Reuse --request-key after a lost response; a new key is a new purchase.
14
15
 
@@ -17,6 +18,8 @@ browser tab that opened (or give them the printed link), then poll
17
18
  \`nomac credits --json\` until spendable_seconds covers the work.`;
18
19
 
19
20
  const usd = microunits => `$${(Number(microunits) / 1_000_000).toFixed(2)}`;
21
+ const money = dollars => `$${Number.isInteger(dollars) ? dollars : dollars.toFixed(2)}`;
22
+ const hours = value => `${Number(Number(value).toFixed(1))} hours`;
20
23
  const clock = seconds => {
21
24
  const minutes = Math.floor(seconds / 60);
22
25
  return minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, "0")}m`;
@@ -64,7 +67,10 @@ export async function creditsCommand(argv, { request = api, print = console.log,
64
67
  if (values.json) return print(JSON.stringify({ ...result, opened_in_browser: opened }, null, 2));
65
68
  if (result.action !== "open_checkout_url") return print(`${result.message}\nrequest key: ${result.request_key}`);
66
69
  return print([
67
- `${packs} × $10 = $${result.amount_usd} for ${result.credit_hours} hours of Mac time.`,
70
+ result.bonus_usd > 0
71
+ ? `${packs} × $10 = $${result.amount_usd} for ${money(result.credit_usd)} of credit, including a +${result.bonus_percent}% bonus (${hours(result.credit_hours)} of Mac time).`
72
+ : `${packs} × $10 = $${result.amount_usd} for ${hours(result.credit_hours)} of Mac time.`,
73
+ ...(result.next_bonus && result.next_bonus.packs - packs === 1 ? [`Tip: ${result.next_bonus.packs} packs ($${result.next_bonus.packs * 10}) earn a +${result.next_bonus.bonus_percent}% bonus.`] : []),
68
74
  opened ? "Opened the payment page in your browser. Pay there to add the credit." : "Open this link to pay:",
69
75
  result.url,
70
76
  `Credit appears within a minute of payment; check with \`nomac credits\`. Request key (reuse if this response was lost): ${result.request_key}`,
package/src/mac-tools.mjs CHANGED
@@ -7,12 +7,12 @@ const key = { type: "string", pattern: "^[A-Za-z0-9_.:-]{8,128}$" };
7
7
  const schema = (properties, required = []) => ({ type: "object", properties, required, additionalProperties: false });
8
8
  const base = args => `/api/v1/mac/sessions/${encodeURIComponent(args.session_id)}`;
9
9
  export const MAC_TOOLS = [
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 }) => {
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. Bigger purchases earn bonus credit: +5% from 3 packs ($30), +10% from 5 ($50), +20% at 10 ($100 buys $120, 150 hours). When the human expects ongoing use, offer a choice, for example $10 (12.5 hours) or $50 (+10%, about 69 hours), and let them decide. 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; 3+ packs earn bonus credit." }, 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
11
  const result = await api("POST", "/api/v1/mac/credits/checkout", { body: { packs }, idempotencyKey: request_key });
12
12
  const opened = open && result.action === "open_checkout_url" && result.url ? openUrl(result.url) : false;
13
13
  return text({ ...result, opened_in_browser: opened });
14
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")) },
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 (including bonus_tiers for bigger purchases) 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
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 })) },
17
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")) },
18
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))) },
@@ -4,6 +4,7 @@ import { HttpsProxyAgent } from "https-proxy-agent";
4
4
  import { getProxyForUrl } from "proxy-from-env";
5
5
  import WebSocket, { createWebSocketStream } from "ws";
6
6
  import { CliError } from "./api.mjs";
7
+ import { clientHeaders } from "./client-info.mjs";
7
8
 
8
9
  export function sessionId(value) {
9
10
  const id = String(value ?? "").replace(/^nomac-/, "");
@@ -60,6 +61,7 @@ function macRequestOnce(method, path, { body, raw, requestKey, ...options } = {}
60
61
  };
61
62
  const request = (target.protocol === "https:" ? https : http).request(target, {
62
63
  method, agent, ca, headers: {
64
+ ...clientHeaders(),
63
65
  authorization: `Bearer ${token}`,
64
66
  ...(payload ? { "content-type": raw ? "application/octet-stream" : "application/json", "content-length": payload.length } : {}),
65
67
  ...(requestKey ? { "idempotency-key": requestKey } : {}),
@@ -99,7 +101,7 @@ export function tunnel(value, { input = process.stdin, output = process.stdout,
99
101
  url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
100
102
  return new Promise((resolve, reject) => {
101
103
  const ws = new WebSocket(url, {
102
- agent, ca, headers: { authorization: `Bearer ${token}` },
104
+ agent, ca, headers: { ...clientHeaders(), authorization: `Bearer ${token}` },
103
105
  handshakeTimeout: 15_000, perMessageDeflate: false, maxPayload: 1024 * 1024,
104
106
  followRedirects: false,
105
107
  });
package/src/mcp.mjs CHANGED
@@ -7,6 +7,7 @@ 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
11
  import { loadProjectState, saveProjectState } from "./config.mjs";
11
12
  import { packTarball } from "./pack.mjs";
12
13
  import { uploadSourceSnapshot } from "./upload.mjs";
@@ -401,6 +402,7 @@ export async function runMcpServer() {
401
402
  try {
402
403
  switch (method) {
403
404
  case "initialize":
405
+ mcpContext.client = mcpClientName(params?.clientInfo);
404
406
  reply({
405
407
  protocolVersion: params?.protocolVersion ?? PROTOCOL_FALLBACK,
406
408
  capabilities: { tools: {} },
@@ -422,7 +424,7 @@ export async function runMcpServer() {
422
424
  const tool = TOOLS.find((t) => t.name === params?.name);
423
425
  if (!tool) return fail(-32602, `unknown tool: ${params?.name}`);
424
426
  try {
425
- reply(await tool.handler(params?.arguments ?? {}));
427
+ reply(await toolScope.run({ tool: tool.name }, () => tool.handler(params?.arguments ?? {})));
426
428
  } catch (err) {
427
429
  reply({
428
430
  content: [{ type: "text", text: err?.details ? JSON.stringify({ error: { ...err.details, message: err.friendly ?? err.message } }, null, 2) : err?.friendly ?? err?.message ?? String(err) }],