@zhihand/mcp 0.29.0 → 0.32.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.
Files changed (42) hide show
  1. package/bin/zhihand +448 -212
  2. package/dist/core/command.d.ts +5 -5
  3. package/dist/core/command.js +6 -8
  4. package/dist/core/config.d.ts +48 -21
  5. package/dist/core/config.js +178 -42
  6. package/dist/core/device.d.ts +28 -19
  7. package/dist/core/device.js +168 -145
  8. package/dist/core/logger.d.ts +17 -0
  9. package/dist/core/logger.js +32 -0
  10. package/dist/core/pair.d.ts +39 -31
  11. package/dist/core/pair.js +205 -77
  12. package/dist/core/registry.d.ts +60 -0
  13. package/dist/core/registry.js +415 -0
  14. package/dist/core/screenshot.d.ts +3 -3
  15. package/dist/core/screenshot.js +3 -2
  16. package/dist/core/sse.d.ts +40 -18
  17. package/dist/core/sse.js +122 -62
  18. package/dist/core/ws.d.ts +92 -0
  19. package/dist/core/ws.js +327 -0
  20. package/dist/daemon/dispatcher.d.ts +3 -1
  21. package/dist/daemon/dispatcher.js +4 -3
  22. package/dist/daemon/heartbeat.d.ts +4 -4
  23. package/dist/daemon/heartbeat.js +1 -1
  24. package/dist/daemon/index.js +10 -8
  25. package/dist/daemon/prompt-listener.d.ts +8 -7
  26. package/dist/daemon/prompt-listener.js +59 -99
  27. package/dist/index.d.ts +3 -3
  28. package/dist/index.js +104 -40
  29. package/dist/openclaw.adapter.js +10 -2
  30. package/dist/tools/control.d.ts +10 -3
  31. package/dist/tools/control.js +18 -24
  32. package/dist/tools/pair.d.ts +1 -1
  33. package/dist/tools/pair.js +22 -28
  34. package/dist/tools/resolve.d.ts +7 -0
  35. package/dist/tools/resolve.js +22 -0
  36. package/dist/tools/schemas.d.ts +9 -1
  37. package/dist/tools/schemas.js +10 -8
  38. package/dist/tools/screenshot.d.ts +3 -2
  39. package/dist/tools/screenshot.js +2 -2
  40. package/dist/tools/system.d.ts +3 -5
  41. package/dist/tools/system.js +19 -6
  42. package/package.json +3 -1
package/bin/zhihand CHANGED
@@ -1,16 +1,36 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import os from "node:os";
4
+ import fs from "node:fs";
4
5
  import { parseArgs } from "node:util";
5
6
  import { startStdioServer } from "../dist/index.js";
6
7
  import { startDaemon, stopDaemon, isAlreadyRunning } from "../dist/daemon/index.js";
7
8
  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";
10
- import { executePairing } from "../dist/core/pair.js";
9
+ import {
10
+ loadConfig,
11
+ listUsers,
12
+ getUserRecord,
13
+ removeUser,
14
+ removeDeviceFromUser,
15
+ findDeviceOwner,
16
+ updateDeviceLabel,
17
+ updateControllerToken,
18
+ loadBackendConfig,
19
+ saveBackendConfig,
20
+ addUser,
21
+ DEFAULT_MODELS,
22
+ resolveConfig,
23
+ resolveDefaultEndpoint,
24
+ } from "../dist/core/config.js";
25
+ import {
26
+ executePairingNewUser,
27
+ executePairingAddDevice,
28
+ } from "../dist/core/pair.js";
29
+ import { fetchUserCredentials } from "../dist/core/ws.js";
11
30
  import { configureMCP, displayName } from "../dist/cli/mcp-config.js";
12
31
 
13
32
  const DEFAULT_ENDPOINT = "https://api.zhihand.com";
33
+ const VERSION = "0.32.0";
14
34
 
15
35
  const CLI_TOOL_MAP = {
16
36
  claude: "claudecode",
@@ -23,8 +43,10 @@ const { positionals, values } = parseArgs({
23
43
  strict: false,
24
44
  options: {
25
45
  device: { type: "string" },
46
+ label: { type: "string" },
26
47
  model: { type: "string", short: "m" },
27
48
  help: { type: "boolean", short: "h", default: false },
49
+ version: { type: "boolean", short: "v", default: false },
28
50
  detach: { type: "boolean", short: "d", default: false },
29
51
  debug: { type: "boolean", default: false },
30
52
  force: { type: "boolean", default: false },
@@ -32,42 +54,50 @@ const { positionals, values } = parseArgs({
32
54
  },
33
55
  });
34
56
 
35
- const command = positionals[0] ?? "serve";
57
+ const command = positionals[0] ?? "mcp";
58
+
59
+ if (values.version) {
60
+ console.log(VERSION);
61
+ process.exit(0);
62
+ }
36
63
 
37
64
  if (values.help) {
38
65
  console.log(`
39
- zhihand — MCP Server and Relay for phone control
66
+ zhihand v${VERSION} — MCP Server and Relay for phone control
40
67
 
41
68
  Usage:
42
69
  zhihand start Start daemon (MCP Server + Relay, foreground)
43
70
  zhihand start -d Start daemon in background (detach)
44
- zhihand start --debug Start daemon with verbose debug logging
45
71
  zhihand stop Stop daemon
46
72
  zhihand status Show status (pairing, backend, brain)
47
73
 
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
74
+ zhihand gemini Switch backend to Gemini CLI
75
+ zhihand claude Switch backend to Claude Code
76
+ zhihand codex Switch backend to Codex CLI
52
77
 
53
78
  zhihand setup Interactive setup: pair + configure + start
54
- zhihand pair Pair with a phone device
79
+ zhihand pair [--label X] Pair new user + first device + auto-configure MCP
80
+ zhihand pair <user_id> Add device to existing user
81
+ zhihand list [<user_id>] List users/devices with real-time online status
82
+ zhihand unpair <id> Remove user (usr_*) or device (credential)
83
+ zhihand rename <cred> <n> Rename a device (server-side + local)
84
+ zhihand export <user_id> Export user credentials as JSON to stdout
85
+ zhihand import <file> Import user credentials from JSON file
86
+ zhihand rotate <user_id> Rotate controller token
55
87
  zhihand detect Detect available CLI tools
56
88
 
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)
89
+ zhihand test [cred] [ids] Run device tests (all, specific ids, or for a credential)
90
+ zhihand mcp Start stdio MCP server (for Claude/Codex/Gemini hosts)
63
91
 
64
92
  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)
93
+ --device <name> Use a specific paired credential_id (test only)
94
+ --label <label> Label for new device (pair only)
95
+ --model, -m <name> Backend model alias
67
96
  --port <port> Override daemon port (default: 18686)
68
97
  -d, --detach Run daemon in background
69
- --debug Enable verbose debug logging
98
+ --debug Verbose debug logging
70
99
  --force (test only) Run tests even if capability not ready
100
+ -v, --version Print version
71
101
  -h, --help Show this help
72
102
  `);
73
103
  process.exit(0);
@@ -90,7 +120,6 @@ if (Object.prototype.hasOwnProperty.call(CLI_TOOL_MAP, command)) {
90
120
  console.error(`Warning: ${command} is installed but not logged in.`);
91
121
  }
92
122
 
93
- // Check if daemon is running — if so, notify it via HTTP
94
123
  const daemonPid = isAlreadyRunning();
95
124
  const config = loadBackendConfig();
96
125
  const previous = config.activeBackend;
@@ -105,11 +134,9 @@ if (Object.prototype.hasOwnProperty.call(CLI_TOOL_MAP, command)) {
105
134
 
106
135
  console.log(`Switching backend to ${displayName(backendName)} (model: ${effectiveModel})...`);
107
136
 
108
- // Configure MCP (HTTP transport)
109
137
  const { configured, removed } = configureMCP(backendName, previous);
110
138
 
111
139
  if (configured) {
112
- // Notify daemon if running
113
140
  if (daemonPid) {
114
141
  try {
115
142
  const port = parseInt(process.env.ZHIHAND_PORT ?? "", 10) || 18686;
@@ -123,7 +150,6 @@ if (Object.prototype.hasOwnProperty.call(CLI_TOOL_MAP, command)) {
123
150
  console.log(`\nDaemon notified. Backend switched to ${displayName(backendName)}.`);
124
151
  }
125
152
  } catch {
126
- // Daemon not responding, just save config
127
153
  saveBackendConfig({ activeBackend: backendName, model: userModel });
128
154
  console.log(`\nBackend config saved. Daemon not responding — restart with 'zhihand start'.`);
129
155
  }
@@ -153,10 +179,8 @@ switch (command) {
153
179
 
154
180
  const args = [process.argv[1], "start"];
155
181
  if (values.port) args.push("--port", values.port);
156
- if (values.device) args.push("--device", values.device);
157
182
  if (values.debug) args.push("--debug");
158
183
 
159
- // Write daemon logs to ~/.zhihand/daemon.log
160
184
  const zhihandDir = pathMod.default.join(osMod.default.homedir(), ".zhihand");
161
185
  fsSync.default.mkdirSync(zhihandDir, { recursive: true });
162
186
  const logPath = pathMod.default.join(zhihandDir, "daemon.log");
@@ -174,48 +198,327 @@ switch (command) {
174
198
  process.exit(0);
175
199
  }
176
200
  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
- });
201
+ await startDaemon({ port, debug: values.debug });
182
202
  break;
183
203
  }
184
204
 
185
205
  case "stop": {
186
206
  const stopped = stopDaemon();
187
- if (stopped) {
188
- console.log("Daemon stopped.");
189
- } else {
190
- console.log("No daemon is running.");
191
- }
207
+ console.log(stopped ? "Daemon stopped." : "No daemon is running.");
192
208
  break;
193
209
  }
194
210
 
211
+ case "mcp":
195
212
  case "serve": {
196
- // Backward compatible: stdio MCP server (for old configs)
197
- await startStdioServer(values.device ?? process.env.ZHIHAND_DEVICE);
213
+ await startStdioServer();
198
214
  break;
199
215
  }
200
216
 
201
217
  case "pair": {
202
- const edgeId = `mcp-${Date.now().toString(36)}`;
203
- const deviceName = values.device ?? `mcp-${os.hostname()}`;
204
- await executePairing(DEFAULT_ENDPOINT, edgeId, deviceName);
218
+ const arg1 = positionals[1];
219
+ const label = values.label ?? undefined;
220
+
221
+ if (arg1 && arg1.startsWith("usr_")) {
222
+ // Add device to existing user
223
+ const deviceRecord = await executePairingAddDevice(arg1, label);
224
+ console.log(`\nDevice paired: ${deviceRecord.label} (${deviceRecord.credential_id})`);
225
+ } else {
226
+ // New user + first device
227
+ const { userRecord, deviceRecord } = await executePairingNewUser(label);
228
+ console.log(`\nUser created: ${userRecord.label} (${userRecord.user_id})`);
229
+ console.log(`Device paired: ${deviceRecord.label} (${deviceRecord.credential_id})`);
230
+ console.log(`\nAdd another device: zhihand pair ${userRecord.user_id}`);
231
+
232
+ // Auto-configure MCP hosts
233
+ const tools = await detectCLITools();
234
+ if (tools.length > 0) {
235
+ const best = tools.find((t) => t.loggedIn) ?? tools[0];
236
+ console.log(`\nAuto-configuring MCP for ${displayName(best.name)}...`);
237
+ const backendCfg = loadBackendConfig();
238
+ configureMCP(best.name, backendCfg.activeBackend);
239
+ saveBackendConfig({ activeBackend: best.name });
240
+ }
241
+ }
242
+ break;
243
+ }
244
+
245
+ case "list": {
246
+ const userIdFilter = positionals[1];
247
+ const users = listUsers();
248
+ const endpoint = resolveDefaultEndpoint();
249
+
250
+ if (users.length === 0) {
251
+ console.log("No users configured. Run: zhihand pair");
252
+ break;
253
+ }
254
+
255
+ const filteredUsers = userIdFilter
256
+ ? users.filter((u) => u.user_id === userIdFilter)
257
+ : users;
258
+
259
+ if (filteredUsers.length === 0) {
260
+ console.error(`User '${userIdFilter}' not found.`);
261
+ process.exit(1);
262
+ }
263
+
264
+ for (const user of filteredUsers) {
265
+ console.log(`\nUSER: ${user.label} (${user.user_id})`);
266
+
267
+ // Fetch real-time online status from server
268
+ let onlineMap = new Map();
269
+ try {
270
+ const creds = await fetchUserCredentials(endpoint, user.user_id, user.controller_token);
271
+ for (const c of creds) {
272
+ onlineMap.set(c.credential_id, c.online ?? false);
273
+ }
274
+ } catch {
275
+ // Fallback: no online status
276
+ }
277
+
278
+ if (user.devices.length === 0) {
279
+ console.log(" (no devices)");
280
+ continue;
281
+ }
282
+
283
+ const header = ["DEVICE_ID", "LABEL", "PLATFORM", "ONLINE", "PAIRED"];
284
+ const rows = user.devices.map((d) => [
285
+ d.credential_id,
286
+ d.label,
287
+ d.platform,
288
+ onlineMap.has(d.credential_id)
289
+ ? (onlineMap.get(d.credential_id) ? "yes" : "no")
290
+ : "?",
291
+ d.paired_at,
292
+ ]);
293
+ const widths = header.map((h, i) => Math.max(h.length, ...rows.map((row) => row[i].length)));
294
+ const fmt = (row) => row.map((c, i) => c.padEnd(widths[i])).join(" ");
295
+ console.log(" " + fmt(header));
296
+ console.log(" " + widths.map((w) => "-".repeat(w)).join(" "));
297
+ for (const r of rows) console.log(" " + fmt(r));
298
+ }
299
+ break;
300
+ }
301
+
302
+ case "unpair": {
303
+ const id = positionals[1];
304
+ if (!id) {
305
+ console.error("Usage: zhihand unpair <user_id | credential_id>");
306
+ process.exit(1);
307
+ }
308
+
309
+ const endpoint = resolveDefaultEndpoint();
310
+
311
+ if (id.startsWith("usr_")) {
312
+ // Delete user (cascade)
313
+ const user = getUserRecord(id);
314
+ if (!user) {
315
+ console.error(`User '${id}' not found.`);
316
+ process.exit(1);
317
+ }
318
+ // Best-effort server-side delete
319
+ try {
320
+ const res = await fetch(`${endpoint}/v1/users/${encodeURIComponent(id)}`, {
321
+ method: "DELETE",
322
+ headers: { "Authorization": `Bearer ${user.controller_token}` },
323
+ signal: AbortSignal.timeout(5000),
324
+ });
325
+ if (!res.ok) {
326
+ console.warn(`Warning: server delete returned ${res.status} (continuing to remove locally)`);
327
+ }
328
+ } catch (err) {
329
+ console.warn(`Warning: server delete failed: ${err.message} (continuing to remove locally)`);
330
+ }
331
+ removeUser(id);
332
+ console.log(`Removed user: ${id} (${user.label})`);
333
+ } else {
334
+ // Delete single credential
335
+ const owner = findDeviceOwner(id);
336
+ if (!owner) {
337
+ console.error(`Device '${id}' not found.`);
338
+ process.exit(1);
339
+ }
340
+ // Best-effort server-side delete
341
+ try {
342
+ const res = await fetch(`${endpoint}/v1/credentials/${encodeURIComponent(id)}`, {
343
+ method: "DELETE",
344
+ headers: { "Authorization": `Bearer ${owner.user.controller_token}` },
345
+ signal: AbortSignal.timeout(5000),
346
+ });
347
+ if (!res.ok) {
348
+ console.warn(`Warning: server delete returned ${res.status} (continuing to remove locally)`);
349
+ }
350
+ } catch (err) {
351
+ console.warn(`Warning: server delete failed: ${err.message} (continuing to remove locally)`);
352
+ }
353
+ removeDeviceFromUser(owner.user.user_id, id);
354
+ console.log(`Removed device: ${id}`);
355
+ }
356
+ break;
357
+ }
358
+
359
+ case "rename": {
360
+ const credId = positionals[1];
361
+ const newLabel = positionals[2];
362
+ if (!credId || !newLabel) {
363
+ console.error("Usage: zhihand rename <credential_id> <new_label>");
364
+ process.exit(1);
365
+ }
366
+ const owner = findDeviceOwner(credId);
367
+ if (!owner) {
368
+ console.error(`Device '${credId}' not found.`);
369
+ process.exit(1);
370
+ }
371
+ // Server-side PATCH
372
+ const endpoint = resolveDefaultEndpoint();
373
+ try {
374
+ const res = await fetch(`${endpoint}/v1/credentials/${encodeURIComponent(credId)}`, {
375
+ method: "PATCH",
376
+ headers: {
377
+ "Content-Type": "application/json",
378
+ "Authorization": `Bearer ${owner.user.controller_token}`,
379
+ },
380
+ body: JSON.stringify({ device_label: newLabel }),
381
+ signal: AbortSignal.timeout(5000),
382
+ });
383
+ if (!res.ok) {
384
+ console.warn(`Warning: server rename returned ${res.status}`);
385
+ }
386
+ } catch (err) {
387
+ console.warn(`Warning: server rename failed: ${err.message}`);
388
+ }
389
+ updateDeviceLabel(owner.user.user_id, credId, newLabel);
390
+ console.log(`Renamed ${credId} to '${newLabel}'`);
391
+ break;
392
+ }
393
+
394
+ case "export": {
395
+ const userId = positionals[1];
396
+ if (!userId) {
397
+ console.error("Usage: zhihand export <user_id>");
398
+ process.exit(1);
399
+ }
400
+ const user = getUserRecord(userId);
401
+ if (!user) {
402
+ console.error(`User '${userId}' not found.`);
403
+ process.exit(1);
404
+ }
405
+ // Plain JSON to stdout
406
+ console.log(JSON.stringify({ user_id: user.user_id, controller_token: user.controller_token }, null, 2));
407
+ break;
408
+ }
409
+
410
+ case "import": {
411
+ const filePath = positionals[1];
412
+ if (!filePath) {
413
+ console.error("Usage: zhihand import <file>");
414
+ process.exit(1);
415
+ }
416
+ let data;
417
+ try {
418
+ data = JSON.parse(fs.readFileSync(filePath, "utf8"));
419
+ } catch (err) {
420
+ console.error(`Error reading file: ${err.message}`);
421
+ process.exit(1);
422
+ }
423
+ if (!data.user_id || !data.controller_token) {
424
+ console.error("Invalid import file: must contain user_id and controller_token");
425
+ process.exit(1);
426
+ }
427
+ // Validate by fetching user info from server
428
+ const endpoint = resolveDefaultEndpoint();
429
+ let serverUser;
430
+ try {
431
+ const res = await fetch(`${endpoint}/v1/users/${encodeURIComponent(data.user_id)}`, {
432
+ headers: { "Authorization": `Bearer ${data.controller_token}` },
433
+ signal: AbortSignal.timeout(10000),
434
+ });
435
+ if (!res.ok) {
436
+ console.error(`Server validation failed: ${res.status}`);
437
+ process.exit(1);
438
+ }
439
+ serverUser = await res.json();
440
+ } catch (err) {
441
+ console.error(`Server validation failed: ${err.message}`);
442
+ process.exit(1);
443
+ }
444
+ // Fetch credentials
445
+ let creds = [];
446
+ try {
447
+ creds = await fetchUserCredentials(endpoint, data.user_id, data.controller_token);
448
+ } catch {
449
+ // Non-fatal
450
+ }
451
+ const devices = creds.map((c) => ({
452
+ credential_id: c.credential_id,
453
+ label: c.label ?? c.credential_id,
454
+ platform: c.platform ?? "unknown",
455
+ paired_at: c.paired_at ?? new Date().toISOString(),
456
+ last_seen_at: c.last_seen_at ?? new Date().toISOString(),
457
+ }));
458
+ const userRecord = {
459
+ user_id: data.user_id,
460
+ controller_token: data.controller_token,
461
+ label: serverUser.label ?? data.user_id,
462
+ created_at: serverUser.created_at ?? new Date().toISOString(),
463
+ devices,
464
+ };
465
+ addUser(userRecord);
466
+ console.log(`Imported user: ${userRecord.label} (${userRecord.user_id}) with ${devices.length} device(s)`);
467
+ break;
468
+ }
469
+
470
+ case "rotate": {
471
+ const userId = positionals[1];
472
+ if (!userId) {
473
+ console.error("Usage: zhihand rotate <user_id>");
474
+ process.exit(1);
475
+ }
476
+ const user = getUserRecord(userId);
477
+ if (!user) {
478
+ console.error(`User '${userId}' not found.`);
479
+ process.exit(1);
480
+ }
481
+ const endpoint = resolveDefaultEndpoint();
482
+ // Find current token ID (we need to pass it in the URL)
483
+ // The API is POST /v1/users/{id}/controller-tokens/{token}/rotate
484
+ try {
485
+ const res = await fetch(
486
+ `${endpoint}/v1/users/${encodeURIComponent(userId)}/controller-tokens/${encodeURIComponent(user.controller_token)}/rotate`,
487
+ {
488
+ method: "POST",
489
+ headers: { "Authorization": `Bearer ${user.controller_token}` },
490
+ signal: AbortSignal.timeout(10000),
491
+ },
492
+ );
493
+ if (!res.ok) {
494
+ console.error(`Rotate failed: ${res.status}`);
495
+ process.exit(1);
496
+ }
497
+ const result = await res.json();
498
+ updateControllerToken(userId, result.new_token);
499
+ console.log(`Token rotated for ${userId}. New token saved.`);
500
+ } catch (err) {
501
+ console.error(`Rotate failed: ${err.message}`);
502
+ process.exit(1);
503
+ }
205
504
  break;
206
505
  }
207
506
 
208
507
  case "status": {
209
- const cred = loadDefaultCredential();
508
+ const users = listUsers();
210
509
  const backend = loadBackendConfig();
211
510
  const daemonPid = isAlreadyRunning();
212
511
 
213
- if (cred) {
214
- console.log(`Paired device: ${cred.deviceName}`);
215
- console.log(`Credential ID: ${cred.credentialId}`);
216
- console.log(`Endpoint: ${cred.endpoint}`);
512
+ if (users.length === 0) {
513
+ console.log("No users configured. Run: zhihand setup");
217
514
  } else {
218
- console.log("No paired device. Run: zhihand setup");
515
+ console.log(`Users: ${users.length}`);
516
+ for (const u of users) {
517
+ console.log(` ${u.user_id} (${u.label}) — ${u.devices.length} device(s)`);
518
+ for (const d of u.devices) {
519
+ console.log(` ${d.credential_id} (${d.label}, ${d.platform})`);
520
+ }
521
+ }
219
522
  }
220
523
  const backendLabel = backend.activeBackend ? displayName(backend.activeBackend) : "(none)";
221
524
  const modelLabel = backend.activeBackend
@@ -224,7 +527,6 @@ switch (command) {
224
527
  console.log(`Active backend: ${backendLabel} (model: ${modelLabel})`);
225
528
  console.log(`Daemon: ${daemonPid ? `running (PID ${daemonPid})` : "not running"}`);
226
529
 
227
- // If daemon running, get live status
228
530
  if (daemonPid) {
229
531
  try {
230
532
  const port = parseInt(process.env.ZHIHAND_PORT ?? "", 10) || 18686;
@@ -248,20 +550,13 @@ switch (command) {
248
550
  }
249
551
 
250
552
  case "setup": {
251
- // 1. Pair
252
- let cred = loadDefaultCredential();
253
- if (!cred) {
254
- console.log("No paired device found. Starting pairing...\n");
255
- 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();
259
- }
260
- if (cred) {
261
- console.log(`\nPaired: ${cred.deviceName} (${cred.credentialId})\n`);
553
+ const users = listUsers();
554
+ if (users.length === 0) {
555
+ console.log("No users found. Starting pairing...\n");
556
+ const { userRecord } = await executePairingNewUser(values.label);
557
+ console.log(`\nUser created: ${userRecord.label} (${userRecord.user_id})\n`);
262
558
  }
263
559
 
264
- // 2. Detect tools
265
560
  const tools = await detectCLITools();
266
561
  console.log(formatDetectedTools(tools));
267
562
 
@@ -270,88 +565,51 @@ switch (command) {
270
565
  break;
271
566
  }
272
567
 
273
- // 3. Auto-select best tool and configure MCP (HTTP transport)
274
568
  const best = tools.find((t) => t.loggedIn) ?? tools[0];
275
569
  const config = loadBackendConfig();
276
570
 
277
571
  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
- }
572
+ if (values.port) process.env.ZHIHAND_PORT = values.port;
282
573
  configureMCP(best.name, config.activeBackend);
283
574
  saveBackendConfig({ activeBackend: best.name });
284
575
 
285
- // 4. Start daemon
286
576
  console.log(`\nStarting daemon...\n`);
287
577
  const port = values.port ? parseInt(values.port, 10) : undefined;
288
- await startDaemon({
289
- port,
290
- deviceName: values.device ?? process.env.ZHIHAND_DEVICE,
291
- });
578
+ await startDaemon({ port });
292
579
  break;
293
580
  }
294
581
 
295
- case "list":
296
582
  case "test": {
297
- const { resolveConfig: resolveTestConfig } = await import("../dist/core/config.js");
298
583
  const { createControlCommand, createSystemCommand, enqueueCommand } = await import("../dist/core/command.js");
299
584
  const { waitForCommandAck } = await import("../dist/core/sse.js");
300
585
  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.
586
+ const { fetchDeviceProfileOnce, extractStatic, computeCapabilities, formatDeviceStatus } = await import("../dist/core/device.js");
587
+
318
588
  const KIND_CAPABILITY = {
319
- profile: "none",
320
- status: "none",
321
- screenshot: "screen",
322
- hid: "hid",
323
- system: "none",
589
+ profile: "none", status: "none", screenshot: "screen", hid: "hid", system: "none",
324
590
  };
325
591
  const REGISTRY = [
326
- // Phase A — Device Info API
327
592
  { id: 1, phase: "Device Info", label: "Fetch device profile", kind: "profile" },
328
593
  { id: 2, phase: "Device Info", label: "Device status fields", kind: "status" },
329
- // Phase B — Screenshot
330
594
  { id: 3, phase: "Screenshot", label: "Screenshot", kind: "screenshot" },
331
- // Phase C — Tap / Touch
332
595
  { id: 4, phase: "Tap/Touch", label: "Click center", kind: "hid", params: { action: "click", xRatio: 0.5, yRatio: 0.5 } },
333
596
  { id: 5, phase: "Tap/Touch", label: "Double click", kind: "hid", params: { action: "doubleclick", xRatio: 0.5, yRatio: 0.5 } },
334
597
  { id: 6, phase: "Tap/Touch", label: "Long click (800ms)", kind: "hid", params: { action: "longclick", xRatio: 0.5, yRatio: 0.5, durationMs: 800 } },
335
598
  { id: 7, phase: "Tap/Touch", label: "Right click", kind: "hid", params: { action: "rightclick", xRatio: 0.5, yRatio: 0.5 } },
336
599
  { 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
600
  { 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
601
  { 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
602
  { 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
603
  { 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
604
  { id: 13, phase: "Swipe/Scroll", label: "Scroll down", kind: "hid", params: { action: "scroll", xRatio: 0.5, yRatio: 0.5, direction: "down", amount: 3 } },
343
605
  { 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
606
  { id: 15, phase: "Text+Keys", label: "Type text", kind: "hid", params: { action: "type", text: "zhihand" } },
346
607
  { id: 16, phase: "Text+Keys", label: "Enter key", kind: "hid", params: { action: "enter" } },
347
608
  { id: 17, phase: "Text+Keys", label: "Key combo (select all)", kind: "hid", platformAware: "select_all" },
348
- // Phase F — App Navigation
349
609
  { id: 18, phase: "Navigation", label: "Press Home", kind: "hid", params: { action: "home" } },
350
610
  { id: 19, phase: "Navigation", label: "Press Back", kind: "hid", params: { action: "back" } },
351
611
  { id: 20, phase: "Navigation", label: "Open WeChat", kind: "hid", platformAware: "open_wechat" },
352
- // Phase G — Clipboard
353
612
  { id: 21, phase: "Clipboard", label: "Clipboard set", kind: "hid", platformAware: "clipboard_set" },
354
- // Phase H — System Navigation
355
613
  { id: 22, phase: "System Nav", label: "Notification shade", kind: "system", params: { action: "notification" } },
356
614
  { id: 23, phase: "System Nav", label: "Recent apps", kind: "system", params: { action: "recent" } },
357
615
  { id: 24, phase: "System Nav", label: "Search (query='zhihand')", kind: "system", params: { action: "search", text: "zhihand" } },
@@ -360,7 +618,6 @@ switch (command) {
360
618
  { id: 27, phase: "System Nav", label: "Control Center", kind: "system", params: { action: "control_center" }, platform: "ios" },
361
619
  { id: 28, phase: "System Nav", label: "Open browser", kind: "system", params: { action: "open_browser" }, platform: "android" },
362
620
  { id: 29, phase: "System Nav", label: "Shortcut help", kind: "system", params: { action: "shortcut_help" }, platform: "android" },
363
- // Phase I — Media
364
621
  { id: 30, phase: "Media", label: "Volume up", kind: "system", params: { action: "volume_up" } },
365
622
  { id: 31, phase: "Media", label: "Volume down", kind: "system", params: { action: "volume_down" } },
366
623
  { id: 32, phase: "Media", label: "Mute toggle", kind: "system", params: { action: "mute" } },
@@ -370,50 +627,36 @@ switch (command) {
370
627
  { id: 36, phase: "Media", label: "Fast forward", kind: "system", params: { action: "fast_forward" } },
371
628
  { id: 37, phase: "Media", label: "Rewind", kind: "system", params: { action: "rewind" } },
372
629
  { id: 38, phase: "Media", label: "Stop", kind: "system", params: { action: "stop" } },
373
- // Phase J — Hardware
374
630
  { id: 39, phase: "Hardware", label: "Brightness up", kind: "system", params: { action: "brightness_up" } },
375
631
  { id: 40, phase: "Hardware", label: "Brightness down", kind: "system", params: { action: "brightness_down" } },
376
- { id: 41, phase: "Hardware", label: "Power button (⚠️ may lock screen)", kind: "system", params: { action: "power" }, unsafe: true },
632
+ { id: 41, phase: "Hardware", label: "Power button (may lock screen)", kind: "system", params: { action: "power" }, unsafe: true },
377
633
  ];
378
634
 
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}`);
635
+ // Parse first positional: credential_id (crd_*) or test ids
636
+ let credentialArg = values.device ?? process.env.ZHIHAND_DEVICE;
637
+ let filterArg = null;
638
+ const arg1 = positionals[1];
639
+ const arg2 = positionals[2];
640
+ if (arg1) {
641
+ if (/^crd_/.test(arg1)) {
642
+ credentialArg = arg1;
643
+ filterArg = arg2 ?? null;
644
+ } else {
645
+ filterArg = arg1;
393
646
  }
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
647
  }
402
648
 
403
- // ── "test" sub-command ───────────────────────────────────
404
649
  let testConfig;
405
650
  try {
406
- testConfig = resolveTestConfig(values.device ?? process.env.ZHIHAND_DEVICE);
651
+ testConfig = resolveConfig(credentialArg);
407
652
  } catch (err) {
408
653
  console.error(`Error: ${err.message}`);
409
- console.error("Run 'zhihand setup' to pair a device first.");
654
+ console.error("Run 'zhihand pair' to add a device first.");
410
655
  process.exit(1);
411
656
  }
412
657
 
413
- // Parse which tests to run from positional args
414
- const filterArg = positionals[1]; // e.g. "4" or "4,9,20" or "all"
415
658
  const forceRun = values.force === true;
416
- let selectedIds = null; // null = default (all safe)
659
+ let selectedIds = null;
417
660
  let includeUnsafe = false;
418
661
  if (filterArg) {
419
662
  if (filterArg === "all") {
@@ -423,56 +666,55 @@ switch (command) {
423
666
  filterArg.split(",").map((s) => {
424
667
  const trimmed = s.trim();
425
668
  const n = Number(trimmed);
426
- // Strict: reject ranges like "4-10" (Number returns NaN), floats, empty
427
669
  return Number.isInteger(n) && n > 0 ? n : NaN;
428
670
  }).filter((n) => !isNaN(n))
429
671
  );
430
672
  if (selectedIds.size === 0) {
431
673
  console.error(`Invalid test IDs: ${filterArg}`);
432
- console.error("Run 'zhihand list' to see available tests.");
433
674
  process.exit(1);
434
675
  }
435
- // Explicit selection implies user knows what they're doing
436
676
  includeUnsafe = true;
437
677
  }
438
678
  }
439
679
 
440
- console.log("🧪 ZhiHand Device Test");
680
+ console.log("ZhiHand Device Test");
441
681
  console.log(` Device: ${testConfig.credentialId}`);
442
682
  console.log(` Endpoint: ${testConfig.controlPlaneEndpoint}\n`);
443
683
 
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.
684
+ // Pre-fetch device profile
685
+ let currentRawAttrs = {};
686
+ let currentProfile = null;
687
+ let currentCaps = null;
688
+ let profileReceivedAtMs = 0;
447
689
  try {
448
- await fetchDeviceProfile(testConfig);
449
- } catch { /* non-fatal — Test 1 will retry and platform will update */ }
450
- const getDevicePlatform = () => isDeviceProfileLoaded() ? getStaticContext().platform : "unknown";
451
-
452
- // ── Capability readiness pre-flight ──
453
- console.log(" ── Capability readiness ──");
454
- const preCaps = isDeviceProfileLoaded() ? getCapabilities() : null;
455
- if (!preCaps) {
456
- console.log(" ⚠️ Device profile not loaded all capability gates will allow tests through.");
690
+ const fetched = await fetchDeviceProfileOnce(testConfig);
691
+ if (fetched) {
692
+ currentRawAttrs = fetched.rawAttrs;
693
+ profileReceivedAtMs = fetched.receivedAtMs;
694
+ currentProfile = extractStatic(currentRawAttrs);
695
+ currentCaps = computeCapabilities(currentRawAttrs, profileReceivedAtMs);
696
+ }
697
+ } catch { /* non-fatal */ }
698
+ const getDevicePlatform = () => currentProfile?.platform ?? "unknown";
699
+
700
+ console.log(" -- Capability readiness --");
701
+ if (!currentCaps) {
702
+ console.log(" [!] Device profile not loaded — all capability gates will allow tests through.");
457
703
  } else {
458
- 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)" : ""}`);
704
+ const fmt = (name, cap) => ` ${cap.ready ? "[ok]" : "[!]"} ${name.padEnd(14)} ${cap.ready ? "ready" : "NOT ready"} — ${cap.reason}`;
705
+ console.log(fmt("screen_sharing", currentCaps.screen_sharing));
706
+ console.log(fmt("hid", currentCaps.hid));
707
+ console.log(fmt("live_session", currentCaps.live_session));
708
+ const ageStr = currentCaps.profile.age_ms >= 0 ? `${(currentCaps.profile.age_ms / 1000).toFixed(1)}s` : "unknown";
709
+ console.log(` ${currentCaps.profile.stale ? "[!]" : "[ok]"} profile age=${ageStr}${currentCaps.profile.stale ? " (STALE)" : ""}`);
464
710
  if (forceRun) {
465
711
  console.log(" --force passed: capability gates disabled.");
466
712
  }
467
713
  }
468
714
  console.log("");
469
715
 
470
- let passed = 0;
471
- let failed = 0;
472
- let skipped = 0;
473
- let totalSteps = 0;
716
+ let passed = 0, failed = 0, skipped = 0, totalSteps = 0;
474
717
 
475
- // ── Resolve platform-aware params (evaluated at test time) ──
476
718
  function resolvePlatformAwareParams(variant) {
477
719
  const platform = getDevicePlatform();
478
720
  if (variant === "open_wechat") {
@@ -490,7 +732,6 @@ switch (command) {
490
732
  return null;
491
733
  }
492
734
 
493
- // ── Test runners (shared ACK logic) ──
494
735
  async function runCommandTest(t, command) {
495
736
  totalSteps++;
496
737
  process.stdout.write(` ${String(t.id).padStart(2)}. ${t.label}... `);
@@ -503,43 +744,36 @@ switch (command) {
503
744
  const ackStatus = ack.command?.ack_status ?? "ok";
504
745
  const resultInfo = ack.command?.ack_result ? ` ${JSON.stringify(ack.command.ack_result)}` : "";
505
746
  if (ackStatus === "ok") {
506
- console.log(`✅ (${ms}ms)${resultInfo}`);
747
+ console.log(`[PASS] (${ms}ms)${resultInfo}`);
507
748
  passed++;
508
749
  } else {
509
- console.log(`❌ [${ackStatus}] (${ms}ms)${resultInfo}`);
750
+ console.log(`[FAIL] [${ackStatus}] (${ms}ms)${resultInfo}`);
510
751
  failed++;
511
752
  }
512
753
  } else {
513
- console.log(`⏱️ Timeout (${ms}ms)`);
754
+ console.log(`[TIMEOUT] (${ms}ms)`);
514
755
  failed++;
515
756
  }
516
757
  } catch (err) {
517
- console.log(`❌ ${err.message} (${Date.now() - t0}ms)`);
758
+ console.log(`[FAIL] ${err.message} (${Date.now() - t0}ms)`);
518
759
  failed++;
519
760
  }
520
761
  }
521
762
 
522
763
  async function runSingleTest(t) {
523
- // Platform skip (evaluated at test time — Test 1 may have just populated the profile)
524
764
  const currentPlatform = getDevicePlatform();
525
765
  if (t.platform && t.platform !== currentPlatform) {
526
- totalSteps++;
527
- skipped++;
528
- console.log(` ${String(t.id).padStart(2)}. ${t.label}... ⏭️ Skipped (${t.platform}-only, device is ${currentPlatform})`);
766
+ totalSteps++; skipped++;
767
+ console.log(` ${String(t.id).padStart(2)}. ${t.label}... [SKIP] (${t.platform}-only, device is ${currentPlatform})`);
529
768
  return;
530
769
  }
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()) {
770
+ if (!forceRun && currentCaps) {
535
771
  const requiredCap = KIND_CAPABILITY[t.kind] ?? "none";
536
772
  if (requiredCap !== "none") {
537
- const caps = getCapabilities();
538
- const gate = requiredCap === "screen" ? caps.screen_sharing : caps.hid;
773
+ const gate = requiredCap === "screen" ? currentCaps.screen_sharing : currentCaps.hid;
539
774
  if (!gate.ready) {
540
- totalSteps++;
541
- skipped++;
542
- console.log(` ${String(t.id).padStart(2)}. ${t.label}... ⏭️ Skipped (${requiredCap} not ready: ${gate.reason})`);
775
+ totalSteps++; skipped++;
776
+ console.log(` ${String(t.id).padStart(2)}. ${t.label}... [SKIP] (${requiredCap} not ready: ${gate.reason})`);
543
777
  return;
544
778
  }
545
779
  }
@@ -551,18 +785,22 @@ switch (command) {
551
785
  process.stdout.write(` ${String(t.id).padStart(2)}. ${t.label}... `);
552
786
  const t0 = Date.now();
553
787
  try {
554
- await fetchDeviceProfile(testConfig);
788
+ const fetched = await fetchDeviceProfileOnce(testConfig);
555
789
  const ms = Date.now() - t0;
556
- if (isDeviceProfileLoaded()) {
557
- const s = getStaticContext();
558
- console.log(`✅ ${s.platform} ${s.model}, ${s.osVersion}, ${s.screenWidthPx}x${s.screenHeightPx} (${ms}ms)`);
790
+ if (fetched) {
791
+ currentRawAttrs = fetched.rawAttrs;
792
+ profileReceivedAtMs = fetched.receivedAtMs;
793
+ currentProfile = extractStatic(currentRawAttrs);
794
+ currentCaps = computeCapabilities(currentRawAttrs, profileReceivedAtMs);
795
+ const s = currentProfile;
796
+ console.log(`[PASS] ${s.platform} ${s.model}, ${s.osVersion}, ${s.screenWidthPx}x${s.screenHeightPx} (${ms}ms)`);
559
797
  passed++;
560
798
  } else {
561
- console.log(`⚠️ Loaded but empty (${ms}ms)`);
799
+ console.log(`[!] Loaded but empty (${ms}ms)`);
562
800
  failed++;
563
801
  }
564
802
  } catch (err) {
565
- console.log(`❌ ${err.message} (${Date.now() - t0}ms)`);
803
+ console.log(`[FAIL] ${err.message} (${Date.now() - t0}ms)`);
566
804
  failed++;
567
805
  }
568
806
  break;
@@ -571,23 +809,33 @@ switch (command) {
571
809
  totalSteps++;
572
810
  process.stdout.write(` ${String(t.id).padStart(2)}. ${t.label}... `);
573
811
  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.
812
+ const state = {
813
+ credentialId: testConfig.credentialId,
814
+ userId: "",
815
+ userLabel: "",
816
+ label: "(test)",
817
+ platform: getDevicePlatform(),
818
+ online: true,
819
+ lastSeenAtMs: Date.now(),
820
+ profile: currentProfile,
821
+ capabilities: currentCaps,
822
+ profileReceivedAtMs,
823
+ rawAttributes: currentRawAttrs,
824
+ record: { credential_id: testConfig.credentialId, label: "", platform: "unknown", paired_at: "", last_seen_at: "" },
825
+ };
826
+ const status = formatDeviceStatus(state);
579
827
  const topLevel = Object.keys(status).filter((k) => k !== "raw" && k !== "capabilities");
580
828
  const rawKeys = Object.keys(status.raw ?? {});
581
829
  const caps = status.capabilities ?? {};
582
830
  const capReadySummary = ["screen_sharing", "hid", "live_session"]
583
831
  .map((k) => `${k}=${caps[k]?.ready ? "ready" : "not-ready"}`)
584
832
  .join(", ");
585
- console.log(`✅ ${topLevel.length} curated + ${rawKeys.length} raw attributes; ${capReadySummary}`);
833
+ console.log(`[PASS] ${topLevel.length} curated + ${rawKeys.length} raw attributes; ${capReadySummary}`);
586
834
  console.log(` curated: ${topLevel.join(", ")}`);
587
835
  console.log(` raw: ${rawKeys.join(", ")}`);
588
836
  passed++;
589
837
  } catch (err) {
590
- console.log(`❌ ${err.message}`);
838
+ console.log(`[FAIL] ${err.message}`);
591
839
  failed++;
592
840
  }
593
841
  break;
@@ -601,37 +849,34 @@ switch (command) {
601
849
  const queued = await enqueueCommand(testConfig, cmd);
602
850
  const ack = await waitForCommandAck(testConfig, { commandId: queued.id, timeoutMs: 10_000 });
603
851
  if (!ack.acked) {
604
- console.log(`⏱️ Timeout (${Date.now() - t0}ms)`);
852
+ console.log(`[TIMEOUT] (${Date.now() - t0}ms)`);
605
853
  failed++;
606
854
  break;
607
855
  }
608
856
  const shot = await fetchScreenshot(testConfig);
609
857
  const kb = (shot.buffer.length / 1024).toFixed(0);
610
858
  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
859
  if (shot.stale) {
615
860
  const threshold = getSnapshotStaleThresholdMs();
616
- 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)`);
861
+ console.log(`[FAIL] 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)`);
617
862
  failed++;
618
863
  } else {
619
- console.log(`✅ ${kb}KB, ${shot.width}x${shot.height}, age=${shot.ageMs >= 0 ? `${shot.ageMs}ms` : "?"}, seq=${shot.sequence} (${ms}ms)`);
864
+ console.log(`[PASS] ${kb}KB, ${shot.width}x${shot.height}, age=${shot.ageMs >= 0 ? `${shot.ageMs}ms` : "?"}, seq=${shot.sequence} (${ms}ms)`);
620
865
  passed++;
621
866
  }
622
867
  } catch (err) {
623
- console.log(`❌ ${err.message} (${Date.now() - t0}ms)`);
868
+ console.log(`[FAIL] ${err.message} (${Date.now() - t0}ms)`);
624
869
  failed++;
625
870
  }
626
871
  break;
627
872
  }
628
873
  case "hid": {
629
874
  const params = t.platformAware ? resolvePlatformAwareParams(t.platformAware) : t.params;
630
- await runCommandTest(t, createControlCommand(params));
875
+ await runCommandTest(t, createControlCommand(params, getDevicePlatform()));
631
876
  break;
632
877
  }
633
878
  case "system": {
634
- await runCommandTest(t, createSystemCommand(t.params));
879
+ await runCommandTest(t, createSystemCommand(t.params, getDevicePlatform()));
635
880
  break;
636
881
  }
637
882
  }
@@ -639,7 +884,6 @@ switch (command) {
639
884
 
640
885
  const pause = () => new Promise((r) => setTimeout(r, 1500));
641
886
 
642
- // Select tests to run
643
887
  const toRun = REGISTRY.filter((t) => {
644
888
  if (selectedIds) return selectedIds.has(t.id);
645
889
  if (t.unsafe && !includeUnsafe) return false;
@@ -648,37 +892,29 @@ switch (command) {
648
892
 
649
893
  if (toRun.length === 0) {
650
894
  console.error("No matching tests.");
651
- console.error("Run 'zhihand list' to see available tests.");
652
895
  process.exit(1);
653
896
  }
654
897
 
655
- // Warn about missing IDs
656
898
  if (selectedIds) {
657
899
  const foundIds = new Set(toRun.map((t) => t.id));
658
900
  const missing = [...selectedIds].filter((id) => !foundIds.has(id));
659
- if (missing.length) {
660
- console.warn(`⚠️ Unknown test IDs: ${missing.join(", ")}`);
661
- }
901
+ if (missing.length) console.warn(`[!] Unknown test IDs: ${missing.join(", ")}`);
662
902
  }
663
903
 
664
904
  let currentPhase = "";
665
905
  for (let i = 0; i < toRun.length; i++) {
666
906
  const t = toRun[i];
667
907
  if (t.phase !== currentPhase) {
668
- console.log(` ── ${t.phase} ──`);
908
+ console.log(` -- ${t.phase} --`);
669
909
  currentPhase = t.phase;
670
910
  }
671
911
  await runSingleTest(t);
672
912
  if (i < toRun.length - 1) await pause();
673
913
  }
674
914
 
675
- // ── Summary ──────────────────────────────────────────────
676
915
  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
- }
916
+ if (failed === 0) console.log(" All tests passed! Device is fully responsive.");
917
+ else console.log(` ${failed} test(s) failed. Check phone connectivity.`);
682
918
  process.exit(failed > 0 ? 1 : 0);
683
919
  }
684
920