@nomac/cli 0.5.1 → 0.5.3

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.1",
3
+ "version": "0.5.3",
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
+ }
@@ -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-/, "");
@@ -25,8 +26,27 @@ export function transportOptions(options = {}) {
25
26
  return { url, token, agent: proxy ? new HttpsProxyAgent(proxy) : undefined, ca: options.ca };
26
27
  }
27
28
 
28
- /** Bounded requests deliberately do not require streaming responses or retry mutations. */
29
- export function macRequest(method, path, { body, raw, requestKey, ...options } = {}) {
29
+ /** How long a busy guest is retried before guest_busy reaches the caller. */
30
+ export const GUEST_BUSY_RETRY = { budgetMs: 75_000, pauseMs: 3_000 };
31
+
32
+ /**
33
+ * A saturated guest (e.g. a first Simulator boot) answers slowly for a minute or
34
+ * two. Guest operations are idempotent by job ID, request key and blob offset, so
35
+ * only guest_busy is retried; every other failure is returned for reconciliation.
36
+ */
37
+ export function macRequest(method, path, options = {}) {
38
+ const started = Date.now();
39
+ // The first attempt runs synchronously, so invalid input still throws immediately.
40
+ const attempt = () => macRequestOnce(method, path, options).catch(async error => {
41
+ if (error?.code !== "guest_busy" || Date.now() - started + GUEST_BUSY_RETRY.pauseMs + 15_000 > GUEST_BUSY_RETRY.budgetMs) throw error;
42
+ await new Promise(resolve => setTimeout(resolve, GUEST_BUSY_RETRY.pauseMs));
43
+ return attempt();
44
+ });
45
+ return attempt();
46
+ }
47
+
48
+ /** Bounded requests deliberately do not require streaming responses. */
49
+ function macRequestOnce(method, path, { body, raw, requestKey, ...options } = {}) {
30
50
  const { url, token, agent, ca } = transportOptions(options);
31
51
  if (!path.startsWith("/api/v1/mac/") || path.includes("#")) throw new CliError("Invalid Mac API path.");
32
52
  const target = new URL(path, url);
@@ -41,6 +61,7 @@ export function macRequest(method, path, { body, raw, requestKey, ...options } =
41
61
  };
42
62
  const request = (target.protocol === "https:" ? https : http).request(target, {
43
63
  method, agent, ca, headers: {
64
+ ...clientHeaders(),
44
65
  authorization: `Bearer ${token}`,
45
66
  ...(payload ? { "content-type": raw ? "application/octet-stream" : "application/json", "content-length": payload.length } : {}),
46
67
  ...(requestKey ? { "idempotency-key": requestKey } : {}),
@@ -80,7 +101,7 @@ export function tunnel(value, { input = process.stdin, output = process.stdout,
80
101
  url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
81
102
  return new Promise((resolve, reject) => {
82
103
  const ws = new WebSocket(url, {
83
- agent, ca, headers: { authorization: `Bearer ${token}` },
104
+ agent, ca, headers: { ...clientHeaders(), authorization: `Bearer ${token}` },
84
105
  handshakeTimeout: 15_000, perMessageDeflate: false, maxPayload: 1024 * 1024,
85
106
  followRedirects: false,
86
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) }],