@zhihand/mcp 0.29.0 → 0.30.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/bin/zhihand CHANGED
@@ -5,12 +5,22 @@ import { parseArgs } from "node:util";
5
5
  import { startStdioServer } from "../dist/index.js";
6
6
  import { startDaemon, stopDaemon, isAlreadyRunning } from "../dist/daemon/index.js";
7
7
  import { detectCLITools, formatDetectedTools } from "../dist/cli/detect.js";
8
- import { detectAndSetupOpenClaw } from "../dist/cli/openclaw.js";
9
- import { loadDefaultCredential, loadBackendConfig, saveBackendConfig, DEFAULT_MODELS } from "../dist/core/config.js";
8
+ import {
9
+ loadConfig,
10
+ listDeviceRecords,
11
+ removeDevice,
12
+ renameDevice,
13
+ setDefaultDevice,
14
+ loadBackendConfig,
15
+ saveBackendConfig,
16
+ DEFAULT_MODELS,
17
+ resolveConfig,
18
+ } from "../dist/core/config.js";
10
19
  import { executePairing } from "../dist/core/pair.js";
11
20
  import { configureMCP, displayName } from "../dist/cli/mcp-config.js";
12
21
 
13
22
  const DEFAULT_ENDPOINT = "https://api.zhihand.com";
23
+ const VERSION = "0.30.0";
14
24
 
15
25
  const CLI_TOOL_MAP = {
16
26
  claude: "claudecode",
@@ -23,8 +33,10 @@ const { positionals, values } = parseArgs({
23
33
  strict: false,
24
34
  options: {
25
35
  device: { type: "string" },
36
+ label: { type: "string" },
26
37
  model: { type: "string", short: "m" },
27
38
  help: { type: "boolean", short: "h", default: false },
39
+ version: { type: "boolean", short: "v", default: false },
28
40
  detach: { type: "boolean", short: "d", default: false },
29
41
  debug: { type: "boolean", default: false },
30
42
  force: { type: "boolean", default: false },
@@ -32,42 +44,47 @@ const { positionals, values } = parseArgs({
32
44
  },
33
45
  });
34
46
 
35
- const command = positionals[0] ?? "serve";
47
+ const command = positionals[0] ?? "mcp";
48
+
49
+ if (values.version) {
50
+ console.log(VERSION);
51
+ process.exit(0);
52
+ }
36
53
 
37
54
  if (values.help) {
38
55
  console.log(`
39
- zhihand — MCP Server and Relay for phone control
56
+ zhihand v${VERSION} — MCP Server and Relay for phone control
40
57
 
41
58
  Usage:
42
59
  zhihand start Start daemon (MCP Server + Relay, foreground)
43
60
  zhihand start -d Start daemon in background (detach)
44
- zhihand start --debug Start daemon with verbose debug logging
45
61
  zhihand stop Stop daemon
46
62
  zhihand status Show status (pairing, backend, brain)
47
63
 
48
- zhihand gemini Switch backend to Gemini CLI (default model: flash)
49
- zhihand claude Switch backend to Claude Code (default model: sonnet)
50
- zhihand codex Switch backend to Codex CLI (default model: gpt-5.4-mini)
51
- zhihand gemini --model pro Switch backend with custom model
64
+ zhihand gemini Switch backend to Gemini CLI
65
+ zhihand claude Switch backend to Claude Code
66
+ zhihand codex Switch backend to Codex CLI
52
67
 
53
68
  zhihand setup Interactive setup: pair + configure + start
54
- zhihand pair Pair with a phone device
69
+ zhihand pair [--label X] Pair with a phone device
70
+ zhihand list List paired devices
71
+ zhihand unpair <cred_id> Revoke + remove a device
72
+ zhihand rename <cred> <n> Rename a device
73
+ zhihand default <cred_id> Set default device
55
74
  zhihand detect Detect available CLI tools
56
75
 
57
- zhihand list List all available tests with IDs
58
- zhihand test Run all safe device tests (skips capability-gated)
59
- zhihand test <ids> Run specific test(s), e.g. 'zhihand test 4' or '4,9,20'
60
- zhihand test all Run ALL tests (including unsafe, e.g. power button)
61
- zhihand test --force Bypass capability gates (run anyway even if NOT ready)
62
- zhihand serve Start MCP Server (stdio mode, backward compat)
76
+ zhihand test [cred] [ids] Run device tests (all, specific ids, or for a credential)
77
+ zhihand mcp Start stdio MCP server (for Claude/Codex/Gemini hosts)
63
78
 
64
79
  Options:
65
- --device <name> Use a specific paired device
66
- --model, -m <name> Set model alias (e.g. flash, pro, sonnet, opus, gpt-5.4-mini)
80
+ --device <name> Use a specific paired credential_id (test only)
81
+ --label <label> Label for new device (pair only)
82
+ --model, -m <name> Backend model alias
67
83
  --port <port> Override daemon port (default: 18686)
68
84
  -d, --detach Run daemon in background
69
- --debug Enable verbose debug logging
85
+ --debug Verbose debug logging
70
86
  --force (test only) Run tests even if capability not ready
87
+ -v, --version Print version
71
88
  -h, --help Show this help
72
89
  `);
73
90
  process.exit(0);
@@ -90,7 +107,6 @@ if (Object.prototype.hasOwnProperty.call(CLI_TOOL_MAP, command)) {
90
107
  console.error(`Warning: ${command} is installed but not logged in.`);
91
108
  }
92
109
 
93
- // Check if daemon is running — if so, notify it via HTTP
94
110
  const daemonPid = isAlreadyRunning();
95
111
  const config = loadBackendConfig();
96
112
  const previous = config.activeBackend;
@@ -105,11 +121,9 @@ if (Object.prototype.hasOwnProperty.call(CLI_TOOL_MAP, command)) {
105
121
 
106
122
  console.log(`Switching backend to ${displayName(backendName)} (model: ${effectiveModel})...`);
107
123
 
108
- // Configure MCP (HTTP transport)
109
124
  const { configured, removed } = configureMCP(backendName, previous);
110
125
 
111
126
  if (configured) {
112
- // Notify daemon if running
113
127
  if (daemonPid) {
114
128
  try {
115
129
  const port = parseInt(process.env.ZHIHAND_PORT ?? "", 10) || 18686;
@@ -123,7 +137,6 @@ if (Object.prototype.hasOwnProperty.call(CLI_TOOL_MAP, command)) {
123
137
  console.log(`\nDaemon notified. Backend switched to ${displayName(backendName)}.`);
124
138
  }
125
139
  } catch {
126
- // Daemon not responding, just save config
127
140
  saveBackendConfig({ activeBackend: backendName, model: userModel });
128
141
  console.log(`\nBackend config saved. Daemon not responding — restart with 'zhihand start'.`);
129
142
  }
@@ -153,10 +166,8 @@ switch (command) {
153
166
 
154
167
  const args = [process.argv[1], "start"];
155
168
  if (values.port) args.push("--port", values.port);
156
- if (values.device) args.push("--device", values.device);
157
169
  if (values.debug) args.push("--debug");
158
170
 
159
- // Write daemon logs to ~/.zhihand/daemon.log
160
171
  const zhihandDir = pathMod.default.join(osMod.default.homedir(), ".zhihand");
161
172
  fsSync.default.mkdirSync(zhihandDir, { recursive: true });
162
173
  const logPath = pathMod.default.join(zhihandDir, "daemon.log");
@@ -174,48 +185,121 @@ switch (command) {
174
185
  process.exit(0);
175
186
  }
176
187
  const port = values.port ? parseInt(values.port, 10) : undefined;
177
- await startDaemon({
178
- port,
179
- deviceName: values.device ?? process.env.ZHIHAND_DEVICE,
180
- debug: values.debug,
181
- });
188
+ await startDaemon({ port, debug: values.debug });
182
189
  break;
183
190
  }
184
191
 
185
192
  case "stop": {
186
193
  const stopped = stopDaemon();
187
- if (stopped) {
188
- console.log("Daemon stopped.");
189
- } else {
190
- console.log("No daemon is running.");
191
- }
194
+ console.log(stopped ? "Daemon stopped." : "No daemon is running.");
192
195
  break;
193
196
  }
194
197
 
198
+ case "mcp":
195
199
  case "serve": {
196
- // Backward compatible: stdio MCP server (for old configs)
197
- await startStdioServer(values.device ?? process.env.ZHIHAND_DEVICE);
200
+ await startStdioServer();
198
201
  break;
199
202
  }
200
203
 
201
204
  case "pair": {
202
205
  const edgeId = `mcp-${Date.now().toString(36)}`;
203
- const deviceName = values.device ?? `mcp-${os.hostname()}`;
204
- await executePairing(DEFAULT_ENDPOINT, edgeId, deviceName);
206
+ const label = values.label ?? undefined;
207
+ await executePairing(DEFAULT_ENDPOINT, edgeId, label);
208
+ break;
209
+ }
210
+
211
+ case "list": {
212
+ const records = listDeviceRecords();
213
+ const cfg = loadConfig();
214
+ if (records.length === 0) {
215
+ console.log("No paired devices. Run: zhihand pair");
216
+ break;
217
+ }
218
+ const header = ["credential_id", "label", "platform", "default", "paired_at", "last_seen"];
219
+ const rows = records.map((r) => [
220
+ r.credential_id,
221
+ r.label,
222
+ r.platform,
223
+ cfg.default_credential_id === r.credential_id ? "*" : "",
224
+ r.paired_at,
225
+ r.last_seen_at,
226
+ ]);
227
+ const widths = header.map((h, i) => Math.max(h.length, ...rows.map((row) => row[i].length)));
228
+ const fmt = (row) => row.map((c, i) => c.padEnd(widths[i])).join(" ");
229
+ console.log(fmt(header));
230
+ console.log(widths.map((w) => "-".repeat(w)).join(" "));
231
+ for (const r of rows) console.log(fmt(r));
232
+ break;
233
+ }
234
+
235
+ case "unpair": {
236
+ const credId = positionals[1];
237
+ if (!credId) {
238
+ console.error("Usage: zhihand unpair <credential_id>");
239
+ process.exit(1);
240
+ }
241
+ const cfg = loadConfig();
242
+ const record = cfg.devices[credId];
243
+ if (!record) {
244
+ console.error(`Device '${credId}' not found.`);
245
+ process.exit(1);
246
+ }
247
+ // Best-effort revoke
248
+ try {
249
+ const url = `${record.endpoint}/v1/credentials/${encodeURIComponent(credId)}/revoke`;
250
+ const res = await fetch(url, {
251
+ method: "POST",
252
+ headers: { "x-zhihand-controller-token": record.controller_token },
253
+ signal: AbortSignal.timeout(5000),
254
+ });
255
+ if (!res.ok) {
256
+ console.warn(`Warning: server revoke returned ${res.status} (continuing to remove locally)`);
257
+ }
258
+ } catch (err) {
259
+ console.warn(`Warning: server revoke failed: ${err.message} (continuing to remove locally)`);
260
+ }
261
+ removeDevice(credId);
262
+ console.log(`Unpaired: ${credId}`);
263
+ break;
264
+ }
265
+
266
+ case "rename": {
267
+ const credId = positionals[1];
268
+ const newLabel = positionals[2];
269
+ if (!credId || !newLabel) {
270
+ console.error("Usage: zhihand rename <credential_id> <new_label>");
271
+ process.exit(1);
272
+ }
273
+ renameDevice(credId, newLabel);
274
+ console.log(`Renamed ${credId} to '${newLabel}'`);
275
+ break;
276
+ }
277
+
278
+ case "default": {
279
+ const credId = positionals[1];
280
+ if (!credId) {
281
+ console.error("Usage: zhihand default <credential_id>");
282
+ process.exit(1);
283
+ }
284
+ setDefaultDevice(credId);
285
+ console.log(`Default device set to ${credId}`);
205
286
  break;
206
287
  }
207
288
 
208
289
  case "status": {
209
- const cred = loadDefaultCredential();
290
+ const records = listDeviceRecords();
291
+ const cfg = loadConfig();
210
292
  const backend = loadBackendConfig();
211
293
  const daemonPid = isAlreadyRunning();
212
294
 
213
- if (cred) {
214
- console.log(`Paired device: ${cred.deviceName}`);
215
- console.log(`Credential ID: ${cred.credentialId}`);
216
- console.log(`Endpoint: ${cred.endpoint}`);
295
+ if (records.length === 0) {
296
+ console.log("No paired devices. Run: zhihand setup");
217
297
  } else {
218
- console.log("No paired device. Run: zhihand setup");
298
+ console.log(`Paired devices: ${records.length}`);
299
+ for (const r of records) {
300
+ const mark = cfg.default_credential_id === r.credential_id ? " *" : "";
301
+ console.log(` ${r.credential_id} (${r.label}, ${r.platform})${mark}`);
302
+ }
219
303
  }
220
304
  const backendLabel = backend.activeBackend ? displayName(backend.activeBackend) : "(none)";
221
305
  const modelLabel = backend.activeBackend
@@ -224,7 +308,6 @@ switch (command) {
224
308
  console.log(`Active backend: ${backendLabel} (model: ${modelLabel})`);
225
309
  console.log(`Daemon: ${daemonPid ? `running (PID ${daemonPid})` : "not running"}`);
226
310
 
227
- // If daemon running, get live status
228
311
  if (daemonPid) {
229
312
  try {
230
313
  const port = parseInt(process.env.ZHIHAND_PORT ?? "", 10) || 18686;
@@ -248,20 +331,19 @@ switch (command) {
248
331
  }
249
332
 
250
333
  case "setup": {
251
- // 1. Pair
252
- let cred = loadDefaultCredential();
253
- if (!cred) {
334
+ const records = listDeviceRecords();
335
+ if (records.length === 0) {
254
336
  console.log("No paired device found. Starting pairing...\n");
255
337
  const edgeId = `mcp-${Date.now().toString(36)}`;
256
- const deviceName = values.device ?? `mcp-${os.hostname()}`;
257
- await executePairing(DEFAULT_ENDPOINT, edgeId, deviceName);
258
- cred = loadDefaultCredential();
338
+ await executePairing(DEFAULT_ENDPOINT, edgeId, values.label);
259
339
  }
260
- if (cred) {
261
- console.log(`\nPaired: ${cred.deviceName} (${cred.credentialId})\n`);
340
+ const updated = listDeviceRecords();
341
+ if (updated.length > 0) {
342
+ const cfg = loadConfig();
343
+ const def = updated.find((r) => r.credential_id === cfg.default_credential_id) ?? updated[0];
344
+ console.log(`\nPaired: ${def.label} (${def.credential_id})\n`);
262
345
  }
263
346
 
264
- // 2. Detect tools
265
347
  const tools = await detectCLITools();
266
348
  console.log(formatDetectedTools(tools));
267
349
 
@@ -270,88 +352,51 @@ switch (command) {
270
352
  break;
271
353
  }
272
354
 
273
- // 3. Auto-select best tool and configure MCP (HTTP transport)
274
355
  const best = tools.find((t) => t.loggedIn) ?? tools[0];
275
356
  const config = loadBackendConfig();
276
357
 
277
358
  console.log(`\nAuto-selecting backend: ${displayName(best.name)}...`);
278
- // Ensure MCP URL uses correct port
279
- if (values.port) {
280
- process.env.ZHIHAND_PORT = values.port;
281
- }
359
+ if (values.port) process.env.ZHIHAND_PORT = values.port;
282
360
  configureMCP(best.name, config.activeBackend);
283
361
  saveBackendConfig({ activeBackend: best.name });
284
362
 
285
- // 4. Start daemon
286
363
  console.log(`\nStarting daemon...\n`);
287
364
  const port = values.port ? parseInt(values.port, 10) : undefined;
288
- await startDaemon({
289
- port,
290
- deviceName: values.device ?? process.env.ZHIHAND_DEVICE,
291
- });
365
+ await startDaemon({ port });
292
366
  break;
293
367
  }
294
368
 
295
- case "list":
296
369
  case "test": {
297
- const { resolveConfig: resolveTestConfig } = await import("../dist/core/config.js");
298
370
  const { createControlCommand, createSystemCommand, enqueueCommand } = await import("../dist/core/command.js");
299
371
  const { waitForCommandAck } = await import("../dist/core/sse.js");
300
372
  const { fetchScreenshot, getSnapshotStaleThresholdMs } = await import("../dist/core/screenshot.js");
301
- const { fetchDeviceProfile, getStaticContext, isDeviceProfileLoaded, formatDeviceStatus, getCapabilities } = await import("../dist/core/device.js");
302
-
303
- // ── Test Registry ────────────────────────────────────────
304
- // Kind: "profile" | "status" | "screenshot" | "hid" | "system"
305
- // - Each kind maps to a required capability (see KIND_CAPABILITY).
306
- // Platform: undefined | "android" | "ios" (skipped on non-matching)
307
- // Unsafe: won't run in full-suite unless explicitly requested
308
-
309
- // Required capability per test kind. Tests whose required capability
310
- // is not ready are SKIPPED (not failed), unless --force is passed.
311
- //
312
- // NOTE: `system` commands (volume, brightness, notification, media,
313
- // etc.) are executed by the phone app via native OS APIs
314
- // (AccessibilityService on Android, Shortcuts/system hooks on iOS)
315
- // and do NOT depend on the BLE HID channel. Only the `hid` kind
316
- // (click, swipe, type, keycombo — which inject into the paired
317
- // target via the ZhiHand peripheral) needs the HID capability.
373
+ const { fetchDeviceProfileOnce, extractStatic, computeCapabilities, formatDeviceStatus } = await import("../dist/core/device.js");
374
+
318
375
  const KIND_CAPABILITY = {
319
- profile: "none",
320
- status: "none",
321
- screenshot: "screen",
322
- hid: "hid",
323
- system: "none",
376
+ profile: "none", status: "none", screenshot: "screen", hid: "hid", system: "none",
324
377
  };
325
378
  const REGISTRY = [
326
- // Phase A — Device Info API
327
379
  { id: 1, phase: "Device Info", label: "Fetch device profile", kind: "profile" },
328
380
  { id: 2, phase: "Device Info", label: "Device status fields", kind: "status" },
329
- // Phase B — Screenshot
330
381
  { id: 3, phase: "Screenshot", label: "Screenshot", kind: "screenshot" },
331
- // Phase C — Tap / Touch
332
382
  { id: 4, phase: "Tap/Touch", label: "Click center", kind: "hid", params: { action: "click", xRatio: 0.5, yRatio: 0.5 } },
333
383
  { id: 5, phase: "Tap/Touch", label: "Double click", kind: "hid", params: { action: "doubleclick", xRatio: 0.5, yRatio: 0.5 } },
334
384
  { id: 6, phase: "Tap/Touch", label: "Long click (800ms)", kind: "hid", params: { action: "longclick", xRatio: 0.5, yRatio: 0.5, durationMs: 800 } },
335
385
  { id: 7, phase: "Tap/Touch", label: "Right click", kind: "hid", params: { action: "rightclick", xRatio: 0.5, yRatio: 0.5 } },
336
386
  { id: 8, phase: "Tap/Touch", label: "Middle click", kind: "hid", params: { action: "middleclick", xRatio: 0.5, yRatio: 0.5 } },
337
- // Phase D — Swipe / Scroll
338
387
  { id: 9, phase: "Swipe/Scroll", label: "Swipe up", kind: "hid", params: { action: "swipe", startXRatio: 0.5, startYRatio: 0.7, endXRatio: 0.5, endYRatio: 0.3, durationMs: 300 } },
339
388
  { id: 10, phase: "Swipe/Scroll", label: "Swipe down", kind: "hid", params: { action: "swipe", startXRatio: 0.5, startYRatio: 0.3, endXRatio: 0.5, endYRatio: 0.7, durationMs: 300 } },
340
389
  { id: 11, phase: "Swipe/Scroll", label: "Swipe left", kind: "hid", params: { action: "swipe", startXRatio: 0.7, startYRatio: 0.5, endXRatio: 0.3, endYRatio: 0.5, durationMs: 300 } },
341
390
  { id: 12, phase: "Swipe/Scroll", label: "Swipe right", kind: "hid", params: { action: "swipe", startXRatio: 0.3, startYRatio: 0.5, endXRatio: 0.7, endYRatio: 0.5, durationMs: 300 } },
342
391
  { id: 13, phase: "Swipe/Scroll", label: "Scroll down", kind: "hid", params: { action: "scroll", xRatio: 0.5, yRatio: 0.5, direction: "down", amount: 3 } },
343
392
  { id: 14, phase: "Swipe/Scroll", label: "Scroll up", kind: "hid", params: { action: "scroll", xRatio: 0.5, yRatio: 0.5, direction: "up", amount: 3 } },
344
- // Phase E — Text + Keys
345
393
  { id: 15, phase: "Text+Keys", label: "Type text", kind: "hid", params: { action: "type", text: "zhihand" } },
346
394
  { id: 16, phase: "Text+Keys", label: "Enter key", kind: "hid", params: { action: "enter" } },
347
395
  { id: 17, phase: "Text+Keys", label: "Key combo (select all)", kind: "hid", platformAware: "select_all" },
348
- // Phase F — App Navigation
349
396
  { id: 18, phase: "Navigation", label: "Press Home", kind: "hid", params: { action: "home" } },
350
397
  { id: 19, phase: "Navigation", label: "Press Back", kind: "hid", params: { action: "back" } },
351
398
  { id: 20, phase: "Navigation", label: "Open WeChat", kind: "hid", platformAware: "open_wechat" },
352
- // Phase G — Clipboard
353
399
  { id: 21, phase: "Clipboard", label: "Clipboard set", kind: "hid", platformAware: "clipboard_set" },
354
- // Phase H — System Navigation
355
400
  { id: 22, phase: "System Nav", label: "Notification shade", kind: "system", params: { action: "notification" } },
356
401
  { id: 23, phase: "System Nav", label: "Recent apps", kind: "system", params: { action: "recent" } },
357
402
  { id: 24, phase: "System Nav", label: "Search (query='zhihand')", kind: "system", params: { action: "search", text: "zhihand" } },
@@ -360,7 +405,6 @@ switch (command) {
360
405
  { id: 27, phase: "System Nav", label: "Control Center", kind: "system", params: { action: "control_center" }, platform: "ios" },
361
406
  { id: 28, phase: "System Nav", label: "Open browser", kind: "system", params: { action: "open_browser" }, platform: "android" },
362
407
  { id: 29, phase: "System Nav", label: "Shortcut help", kind: "system", params: { action: "shortcut_help" }, platform: "android" },
363
- // Phase I — Media
364
408
  { id: 30, phase: "Media", label: "Volume up", kind: "system", params: { action: "volume_up" } },
365
409
  { id: 31, phase: "Media", label: "Volume down", kind: "system", params: { action: "volume_down" } },
366
410
  { id: 32, phase: "Media", label: "Mute toggle", kind: "system", params: { action: "mute" } },
@@ -370,50 +414,36 @@ switch (command) {
370
414
  { id: 36, phase: "Media", label: "Fast forward", kind: "system", params: { action: "fast_forward" } },
371
415
  { id: 37, phase: "Media", label: "Rewind", kind: "system", params: { action: "rewind" } },
372
416
  { id: 38, phase: "Media", label: "Stop", kind: "system", params: { action: "stop" } },
373
- // Phase J — Hardware
374
417
  { id: 39, phase: "Hardware", label: "Brightness up", kind: "system", params: { action: "brightness_up" } },
375
418
  { id: 40, phase: "Hardware", label: "Brightness down", kind: "system", params: { action: "brightness_down" } },
376
419
  { id: 41, phase: "Hardware", label: "Power button (⚠️ may lock screen)", kind: "system", params: { action: "power" }, unsafe: true },
377
420
  ];
378
421
 
379
- // ── "list" sub-command ───────────────────────────────────
380
- if (command === "list") {
381
- console.log("📋 ZhiHand Test Registry\n");
382
- let currentPhase = "";
383
- for (const t of REGISTRY) {
384
- if (t.phase !== currentPhase) {
385
- console.log(`\n ── ${t.phase} ──`);
386
- currentPhase = t.phase;
387
- }
388
- const tags = [];
389
- if (t.platform) tags.push(`${t.platform}-only`);
390
- if (t.unsafe) tags.push("unsafe");
391
- const tagStr = tags.length ? ` [${tags.join(", ")}]` : "";
392
- console.log(` ${String(t.id).padStart(2)}. ${t.label}${tagStr}`);
422
+ // Parse first positional: credential_id (crd_*) or test ids
423
+ let credentialArg = values.device ?? process.env.ZHIHAND_DEVICE;
424
+ let filterArg = null;
425
+ const arg1 = positionals[1];
426
+ const arg2 = positionals[2];
427
+ if (arg1) {
428
+ if (/^crd_/.test(arg1)) {
429
+ credentialArg = arg1;
430
+ filterArg = arg2 ?? null;
431
+ } else {
432
+ filterArg = arg1;
393
433
  }
394
- console.log(`\n Total: ${REGISTRY.length} tests`);
395
- console.log("\nUsage:");
396
- console.log(" zhihand test # run all safe tests");
397
- console.log(" zhihand test 4 # run test #4 only");
398
- console.log(" zhihand test 4,9,20 # run tests #4, #9, #20");
399
- console.log(" zhihand test all # run ALL tests (including unsafe)");
400
- process.exit(0);
401
434
  }
402
435
 
403
- // ── "test" sub-command ───────────────────────────────────
404
436
  let testConfig;
405
437
  try {
406
- testConfig = resolveTestConfig(values.device ?? process.env.ZHIHAND_DEVICE);
438
+ testConfig = resolveConfig(credentialArg);
407
439
  } catch (err) {
408
440
  console.error(`Error: ${err.message}`);
409
- console.error("Run 'zhihand setup' to pair a device first.");
441
+ console.error("Run 'zhihand pair' to add a device first.");
410
442
  process.exit(1);
411
443
  }
412
444
 
413
- // Parse which tests to run from positional args
414
- const filterArg = positionals[1]; // e.g. "4" or "4,9,20" or "all"
415
445
  const forceRun = values.force === true;
416
- let selectedIds = null; // null = default (all safe)
446
+ let selectedIds = null;
417
447
  let includeUnsafe = false;
418
448
  if (filterArg) {
419
449
  if (filterArg === "all") {
@@ -423,16 +453,13 @@ switch (command) {
423
453
  filterArg.split(",").map((s) => {
424
454
  const trimmed = s.trim();
425
455
  const n = Number(trimmed);
426
- // Strict: reject ranges like "4-10" (Number returns NaN), floats, empty
427
456
  return Number.isInteger(n) && n > 0 ? n : NaN;
428
457
  }).filter((n) => !isNaN(n))
429
458
  );
430
459
  if (selectedIds.size === 0) {
431
460
  console.error(`Invalid test IDs: ${filterArg}`);
432
- console.error("Run 'zhihand list' to see available tests.");
433
461
  process.exit(1);
434
462
  }
435
- // Explicit selection implies user knows what they're doing
436
463
  includeUnsafe = true;
437
464
  }
438
465
  }
@@ -441,38 +468,40 @@ switch (command) {
441
468
  console.log(` Device: ${testConfig.credentialId}`);
442
469
  console.log(` Endpoint: ${testConfig.controlPlaneEndpoint}\n`);
443
470
 
444
- // Pre-fetch device profile so platform-aware tests work.
445
- // Note: platform is read dynamically via getDevicePlatform() so Test 1
446
- // (Fetch device profile) can populate it before later tests consume it.
471
+ // Pre-fetch device profile
472
+ let currentRawAttrs = {};
473
+ let currentProfile = null;
474
+ let currentCaps = null;
475
+ let profileReceivedAtMs = 0;
447
476
  try {
448
- await fetchDeviceProfile(testConfig);
449
- } catch { /* non-fatal — Test 1 will retry and platform will update */ }
450
- const getDevicePlatform = () => isDeviceProfileLoaded() ? getStaticContext().platform : "unknown";
477
+ const fetched = await fetchDeviceProfileOnce(testConfig);
478
+ if (fetched) {
479
+ currentRawAttrs = fetched.rawAttrs;
480
+ profileReceivedAtMs = fetched.receivedAtMs;
481
+ currentProfile = extractStatic(currentRawAttrs);
482
+ currentCaps = computeCapabilities(currentRawAttrs, profileReceivedAtMs);
483
+ }
484
+ } catch { /* non-fatal */ }
485
+ const getDevicePlatform = () => currentProfile?.platform ?? "unknown";
451
486
 
452
- // ── Capability readiness pre-flight ──
453
487
  console.log(" ── Capability readiness ──");
454
- const preCaps = isDeviceProfileLoaded() ? getCapabilities() : null;
455
- if (!preCaps) {
488
+ if (!currentCaps) {
456
489
  console.log(" ⚠️ Device profile not loaded — all capability gates will allow tests through.");
457
490
  } else {
458
491
  const fmt = (name, cap) => ` ${cap.ready ? "✅" : "⚠️"} ${name.padEnd(14)} ${cap.ready ? "ready" : "NOT ready"} — ${cap.reason}`;
459
- console.log(fmt("screen_sharing", preCaps.screen_sharing));
460
- console.log(fmt("hid", preCaps.hid));
461
- console.log(fmt("live_session", preCaps.live_session));
462
- const ageStr = preCaps.profile.age_ms >= 0 ? `${(preCaps.profile.age_ms / 1000).toFixed(1)}s` : "unknown";
463
- console.log(` ${preCaps.profile.stale ? "⚠️" : "✅"} profile age=${ageStr}${preCaps.profile.stale ? " (STALE)" : ""}`);
492
+ console.log(fmt("screen_sharing", currentCaps.screen_sharing));
493
+ console.log(fmt("hid", currentCaps.hid));
494
+ console.log(fmt("live_session", currentCaps.live_session));
495
+ const ageStr = currentCaps.profile.age_ms >= 0 ? `${(currentCaps.profile.age_ms / 1000).toFixed(1)}s` : "unknown";
496
+ console.log(` ${currentCaps.profile.stale ? "⚠️" : "✅"} profile age=${ageStr}${currentCaps.profile.stale ? " (STALE)" : ""}`);
464
497
  if (forceRun) {
465
498
  console.log(" --force passed: capability gates disabled.");
466
499
  }
467
500
  }
468
501
  console.log("");
469
502
 
470
- let passed = 0;
471
- let failed = 0;
472
- let skipped = 0;
473
- let totalSteps = 0;
503
+ let passed = 0, failed = 0, skipped = 0, totalSteps = 0;
474
504
 
475
- // ── Resolve platform-aware params (evaluated at test time) ──
476
505
  function resolvePlatformAwareParams(variant) {
477
506
  const platform = getDevicePlatform();
478
507
  if (variant === "open_wechat") {
@@ -490,7 +519,6 @@ switch (command) {
490
519
  return null;
491
520
  }
492
521
 
493
- // ── Test runners (shared ACK logic) ──
494
522
  async function runCommandTest(t, command) {
495
523
  totalSteps++;
496
524
  process.stdout.write(` ${String(t.id).padStart(2)}. ${t.label}... `);
@@ -520,25 +548,18 @@ switch (command) {
520
548
  }
521
549
 
522
550
  async function runSingleTest(t) {
523
- // Platform skip (evaluated at test time — Test 1 may have just populated the profile)
524
551
  const currentPlatform = getDevicePlatform();
525
552
  if (t.platform && t.platform !== currentPlatform) {
526
- totalSteps++;
527
- skipped++;
553
+ totalSteps++; skipped++;
528
554
  console.log(` ${String(t.id).padStart(2)}. ${t.label}... ⏭️ Skipped (${t.platform}-only, device is ${currentPlatform})`);
529
555
  return;
530
556
  }
531
-
532
- // Capability gate (unless --force) — evaluated per-test so later
533
- // tests see fresh readiness flags pushed after Test 1's profile fetch.
534
- if (!forceRun && isDeviceProfileLoaded()) {
557
+ if (!forceRun && currentCaps) {
535
558
  const requiredCap = KIND_CAPABILITY[t.kind] ?? "none";
536
559
  if (requiredCap !== "none") {
537
- const caps = getCapabilities();
538
- const gate = requiredCap === "screen" ? caps.screen_sharing : caps.hid;
560
+ const gate = requiredCap === "screen" ? currentCaps.screen_sharing : currentCaps.hid;
539
561
  if (!gate.ready) {
540
- totalSteps++;
541
- skipped++;
562
+ totalSteps++; skipped++;
542
563
  console.log(` ${String(t.id).padStart(2)}. ${t.label}... ⏭️ Skipped (${requiredCap} not ready: ${gate.reason})`);
543
564
  return;
544
565
  }
@@ -551,10 +572,14 @@ switch (command) {
551
572
  process.stdout.write(` ${String(t.id).padStart(2)}. ${t.label}... `);
552
573
  const t0 = Date.now();
553
574
  try {
554
- await fetchDeviceProfile(testConfig);
575
+ const fetched = await fetchDeviceProfileOnce(testConfig);
555
576
  const ms = Date.now() - t0;
556
- if (isDeviceProfileLoaded()) {
557
- const s = getStaticContext();
577
+ if (fetched) {
578
+ currentRawAttrs = fetched.rawAttrs;
579
+ profileReceivedAtMs = fetched.receivedAtMs;
580
+ currentProfile = extractStatic(currentRawAttrs);
581
+ currentCaps = computeCapabilities(currentRawAttrs, profileReceivedAtMs);
582
+ const s = currentProfile;
558
583
  console.log(`✅ ${s.platform} ${s.model}, ${s.osVersion}, ${s.screenWidthPx}x${s.screenHeightPx} (${ms}ms)`);
559
584
  passed++;
560
585
  } else {
@@ -571,11 +596,22 @@ switch (command) {
571
596
  totalSteps++;
572
597
  process.stdout.write(` ${String(t.id).padStart(2)}. ${t.label}... `);
573
598
  try {
574
- const status = formatDeviceStatus();
575
- // Count curated top-level fields (excluding the nested 'raw'
576
- // and 'capabilities' containers) plus every allowlisted raw
577
- // attribute — this is what the LLM actually sees via
578
- // zhihand_status.
599
+ const state = {
600
+ credentialId: testConfig.credentialId,
601
+ label: "(test)",
602
+ platform: getDevicePlatform(),
603
+ online: true,
604
+ lastSeenAtMs: Date.now(),
605
+ profile: currentProfile,
606
+ capabilities: currentCaps,
607
+ profileReceivedAtMs,
608
+ rawAttributes: currentRawAttrs,
609
+ sseController: null,
610
+ sseConnected: false,
611
+ heartbeatTimer: null,
612
+ record: { credential_id: testConfig.credentialId, controller_token: "", endpoint: "", label: "", platform: "unknown", paired_at: "", last_seen_at: "" },
613
+ };
614
+ const status = formatDeviceStatus(state);
579
615
  const topLevel = Object.keys(status).filter((k) => k !== "raw" && k !== "capabilities");
580
616
  const rawKeys = Object.keys(status.raw ?? {});
581
617
  const caps = status.capabilities ?? {};
@@ -608,9 +644,6 @@ switch (command) {
608
644
  const shot = await fetchScreenshot(testConfig);
609
645
  const kb = (shot.buffer.length / 1024).toFixed(0);
610
646
  const ms = Date.now() - t0;
611
- // The screenshot endpoint returns the last cached frame even
612
- // when the phone isn't actively sharing — the age header is
613
- // the only way to tell. Treat stale as failure.
614
647
  if (shot.stale) {
615
648
  const threshold = getSnapshotStaleThresholdMs();
616
649
  console.log(`❌ Stale (${kb}KB, age=${(shot.ageMs / 1000).toFixed(1)}s > ${(threshold / 1000).toFixed(1)}s) ${shot.width}x${shot.height} seq=${shot.sequence} — phone may not be screen-sharing (${ms}ms)`);
@@ -627,11 +660,11 @@ switch (command) {
627
660
  }
628
661
  case "hid": {
629
662
  const params = t.platformAware ? resolvePlatformAwareParams(t.platformAware) : t.params;
630
- await runCommandTest(t, createControlCommand(params));
663
+ await runCommandTest(t, createControlCommand(params, getDevicePlatform()));
631
664
  break;
632
665
  }
633
666
  case "system": {
634
- await runCommandTest(t, createSystemCommand(t.params));
667
+ await runCommandTest(t, createSystemCommand(t.params, getDevicePlatform()));
635
668
  break;
636
669
  }
637
670
  }
@@ -639,7 +672,6 @@ switch (command) {
639
672
 
640
673
  const pause = () => new Promise((r) => setTimeout(r, 1500));
641
674
 
642
- // Select tests to run
643
675
  const toRun = REGISTRY.filter((t) => {
644
676
  if (selectedIds) return selectedIds.has(t.id);
645
677
  if (t.unsafe && !includeUnsafe) return false;
@@ -648,17 +680,13 @@ switch (command) {
648
680
 
649
681
  if (toRun.length === 0) {
650
682
  console.error("No matching tests.");
651
- console.error("Run 'zhihand list' to see available tests.");
652
683
  process.exit(1);
653
684
  }
654
685
 
655
- // Warn about missing IDs
656
686
  if (selectedIds) {
657
687
  const foundIds = new Set(toRun.map((t) => t.id));
658
688
  const missing = [...selectedIds].filter((id) => !foundIds.has(id));
659
- if (missing.length) {
660
- console.warn(`⚠️ Unknown test IDs: ${missing.join(", ")}`);
661
- }
689
+ if (missing.length) console.warn(`⚠️ Unknown test IDs: ${missing.join(", ")}`);
662
690
  }
663
691
 
664
692
  let currentPhase = "";
@@ -672,13 +700,9 @@ switch (command) {
672
700
  if (i < toRun.length - 1) await pause();
673
701
  }
674
702
 
675
- // ── Summary ──────────────────────────────────────────────
676
703
  console.log(`\n Result: ${passed}/${totalSteps} passed, ${failed} failed, ${skipped} skipped`);
677
- if (failed === 0) {
678
- console.log(" All tests passed! Device is fully responsive.");
679
- } else {
680
- console.log(` ⚠️ ${failed} test(s) failed. Check phone connectivity.`);
681
- }
704
+ if (failed === 0) console.log(" ✅ All tests passed! Device is fully responsive.");
705
+ else console.log(` ⚠️ ${failed} test(s) failed. Check phone connectivity.`);
682
706
  process.exit(failed > 0 ? 1 : 0);
683
707
  }
684
708