@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.
@@ -1,4 +1,4 @@
1
- import type { ZhiHandConfig } from "./config.ts";
1
+ import type { ZhiHandRuntimeConfig } from "./config.ts";
2
2
  export type ScrollDirection = "up" | "down" | "left" | "right";
3
3
  export interface ControlParams {
4
4
  action: string;
@@ -36,12 +36,12 @@ export interface WaitForCommandAckResult {
36
36
  acked: boolean;
37
37
  command?: QueuedCommandRecord;
38
38
  }
39
- export declare function createControlCommand(params: ControlParams): QueuedControlCommand;
39
+ export declare function createControlCommand(params: ControlParams, platform?: string): QueuedControlCommand;
40
40
  export interface SystemParams {
41
41
  action: string;
42
42
  text?: string;
43
43
  }
44
- export declare function createSystemCommand(params: SystemParams): QueuedControlCommand;
45
- export declare function enqueueCommand(config: ZhiHandConfig, command: QueuedControlCommand): Promise<QueuedCommandRecord>;
46
- export declare function getCommand(config: ZhiHandConfig, commandId: string): Promise<QueuedCommandRecord>;
44
+ export declare function createSystemCommand(params: SystemParams, platform?: string): QueuedControlCommand;
45
+ export declare function enqueueCommand(config: ZhiHandRuntimeConfig, command: QueuedControlCommand): Promise<QueuedCommandRecord>;
46
+ export declare function getCommand(config: ZhiHandRuntimeConfig, commandId: string): Promise<QueuedCommandRecord>;
47
47
  export declare function formatAckSummary(action: string, result: WaitForCommandAckResult): string;
@@ -1,11 +1,10 @@
1
- import { getStaticContext, isDeviceProfileLoaded } from "./device.js";
2
1
  import { dbg } from "../daemon/logger.js";
3
2
  let messageCounter = 0;
4
3
  function nextMessageId() {
5
4
  messageCounter = (messageCounter + 1) % 1000;
6
5
  return (Date.now() * 1000) + messageCounter;
7
6
  }
8
- export function createControlCommand(params) {
7
+ export function createControlCommand(params, platform = "unknown") {
9
8
  switch (params.action) {
10
9
  case "click":
11
10
  return { type: "receive_click", payload: { x: params.xRatio, y: params.yRatio } };
@@ -57,7 +56,6 @@ export function createControlCommand(params) {
57
56
  };
58
57
  case "open_app": {
59
58
  const appPayload = {};
60
- const platform = isDeviceProfileLoaded() ? getStaticContext().platform : "unknown";
61
59
  // Only send platform-appropriate fields — Android strict JSON rejects unknown keys
62
60
  if (platform === "android") {
63
61
  // Android: only app_package
@@ -95,8 +93,7 @@ export function createControlCommand(params) {
95
93
  }
96
94
  const IOS_ONLY_ACTIONS = new Set(["siri", "control_center"]);
97
95
  const ANDROID_ONLY_ACTIONS = new Set(["open_browser", "shortcut_help"]);
98
- export function createSystemCommand(params) {
99
- const platform = isDeviceProfileLoaded() ? getStaticContext().platform : "unknown";
96
+ export function createSystemCommand(params, platform = "unknown") {
100
97
  // Platform validation — block mismatched platform-specific actions
101
98
  if (platform === "android" && IOS_ONLY_ACTIONS.has(params.action)) {
102
99
  throw new Error(`Action '${params.action}' is not supported on Android.`);
@@ -1,15 +1,23 @@
1
- export interface DeviceCredential {
2
- credentialId: string;
3
- controllerToken: string;
1
+ export type DevicePlatform = "ios" | "android" | "unknown";
2
+ export interface DeviceRecord {
3
+ credential_id: string;
4
+ controller_token: string;
4
5
  endpoint: string;
5
- deviceName?: string;
6
- pairedAt?: string;
6
+ label: string;
7
+ platform: DevicePlatform;
8
+ paired_at: string;
9
+ last_seen_at: string;
7
10
  }
8
- export interface CredentialStore {
9
- default: string;
10
- devices: Record<string, DeviceCredential>;
11
+ export interface ZhihandConfig {
12
+ schema_version: 2;
13
+ default_credential_id: string | null;
14
+ devices: Record<string, DeviceRecord>;
11
15
  }
12
- export interface ZhiHandConfig {
16
+ /**
17
+ * Legacy-shaped config passed to HTTP callers (command/sse/device endpoints).
18
+ * Corresponds to what the old single-device code called ZhiHandConfig.
19
+ */
20
+ export interface ZhiHandRuntimeConfig {
13
21
  controlPlaneEndpoint: string;
14
22
  credentialId: string;
15
23
  controllerToken: string;
@@ -21,20 +29,24 @@ export interface BackendConfig {
21
29
  activeBackend: BackendName | null;
22
30
  model?: string | null;
23
31
  }
24
- /**
25
- * Default model aliases per backend.
26
- * These are generic aliases that the respective CLIs resolve to the latest version:
27
- * - Gemini CLI: "flash" → latest flash model (e.g. gemini-2.5-flash)
28
- * - Claude Code: "sonnet" → latest sonnet (e.g. claude-sonnet-4-20250514)
29
- * - Codex CLI: requires full model name, no alias support
30
- */
31
32
  export declare const DEFAULT_MODELS: Record<Exclude<BackendName, "openclaw">, string>;
32
33
  export declare function resolveZhiHandDir(): string;
33
34
  export declare function ensureZhiHandDir(): void;
34
- export declare function loadCredentialStore(): CredentialStore | null;
35
- export declare function loadDefaultCredential(): DeviceCredential | null;
36
- export declare function saveCredential(name: string, cred: DeviceCredential, setDefault?: boolean): void;
37
- export declare function resolveConfig(deviceName?: string): ZhiHandConfig;
35
+ export declare function loadConfig(): ZhihandConfig;
36
+ export declare function saveConfig(cfg: ZhihandConfig): void;
37
+ export declare function addDevice(record: DeviceRecord, makeDefault?: boolean): void;
38
+ export declare function removeDevice(credentialId: string): void;
39
+ export declare function renameDevice(credentialId: string, label: string): void;
40
+ export declare function setDefaultDevice(credentialId: string): void;
41
+ export declare function updateLastSeen(credentialId: string, iso: string): void;
42
+ export declare function getDeviceRecord(credentialId: string): DeviceRecord | null;
43
+ export declare function listDeviceRecords(): DeviceRecord[];
44
+ export declare function recordToRuntimeConfig(r: DeviceRecord): ZhiHandRuntimeConfig;
45
+ /**
46
+ * Resolve a runtime config for HTTP calls. If credentialId provided, look it up;
47
+ * else use default_credential_id; else throw.
48
+ */
49
+ export declare function resolveConfig(credentialId?: string): ZhiHandRuntimeConfig;
38
50
  export declare function loadState<T = unknown>(): T | null;
39
51
  export declare function saveState(state: unknown): void;
40
52
  export declare function loadBackendConfig(): BackendConfig;
@@ -1,20 +1,15 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import os from "node:os";
4
- /**
5
- * Default model aliases per backend.
6
- * These are generic aliases that the respective CLIs resolve to the latest version:
7
- * - Gemini CLI: "flash" → latest flash model (e.g. gemini-2.5-flash)
8
- * - Claude Code: "sonnet" → latest sonnet (e.g. claude-sonnet-4-20250514)
9
- * - Codex CLI: requires full model name, no alias support
10
- */
11
4
  export const DEFAULT_MODELS = {
12
- gemini: "flash", // Gemini CLI resolves to latest flash
13
- claudecode: "sonnet", // Claude Code resolves to latest sonnet
14
- codex: "gpt-5.4-mini", // Codex default: latest GPT mini model
5
+ gemini: "flash",
6
+ claudecode: "sonnet",
7
+ codex: "gpt-5.4-mini",
15
8
  };
9
+ // ── Paths ──────────────────────────────────────────────────
16
10
  const ZHIHAND_DIR = path.join(os.homedir(), ".zhihand");
17
- const CREDENTIALS_PATH = path.join(ZHIHAND_DIR, "credentials.json");
11
+ const CONFIG_PATH = path.join(ZHIHAND_DIR, "config.json");
12
+ const LEGACY_CREDENTIALS_PATH = path.join(ZHIHAND_DIR, "credentials.json");
18
13
  const STATE_PATH = path.join(ZHIHAND_DIR, "state.json");
19
14
  const BACKEND_PATH = path.join(ZHIHAND_DIR, "backend.json");
20
15
  export function resolveZhiHandDir() {
@@ -23,47 +18,114 @@ export function resolveZhiHandDir() {
23
18
  export function ensureZhiHandDir() {
24
19
  fs.mkdirSync(ZHIHAND_DIR, { recursive: true, mode: 0o700 });
25
20
  }
26
- export function loadCredentialStore() {
27
- if (!fs.existsSync(CREDENTIALS_PATH))
28
- return null;
21
+ // ── v2 config I/O ──────────────────────────────────────────
22
+ let legacyWarningPrinted = false;
23
+ function emptyConfig() {
24
+ return { schema_version: 2, default_credential_id: null, devices: {} };
25
+ }
26
+ export function loadConfig() {
27
+ if (!fs.existsSync(CONFIG_PATH)) {
28
+ if (!legacyWarningPrinted && fs.existsSync(LEGACY_CREDENTIALS_PATH)) {
29
+ legacyWarningPrinted = true;
30
+ process.stderr.write("[zhihand] legacy credentials.json detected — run 'zhihand pair' to re-pair on v0.30 schema\n");
31
+ }
32
+ return emptyConfig();
33
+ }
29
34
  try {
30
- return JSON.parse(fs.readFileSync(CREDENTIALS_PATH, "utf8"));
35
+ const raw = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
36
+ if (raw && raw.schema_version === 2) {
37
+ return {
38
+ schema_version: 2,
39
+ default_credential_id: raw.default_credential_id ?? null,
40
+ devices: raw.devices ?? {},
41
+ };
42
+ }
31
43
  }
32
44
  catch {
33
- return null;
45
+ // fall through
34
46
  }
47
+ return emptyConfig();
35
48
  }
36
- export function loadDefaultCredential() {
37
- const store = loadCredentialStore();
38
- if (!store)
39
- return null;
40
- return store.devices[store.default] ?? null;
41
- }
42
- export function saveCredential(name, cred, setDefault = true) {
49
+ export function saveConfig(cfg) {
43
50
  ensureZhiHandDir();
44
- let store = loadCredentialStore() ?? { default: name, devices: {} };
45
- store.devices[name] = cred;
46
- if (setDefault)
47
- store.default = name;
48
- fs.writeFileSync(CREDENTIALS_PATH, JSON.stringify(store, null, 2), { mode: 0o600 });
49
- }
50
- export function resolveConfig(deviceName) {
51
- const store = loadCredentialStore();
52
- if (!store) {
53
- throw new Error("No ZhiHand credentials found. Run 'zhihand pair' first.");
51
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), { mode: 0o600 });
52
+ }
53
+ export function addDevice(record, makeDefault) {
54
+ const cfg = loadConfig();
55
+ cfg.devices[record.credential_id] = record;
56
+ if (makeDefault || cfg.default_credential_id === null) {
57
+ cfg.default_credential_id = record.credential_id;
58
+ }
59
+ saveConfig(cfg);
60
+ }
61
+ export function removeDevice(credentialId) {
62
+ const cfg = loadConfig();
63
+ delete cfg.devices[credentialId];
64
+ if (cfg.default_credential_id === credentialId) {
65
+ const remaining = Object.keys(cfg.devices);
66
+ cfg.default_credential_id = remaining[0] ?? null;
54
67
  }
55
- const name = deviceName ?? store.default;
56
- const cred = store.devices[name];
57
- if (!cred) {
58
- throw new Error(`Device '${name}' not found. Available: ${Object.keys(store.devices).join(", ")}`);
68
+ saveConfig(cfg);
69
+ }
70
+ export function renameDevice(credentialId, label) {
71
+ const cfg = loadConfig();
72
+ const r = cfg.devices[credentialId];
73
+ if (!r)
74
+ throw new Error(`Device '${credentialId}' not found`);
75
+ r.label = label;
76
+ saveConfig(cfg);
77
+ }
78
+ export function setDefaultDevice(credentialId) {
79
+ const cfg = loadConfig();
80
+ if (!cfg.devices[credentialId]) {
81
+ throw new Error(`Device '${credentialId}' not found`);
59
82
  }
83
+ cfg.default_credential_id = credentialId;
84
+ saveConfig(cfg);
85
+ }
86
+ export function updateLastSeen(credentialId, iso) {
87
+ const cfg = loadConfig();
88
+ const r = cfg.devices[credentialId];
89
+ if (!r)
90
+ return;
91
+ r.last_seen_at = iso;
92
+ saveConfig(cfg);
93
+ }
94
+ export function getDeviceRecord(credentialId) {
95
+ const cfg = loadConfig();
96
+ return cfg.devices[credentialId] ?? null;
97
+ }
98
+ export function listDeviceRecords() {
99
+ const cfg = loadConfig();
100
+ return Object.values(cfg.devices);
101
+ }
102
+ // ── Runtime config resolution ─────────────────────────────
103
+ export function recordToRuntimeConfig(r) {
60
104
  return {
61
- controlPlaneEndpoint: cred.endpoint,
62
- credentialId: cred.credentialId,
63
- controllerToken: cred.controllerToken,
105
+ controlPlaneEndpoint: r.endpoint,
106
+ credentialId: r.credential_id,
107
+ controllerToken: r.controller_token,
64
108
  timeoutMs: 10_000,
65
109
  };
66
110
  }
111
+ /**
112
+ * Resolve a runtime config for HTTP calls. If credentialId provided, look it up;
113
+ * else use default_credential_id; else throw.
114
+ */
115
+ export function resolveConfig(credentialId) {
116
+ const cfg = loadConfig();
117
+ const id = credentialId ?? cfg.default_credential_id;
118
+ if (!id) {
119
+ throw new Error("No default device — run zhihand pair");
120
+ }
121
+ const r = cfg.devices[id];
122
+ if (!r) {
123
+ const known = Object.keys(cfg.devices).join(", ") || "(none)";
124
+ throw new Error(`Device '${id}' not found. Known: ${known}`);
125
+ }
126
+ return recordToRuntimeConfig(r);
127
+ }
128
+ // ── State / backend ───────────────────────────────────────
67
129
  export function loadState() {
68
130
  if (!fs.existsSync(STATE_PATH))
69
131
  return null;
@@ -1,13 +1,12 @@
1
1
  /**
2
- * Device Context static + dynamic device info fetched from control plane.
2
+ * Device profile extraction & formatting stateless.
3
3
  *
4
- * Static info (platform, model, screen size) is set once after pairing and
5
- * injected into MCP tool descriptions so the LLM always knows the device.
6
- *
7
- * Dynamic info (battery, network, BLE) is updated via SSE push and exposed
8
- * through the zhihand_status tool and device://profile resource.
4
+ * Per-device state (profile, raw attributes, timestamps) lives in the
5
+ * device registry (see ./registry.ts). This module exposes pure helpers
6
+ * to extract, classify, and format device data so the same logic can be
7
+ * applied to any number of devices.
9
8
  */
10
- import type { ZhiHandConfig } from "./config.ts";
9
+ import type { ZhiHandRuntimeConfig } from "./config.ts";
11
10
  export interface StaticContext {
12
11
  platform: string;
13
12
  model: string;
@@ -35,11 +34,8 @@ export interface DynamicContext {
35
34
  thermalState?: string;
36
35
  fontScale: number;
37
36
  }
38
- export declare function getStaticContext(): StaticContext;
39
- export declare function getDynamicContext(): DynamicContext;
40
- export declare function getRawAttributes(): Record<string, unknown>;
41
- export declare function getProfileAgeMs(): number;
42
- export declare function isDeviceProfileLoaded(): boolean;
37
+ declare const DEFAULT_STATIC: StaticContext;
38
+ declare const DEFAULT_DYNAMIC: DynamicContext;
43
39
  export interface Capability {
44
40
  ready: boolean;
45
41
  reason: string;
@@ -53,12 +49,25 @@ export interface Capabilities {
53
49
  stale: boolean;
54
50
  };
55
51
  }
56
- export declare function getCapabilities(): Capabilities;
52
+ export declare function computeCapabilities(rawAttributes: Record<string, unknown>, profileReceivedAtMs: number): Capabilities;
57
53
  export declare function extractStatic(profile: Record<string, unknown>): StaticContext;
58
54
  export declare function extractDynamic(profile: Record<string, unknown>): DynamicContext;
59
- export declare function updateDeviceProfile(raw: Record<string, unknown>): void;
60
- export declare function fetchDeviceProfile(config: ZhiHandConfig): Promise<void>;
61
- export declare function buildControlToolDescription(): string;
62
- export declare function buildSystemToolDescription(): string;
63
- export declare function buildScreenshotToolDescription(): string;
64
- export declare function formatDeviceStatus(): Record<string, unknown>;
55
+ /**
56
+ * Fetch and normalize the device profile from the control plane once.
57
+ * Returns null on failure (HTTP or network).
58
+ */
59
+ export declare function fetchDeviceProfileOnce(config: ZhiHandRuntimeConfig): Promise<{
60
+ rawAttrs: Record<string, unknown>;
61
+ receivedAtMs: number;
62
+ } | null>;
63
+ /**
64
+ * Normalize an SSE device_profile.updated payload into rawAttrs shape.
65
+ */
66
+ export declare function normalizeProfilePayload(raw: Record<string, unknown>): Record<string, unknown>;
67
+ export declare function pickAllowlistedRawAttributes(rawAttributes: Record<string, unknown>): Record<string, unknown>;
68
+ export { DEFAULT_STATIC, DEFAULT_DYNAMIC };
69
+ import type { DeviceState } from "./registry.ts";
70
+ export declare function buildControlToolDescription(state: DeviceState | null, onlineStates?: DeviceState[]): string;
71
+ export declare function buildSystemToolDescription(state: DeviceState | null, onlineStates?: DeviceState[]): string;
72
+ export declare function buildScreenshotToolDescription(state: DeviceState | null, onlineStates?: DeviceState[]): string;
73
+ export declare function formatDeviceStatus(state: DeviceState): Record<string, unknown>;