@zhihand/mcp 0.28.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,48 +33,58 @@ 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 },
42
+ force: { type: "boolean", default: false },
30
43
  port: { type: "string" },
31
44
  },
32
45
  });
33
46
 
34
- 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
+ }
35
53
 
36
54
  if (values.help) {
37
55
  console.log(`
38
- zhihand — MCP Server and Relay for phone control
56
+ zhihand v${VERSION} — MCP Server and Relay for phone control
39
57
 
40
58
  Usage:
41
59
  zhihand start Start daemon (MCP Server + Relay, foreground)
42
60
  zhihand start -d Start daemon in background (detach)
43
- zhihand start --debug Start daemon with verbose debug logging
44
61
  zhihand stop Stop daemon
45
62
  zhihand status Show status (pairing, backend, brain)
46
63
 
47
- zhihand gemini Switch backend to Gemini CLI (default model: flash)
48
- zhihand claude Switch backend to Claude Code (default model: sonnet)
49
- zhihand codex Switch backend to Codex CLI (default model: gpt-5.4-mini)
50
- 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
51
67
 
52
68
  zhihand setup Interactive setup: pair + configure + start
53
- 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
54
74
  zhihand detect Detect available CLI tools
55
75
 
56
- zhihand list List all available tests with IDs
57
- zhihand test Run all safe device tests
58
- zhihand test <ids> Run specific test(s), e.g. 'zhihand test 4' or '4,9,20'
59
- zhihand test all Run ALL tests (including unsafe, e.g. power button)
60
- 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)
61
78
 
62
79
  Options:
63
- --device <name> Use a specific paired device
64
- --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
65
83
  --port <port> Override daemon port (default: 18686)
66
84
  -d, --detach Run daemon in background
67
- --debug Enable verbose debug logging
85
+ --debug Verbose debug logging
86
+ --force (test only) Run tests even if capability not ready
87
+ -v, --version Print version
68
88
  -h, --help Show this help
69
89
  `);
70
90
  process.exit(0);
@@ -87,7 +107,6 @@ if (Object.prototype.hasOwnProperty.call(CLI_TOOL_MAP, command)) {
87
107
  console.error(`Warning: ${command} is installed but not logged in.`);
88
108
  }
89
109
 
90
- // Check if daemon is running — if so, notify it via HTTP
91
110
  const daemonPid = isAlreadyRunning();
92
111
  const config = loadBackendConfig();
93
112
  const previous = config.activeBackend;
@@ -102,11 +121,9 @@ if (Object.prototype.hasOwnProperty.call(CLI_TOOL_MAP, command)) {
102
121
 
103
122
  console.log(`Switching backend to ${displayName(backendName)} (model: ${effectiveModel})...`);
104
123
 
105
- // Configure MCP (HTTP transport)
106
124
  const { configured, removed } = configureMCP(backendName, previous);
107
125
 
108
126
  if (configured) {
109
- // Notify daemon if running
110
127
  if (daemonPid) {
111
128
  try {
112
129
  const port = parseInt(process.env.ZHIHAND_PORT ?? "", 10) || 18686;
@@ -120,7 +137,6 @@ if (Object.prototype.hasOwnProperty.call(CLI_TOOL_MAP, command)) {
120
137
  console.log(`\nDaemon notified. Backend switched to ${displayName(backendName)}.`);
121
138
  }
122
139
  } catch {
123
- // Daemon not responding, just save config
124
140
  saveBackendConfig({ activeBackend: backendName, model: userModel });
125
141
  console.log(`\nBackend config saved. Daemon not responding — restart with 'zhihand start'.`);
126
142
  }
@@ -150,10 +166,8 @@ switch (command) {
150
166
 
151
167
  const args = [process.argv[1], "start"];
152
168
  if (values.port) args.push("--port", values.port);
153
- if (values.device) args.push("--device", values.device);
154
169
  if (values.debug) args.push("--debug");
155
170
 
156
- // Write daemon logs to ~/.zhihand/daemon.log
157
171
  const zhihandDir = pathMod.default.join(osMod.default.homedir(), ".zhihand");
158
172
  fsSync.default.mkdirSync(zhihandDir, { recursive: true });
159
173
  const logPath = pathMod.default.join(zhihandDir, "daemon.log");
@@ -171,48 +185,121 @@ switch (command) {
171
185
  process.exit(0);
172
186
  }
173
187
  const port = values.port ? parseInt(values.port, 10) : undefined;
174
- await startDaemon({
175
- port,
176
- deviceName: values.device ?? process.env.ZHIHAND_DEVICE,
177
- debug: values.debug,
178
- });
188
+ await startDaemon({ port, debug: values.debug });
179
189
  break;
180
190
  }
181
191
 
182
192
  case "stop": {
183
193
  const stopped = stopDaemon();
184
- if (stopped) {
185
- console.log("Daemon stopped.");
186
- } else {
187
- console.log("No daemon is running.");
188
- }
194
+ console.log(stopped ? "Daemon stopped." : "No daemon is running.");
189
195
  break;
190
196
  }
191
197
 
198
+ case "mcp":
192
199
  case "serve": {
193
- // Backward compatible: stdio MCP server (for old configs)
194
- await startStdioServer(values.device ?? process.env.ZHIHAND_DEVICE);
200
+ await startStdioServer();
195
201
  break;
196
202
  }
197
203
 
198
204
  case "pair": {
199
205
  const edgeId = `mcp-${Date.now().toString(36)}`;
200
- const deviceName = values.device ?? `mcp-${os.hostname()}`;
201
- 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}`);
202
286
  break;
203
287
  }
204
288
 
205
289
  case "status": {
206
- const cred = loadDefaultCredential();
290
+ const records = listDeviceRecords();
291
+ const cfg = loadConfig();
207
292
  const backend = loadBackendConfig();
208
293
  const daemonPid = isAlreadyRunning();
209
294
 
210
- if (cred) {
211
- console.log(`Paired device: ${cred.deviceName}`);
212
- console.log(`Credential ID: ${cred.credentialId}`);
213
- console.log(`Endpoint: ${cred.endpoint}`);
295
+ if (records.length === 0) {
296
+ console.log("No paired devices. Run: zhihand setup");
214
297
  } else {
215
- 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
+ }
216
303
  }
217
304
  const backendLabel = backend.activeBackend ? displayName(backend.activeBackend) : "(none)";
218
305
  const modelLabel = backend.activeBackend
@@ -221,7 +308,6 @@ switch (command) {
221
308
  console.log(`Active backend: ${backendLabel} (model: ${modelLabel})`);
222
309
  console.log(`Daemon: ${daemonPid ? `running (PID ${daemonPid})` : "not running"}`);
223
310
 
224
- // If daemon running, get live status
225
311
  if (daemonPid) {
226
312
  try {
227
313
  const port = parseInt(process.env.ZHIHAND_PORT ?? "", 10) || 18686;
@@ -245,20 +331,19 @@ switch (command) {
245
331
  }
246
332
 
247
333
  case "setup": {
248
- // 1. Pair
249
- let cred = loadDefaultCredential();
250
- if (!cred) {
334
+ const records = listDeviceRecords();
335
+ if (records.length === 0) {
251
336
  console.log("No paired device found. Starting pairing...\n");
252
337
  const edgeId = `mcp-${Date.now().toString(36)}`;
253
- const deviceName = values.device ?? `mcp-${os.hostname()}`;
254
- await executePairing(DEFAULT_ENDPOINT, edgeId, deviceName);
255
- cred = loadDefaultCredential();
338
+ await executePairing(DEFAULT_ENDPOINT, edgeId, values.label);
256
339
  }
257
- if (cred) {
258
- 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`);
259
345
  }
260
346
 
261
- // 2. Detect tools
262
347
  const tools = await detectCLITools();
263
348
  console.log(formatDetectedTools(tools));
264
349
 
@@ -267,70 +352,51 @@ switch (command) {
267
352
  break;
268
353
  }
269
354
 
270
- // 3. Auto-select best tool and configure MCP (HTTP transport)
271
355
  const best = tools.find((t) => t.loggedIn) ?? tools[0];
272
356
  const config = loadBackendConfig();
273
357
 
274
358
  console.log(`\nAuto-selecting backend: ${displayName(best.name)}...`);
275
- // Ensure MCP URL uses correct port
276
- if (values.port) {
277
- process.env.ZHIHAND_PORT = values.port;
278
- }
359
+ if (values.port) process.env.ZHIHAND_PORT = values.port;
279
360
  configureMCP(best.name, config.activeBackend);
280
361
  saveBackendConfig({ activeBackend: best.name });
281
362
 
282
- // 4. Start daemon
283
363
  console.log(`\nStarting daemon...\n`);
284
364
  const port = values.port ? parseInt(values.port, 10) : undefined;
285
- await startDaemon({
286
- port,
287
- deviceName: values.device ?? process.env.ZHIHAND_DEVICE,
288
- });
365
+ await startDaemon({ port });
289
366
  break;
290
367
  }
291
368
 
292
- case "list":
293
369
  case "test": {
294
- const { resolveConfig: resolveTestConfig } = await import("../dist/core/config.js");
295
370
  const { createControlCommand, createSystemCommand, enqueueCommand } = await import("../dist/core/command.js");
296
371
  const { waitForCommandAck } = await import("../dist/core/sse.js");
297
- const { fetchScreenshotBinary } = await import("../dist/core/screenshot.js");
298
- const { fetchDeviceProfile, getStaticContext, isDeviceProfileLoaded, formatDeviceStatus } = await import("../dist/core/device.js");
372
+ const { fetchScreenshot, getSnapshotStaleThresholdMs } = await import("../dist/core/screenshot.js");
373
+ const { fetchDeviceProfileOnce, extractStatic, computeCapabilities, formatDeviceStatus } = await import("../dist/core/device.js");
299
374
 
300
- // ── Test Registry ────────────────────────────────────────
301
- // Kind: "profile" | "status" | "screenshot" | "hid" | "system"
302
- // Platform: undefined | "android" | "ios" (skipped on non-matching)
303
- // Unsafe: won't run in full-suite unless explicitly requested
375
+ const KIND_CAPABILITY = {
376
+ profile: "none", status: "none", screenshot: "screen", hid: "hid", system: "none",
377
+ };
304
378
  const REGISTRY = [
305
- // Phase A — Device Info API
306
379
  { id: 1, phase: "Device Info", label: "Fetch device profile", kind: "profile" },
307
380
  { id: 2, phase: "Device Info", label: "Device status fields", kind: "status" },
308
- // Phase B — Screenshot
309
381
  { id: 3, phase: "Screenshot", label: "Screenshot", kind: "screenshot" },
310
- // Phase C — Tap / Touch
311
382
  { id: 4, phase: "Tap/Touch", label: "Click center", kind: "hid", params: { action: "click", xRatio: 0.5, yRatio: 0.5 } },
312
383
  { id: 5, phase: "Tap/Touch", label: "Double click", kind: "hid", params: { action: "doubleclick", xRatio: 0.5, yRatio: 0.5 } },
313
384
  { id: 6, phase: "Tap/Touch", label: "Long click (800ms)", kind: "hid", params: { action: "longclick", xRatio: 0.5, yRatio: 0.5, durationMs: 800 } },
314
385
  { id: 7, phase: "Tap/Touch", label: "Right click", kind: "hid", params: { action: "rightclick", xRatio: 0.5, yRatio: 0.5 } },
315
386
  { id: 8, phase: "Tap/Touch", label: "Middle click", kind: "hid", params: { action: "middleclick", xRatio: 0.5, yRatio: 0.5 } },
316
- // Phase D — Swipe / Scroll
317
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 } },
318
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 } },
319
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 } },
320
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 } },
321
391
  { id: 13, phase: "Swipe/Scroll", label: "Scroll down", kind: "hid", params: { action: "scroll", xRatio: 0.5, yRatio: 0.5, direction: "down", amount: 3 } },
322
392
  { id: 14, phase: "Swipe/Scroll", label: "Scroll up", kind: "hid", params: { action: "scroll", xRatio: 0.5, yRatio: 0.5, direction: "up", amount: 3 } },
323
- // Phase E — Text + Keys
324
393
  { id: 15, phase: "Text+Keys", label: "Type text", kind: "hid", params: { action: "type", text: "zhihand" } },
325
394
  { id: 16, phase: "Text+Keys", label: "Enter key", kind: "hid", params: { action: "enter" } },
326
395
  { id: 17, phase: "Text+Keys", label: "Key combo (select all)", kind: "hid", platformAware: "select_all" },
327
- // Phase F — App Navigation
328
396
  { id: 18, phase: "Navigation", label: "Press Home", kind: "hid", params: { action: "home" } },
329
397
  { id: 19, phase: "Navigation", label: "Press Back", kind: "hid", params: { action: "back" } },
330
398
  { id: 20, phase: "Navigation", label: "Open WeChat", kind: "hid", platformAware: "open_wechat" },
331
- // Phase G — Clipboard
332
399
  { id: 21, phase: "Clipboard", label: "Clipboard set", kind: "hid", platformAware: "clipboard_set" },
333
- // Phase H — System Navigation
334
400
  { id: 22, phase: "System Nav", label: "Notification shade", kind: "system", params: { action: "notification" } },
335
401
  { id: 23, phase: "System Nav", label: "Recent apps", kind: "system", params: { action: "recent" } },
336
402
  { id: 24, phase: "System Nav", label: "Search (query='zhihand')", kind: "system", params: { action: "search", text: "zhihand" } },
@@ -339,7 +405,6 @@ switch (command) {
339
405
  { id: 27, phase: "System Nav", label: "Control Center", kind: "system", params: { action: "control_center" }, platform: "ios" },
340
406
  { id: 28, phase: "System Nav", label: "Open browser", kind: "system", params: { action: "open_browser" }, platform: "android" },
341
407
  { id: 29, phase: "System Nav", label: "Shortcut help", kind: "system", params: { action: "shortcut_help" }, platform: "android" },
342
- // Phase I — Media
343
408
  { id: 30, phase: "Media", label: "Volume up", kind: "system", params: { action: "volume_up" } },
344
409
  { id: 31, phase: "Media", label: "Volume down", kind: "system", params: { action: "volume_down" } },
345
410
  { id: 32, phase: "Media", label: "Mute toggle", kind: "system", params: { action: "mute" } },
@@ -349,49 +414,36 @@ switch (command) {
349
414
  { id: 36, phase: "Media", label: "Fast forward", kind: "system", params: { action: "fast_forward" } },
350
415
  { id: 37, phase: "Media", label: "Rewind", kind: "system", params: { action: "rewind" } },
351
416
  { id: 38, phase: "Media", label: "Stop", kind: "system", params: { action: "stop" } },
352
- // Phase J — Hardware
353
417
  { id: 39, phase: "Hardware", label: "Brightness up", kind: "system", params: { action: "brightness_up" } },
354
418
  { id: 40, phase: "Hardware", label: "Brightness down", kind: "system", params: { action: "brightness_down" } },
355
419
  { id: 41, phase: "Hardware", label: "Power button (⚠️ may lock screen)", kind: "system", params: { action: "power" }, unsafe: true },
356
420
  ];
357
421
 
358
- // ── "list" sub-command ───────────────────────────────────
359
- if (command === "list") {
360
- console.log("📋 ZhiHand Test Registry\n");
361
- let currentPhase = "";
362
- for (const t of REGISTRY) {
363
- if (t.phase !== currentPhase) {
364
- console.log(`\n ── ${t.phase} ──`);
365
- currentPhase = t.phase;
366
- }
367
- const tags = [];
368
- if (t.platform) tags.push(`${t.platform}-only`);
369
- if (t.unsafe) tags.push("unsafe");
370
- const tagStr = tags.length ? ` [${tags.join(", ")}]` : "";
371
- 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;
372
433
  }
373
- console.log(`\n Total: ${REGISTRY.length} tests`);
374
- console.log("\nUsage:");
375
- console.log(" zhihand test # run all safe tests");
376
- console.log(" zhihand test 4 # run test #4 only");
377
- console.log(" zhihand test 4,9,20 # run tests #4, #9, #20");
378
- console.log(" zhihand test all # run ALL tests (including unsafe)");
379
- process.exit(0);
380
434
  }
381
435
 
382
- // ── "test" sub-command ───────────────────────────────────
383
436
  let testConfig;
384
437
  try {
385
- testConfig = resolveTestConfig(values.device ?? process.env.ZHIHAND_DEVICE);
438
+ testConfig = resolveConfig(credentialArg);
386
439
  } catch (err) {
387
440
  console.error(`Error: ${err.message}`);
388
- console.error("Run 'zhihand setup' to pair a device first.");
441
+ console.error("Run 'zhihand pair' to add a device first.");
389
442
  process.exit(1);
390
443
  }
391
444
 
392
- // Parse which tests to run from positional args
393
- const filterArg = positionals[1]; // e.g. "4" or "4,9,20" or "all"
394
- let selectedIds = null; // null = default (all safe)
445
+ const forceRun = values.force === true;
446
+ let selectedIds = null;
395
447
  let includeUnsafe = false;
396
448
  if (filterArg) {
397
449
  if (filterArg === "all") {
@@ -401,16 +453,13 @@ switch (command) {
401
453
  filterArg.split(",").map((s) => {
402
454
  const trimmed = s.trim();
403
455
  const n = Number(trimmed);
404
- // Strict: reject ranges like "4-10" (Number returns NaN), floats, empty
405
456
  return Number.isInteger(n) && n > 0 ? n : NaN;
406
457
  }).filter((n) => !isNaN(n))
407
458
  );
408
459
  if (selectedIds.size === 0) {
409
460
  console.error(`Invalid test IDs: ${filterArg}`);
410
- console.error("Run 'zhihand list' to see available tests.");
411
461
  process.exit(1);
412
462
  }
413
- // Explicit selection implies user knows what they're doing
414
463
  includeUnsafe = true;
415
464
  }
416
465
  }
@@ -419,20 +468,40 @@ switch (command) {
419
468
  console.log(` Device: ${testConfig.credentialId}`);
420
469
  console.log(` Endpoint: ${testConfig.controlPlaneEndpoint}\n`);
421
470
 
422
- // Pre-fetch device profile so platform-aware tests work.
423
- // Note: platform is read dynamically via getDevicePlatform() so Test 1
424
- // (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;
425
476
  try {
426
- await fetchDeviceProfile(testConfig);
427
- } catch { /* non-fatal — Test 1 will retry and platform will update */ }
428
- 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";
486
+
487
+ console.log(" ── Capability readiness ──");
488
+ if (!currentCaps) {
489
+ console.log(" ⚠️ Device profile not loaded — all capability gates will allow tests through.");
490
+ } else {
491
+ const fmt = (name, cap) => ` ${cap.ready ? "✅" : "⚠️"} ${name.padEnd(14)} ${cap.ready ? "ready" : "NOT ready"} — ${cap.reason}`;
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)" : ""}`);
497
+ if (forceRun) {
498
+ console.log(" --force passed: capability gates disabled.");
499
+ }
500
+ }
501
+ console.log("");
429
502
 
430
- let passed = 0;
431
- let failed = 0;
432
- let skipped = 0;
433
- let totalSteps = 0;
503
+ let passed = 0, failed = 0, skipped = 0, totalSteps = 0;
434
504
 
435
- // ── Resolve platform-aware params (evaluated at test time) ──
436
505
  function resolvePlatformAwareParams(variant) {
437
506
  const platform = getDevicePlatform();
438
507
  if (variant === "open_wechat") {
@@ -450,7 +519,6 @@ switch (command) {
450
519
  return null;
451
520
  }
452
521
 
453
- // ── Test runners (shared ACK logic) ──
454
522
  async function runCommandTest(t, command) {
455
523
  totalSteps++;
456
524
  process.stdout.write(` ${String(t.id).padStart(2)}. ${t.label}... `);
@@ -480,14 +548,23 @@ switch (command) {
480
548
  }
481
549
 
482
550
  async function runSingleTest(t) {
483
- // Platform skip (evaluated at test time — Test 1 may have just populated the profile)
484
551
  const currentPlatform = getDevicePlatform();
485
552
  if (t.platform && t.platform !== currentPlatform) {
486
- totalSteps++;
487
- skipped++;
553
+ totalSteps++; skipped++;
488
554
  console.log(` ${String(t.id).padStart(2)}. ${t.label}... ⏭️ Skipped (${t.platform}-only, device is ${currentPlatform})`);
489
555
  return;
490
556
  }
557
+ if (!forceRun && currentCaps) {
558
+ const requiredCap = KIND_CAPABILITY[t.kind] ?? "none";
559
+ if (requiredCap !== "none") {
560
+ const gate = requiredCap === "screen" ? currentCaps.screen_sharing : currentCaps.hid;
561
+ if (!gate.ready) {
562
+ totalSteps++; skipped++;
563
+ console.log(` ${String(t.id).padStart(2)}. ${t.label}... ⏭️ Skipped (${requiredCap} not ready: ${gate.reason})`);
564
+ return;
565
+ }
566
+ }
567
+ }
491
568
 
492
569
  switch (t.kind) {
493
570
  case "profile": {
@@ -495,10 +572,14 @@ switch (command) {
495
572
  process.stdout.write(` ${String(t.id).padStart(2)}. ${t.label}... `);
496
573
  const t0 = Date.now();
497
574
  try {
498
- await fetchDeviceProfile(testConfig);
575
+ const fetched = await fetchDeviceProfileOnce(testConfig);
499
576
  const ms = Date.now() - t0;
500
- if (isDeviceProfileLoaded()) {
501
- 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;
502
583
  console.log(`✅ ${s.platform} ${s.model}, ${s.osVersion}, ${s.screenWidthPx}x${s.screenHeightPx} (${ms}ms)`);
503
584
  passed++;
504
585
  } else {
@@ -515,15 +596,31 @@ switch (command) {
515
596
  totalSteps++;
516
597
  process.stdout.write(` ${String(t.id).padStart(2)}. ${t.label}... `);
517
598
  try {
518
- const status = formatDeviceStatus();
519
- const ignoredDefaults = new Set(["unknown", "0x0", "-1% (unknown)", "0"]);
520
- const fields = Object.keys(status).filter((k) => {
521
- const v = status[k];
522
- if (v === null || v === undefined) return false;
523
- if (ignoredDefaults.has(String(v))) return false;
524
- return true;
525
- });
526
- console.log(`✅ ${fields.length} fields (${fields.join(", ")})`);
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);
615
+ const topLevel = Object.keys(status).filter((k) => k !== "raw" && k !== "capabilities");
616
+ const rawKeys = Object.keys(status.raw ?? {});
617
+ const caps = status.capabilities ?? {};
618
+ const capReadySummary = ["screen_sharing", "hid", "live_session"]
619
+ .map((k) => `${k}=${caps[k]?.ready ? "ready" : "not-ready"}`)
620
+ .join(", ");
621
+ console.log(`✅ ${topLevel.length} curated + ${rawKeys.length} raw attributes; ${capReadySummary}`);
622
+ console.log(` curated: ${topLevel.join(", ")}`);
623
+ console.log(` raw: ${rawKeys.join(", ")}`);
527
624
  passed++;
528
625
  } catch (err) {
529
626
  console.log(`❌ ${err.message}`);
@@ -539,13 +636,21 @@ switch (command) {
539
636
  const cmd = createControlCommand({ action: "screenshot" });
540
637
  const queued = await enqueueCommand(testConfig, cmd);
541
638
  const ack = await waitForCommandAck(testConfig, { commandId: queued.id, timeoutMs: 10_000 });
542
- if (ack.acked) {
543
- const buf = await fetchScreenshotBinary(testConfig);
544
- console.log(`✅ ${(buf.length / 1024).toFixed(0)}KB (${Date.now() - t0}ms)`);
545
- passed++;
546
- } else {
639
+ if (!ack.acked) {
547
640
  console.log(`⏱️ Timeout (${Date.now() - t0}ms)`);
548
641
  failed++;
642
+ break;
643
+ }
644
+ const shot = await fetchScreenshot(testConfig);
645
+ const kb = (shot.buffer.length / 1024).toFixed(0);
646
+ const ms = Date.now() - t0;
647
+ if (shot.stale) {
648
+ const threshold = getSnapshotStaleThresholdMs();
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)`);
650
+ failed++;
651
+ } else {
652
+ console.log(`✅ ${kb}KB, ${shot.width}x${shot.height}, age=${shot.ageMs >= 0 ? `${shot.ageMs}ms` : "?"}, seq=${shot.sequence} (${ms}ms)`);
653
+ passed++;
549
654
  }
550
655
  } catch (err) {
551
656
  console.log(`❌ ${err.message} (${Date.now() - t0}ms)`);
@@ -555,11 +660,11 @@ switch (command) {
555
660
  }
556
661
  case "hid": {
557
662
  const params = t.platformAware ? resolvePlatformAwareParams(t.platformAware) : t.params;
558
- await runCommandTest(t, createControlCommand(params));
663
+ await runCommandTest(t, createControlCommand(params, getDevicePlatform()));
559
664
  break;
560
665
  }
561
666
  case "system": {
562
- await runCommandTest(t, createSystemCommand(t.params));
667
+ await runCommandTest(t, createSystemCommand(t.params, getDevicePlatform()));
563
668
  break;
564
669
  }
565
670
  }
@@ -567,7 +672,6 @@ switch (command) {
567
672
 
568
673
  const pause = () => new Promise((r) => setTimeout(r, 1500));
569
674
 
570
- // Select tests to run
571
675
  const toRun = REGISTRY.filter((t) => {
572
676
  if (selectedIds) return selectedIds.has(t.id);
573
677
  if (t.unsafe && !includeUnsafe) return false;
@@ -576,17 +680,13 @@ switch (command) {
576
680
 
577
681
  if (toRun.length === 0) {
578
682
  console.error("No matching tests.");
579
- console.error("Run 'zhihand list' to see available tests.");
580
683
  process.exit(1);
581
684
  }
582
685
 
583
- // Warn about missing IDs
584
686
  if (selectedIds) {
585
687
  const foundIds = new Set(toRun.map((t) => t.id));
586
688
  const missing = [...selectedIds].filter((id) => !foundIds.has(id));
587
- if (missing.length) {
588
- console.warn(`⚠️ Unknown test IDs: ${missing.join(", ")}`);
589
- }
689
+ if (missing.length) console.warn(`⚠️ Unknown test IDs: ${missing.join(", ")}`);
590
690
  }
591
691
 
592
692
  let currentPhase = "";
@@ -600,13 +700,9 @@ switch (command) {
600
700
  if (i < toRun.length - 1) await pause();
601
701
  }
602
702
 
603
- // ── Summary ──────────────────────────────────────────────
604
703
  console.log(`\n Result: ${passed}/${totalSteps} passed, ${failed} failed, ${skipped} skipped`);
605
- if (failed === 0) {
606
- console.log(" All tests passed! Device is fully responsive.");
607
- } else {
608
- console.log(` ⚠️ ${failed} test(s) failed. Check phone connectivity.`);
609
- }
704
+ if (failed === 0) console.log(" ✅ All tests passed! Device is fully responsive.");
705
+ else console.log(` ⚠️ ${failed} test(s) failed. Check phone connectivity.`);
610
706
  process.exit(failed > 0 ? 1 : 0);
611
707
  }
612
708