@zhihand/mcp 0.12.0 → 0.12.1

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 (46) hide show
  1. package/README.md +288 -0
  2. package/bin/zhihand +6 -6
  3. package/bin/zhihand.openclaw +2 -2
  4. package/dist/cli/detect.d.ts +10 -0
  5. package/dist/cli/detect.js +75 -0
  6. package/dist/cli/openclaw.d.ts +6 -0
  7. package/dist/cli/openclaw.js +47 -0
  8. package/dist/cli/spawn.d.ts +2 -0
  9. package/dist/cli/spawn.js +31 -0
  10. package/dist/core/command.d.ts +41 -0
  11. package/dist/core/command.js +84 -0
  12. package/dist/core/config.d.ts +26 -0
  13. package/dist/core/config.js +67 -0
  14. package/dist/core/pair.d.ts +45 -0
  15. package/dist/core/pair.js +124 -0
  16. package/dist/core/screenshot.d.ts +2 -0
  17. package/dist/core/screenshot.js +21 -0
  18. package/dist/core/sse.d.ts +35 -0
  19. package/dist/core/sse.js +149 -0
  20. package/dist/index.d.ts +3 -0
  21. package/dist/index.js +43 -0
  22. package/dist/openclaw.adapter.d.ts +49 -0
  23. package/dist/openclaw.adapter.js +72 -0
  24. package/dist/tools/control.d.ts +18 -0
  25. package/dist/tools/control.js +52 -0
  26. package/dist/tools/pair.d.ts +8 -0
  27. package/dist/tools/pair.js +49 -0
  28. package/dist/tools/schemas.d.ts +20 -0
  29. package/dist/tools/schemas.js +25 -0
  30. package/dist/tools/screenshot.d.ts +11 -0
  31. package/dist/tools/screenshot.js +4 -0
  32. package/package.json +13 -5
  33. package/src/cli/detect.ts +0 -90
  34. package/src/cli/openclaw.ts +0 -50
  35. package/src/cli/spawn.ts +0 -34
  36. package/src/core/command.ts +0 -144
  37. package/src/core/config.ts +0 -91
  38. package/src/core/pair.ts +0 -143
  39. package/src/core/screenshot.ts +0 -28
  40. package/src/core/sse.ts +0 -88
  41. package/src/index.ts +0 -53
  42. package/src/openclaw.adapter.ts +0 -116
  43. package/src/tools/control.ts +0 -66
  44. package/src/tools/pair.ts +0 -58
  45. package/src/tools/schemas.ts +0 -28
  46. package/src/tools/screenshot.ts +0 -8
@@ -0,0 +1,67 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ const ZHIHAND_DIR = path.join(os.homedir(), ".zhihand");
5
+ const CREDENTIALS_PATH = path.join(ZHIHAND_DIR, "credentials.json");
6
+ const STATE_PATH = path.join(ZHIHAND_DIR, "state.json");
7
+ export function resolveZhiHandDir() {
8
+ return ZHIHAND_DIR;
9
+ }
10
+ export function ensureZhiHandDir() {
11
+ fs.mkdirSync(ZHIHAND_DIR, { recursive: true });
12
+ }
13
+ export function loadCredentialStore() {
14
+ if (!fs.existsSync(CREDENTIALS_PATH))
15
+ return null;
16
+ try {
17
+ return JSON.parse(fs.readFileSync(CREDENTIALS_PATH, "utf8"));
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
23
+ export function loadDefaultCredential() {
24
+ const store = loadCredentialStore();
25
+ if (!store)
26
+ return null;
27
+ return store.devices[store.default] ?? null;
28
+ }
29
+ export function saveCredential(name, cred, setDefault = true) {
30
+ ensureZhiHandDir();
31
+ let store = loadCredentialStore() ?? { default: name, devices: {} };
32
+ store.devices[name] = cred;
33
+ if (setDefault)
34
+ store.default = name;
35
+ fs.writeFileSync(CREDENTIALS_PATH, JSON.stringify(store, null, 2));
36
+ }
37
+ export function resolveConfig(deviceName) {
38
+ const store = loadCredentialStore();
39
+ if (!store) {
40
+ throw new Error("No ZhiHand credentials found. Run 'zhihand pair' first.");
41
+ }
42
+ const name = deviceName ?? store.default;
43
+ const cred = store.devices[name];
44
+ if (!cred) {
45
+ throw new Error(`Device '${name}' not found. Available: ${Object.keys(store.devices).join(", ")}`);
46
+ }
47
+ return {
48
+ controlPlaneEndpoint: cred.endpoint,
49
+ credentialId: cred.credentialId,
50
+ controllerToken: cred.controllerToken,
51
+ timeoutMs: 10_000,
52
+ };
53
+ }
54
+ export function loadState() {
55
+ if (!fs.existsSync(STATE_PATH))
56
+ return null;
57
+ try {
58
+ return JSON.parse(fs.readFileSync(STATE_PATH, "utf8"));
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ }
64
+ export function saveState(state) {
65
+ ensureZhiHandDir();
66
+ fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2));
67
+ }
@@ -0,0 +1,45 @@
1
+ import type { DeviceCredential } from "./config.ts";
2
+ export interface PluginRecord {
3
+ id: string;
4
+ edge_id: string;
5
+ adapter_kind: string;
6
+ display_name?: string;
7
+ stable_identity?: string;
8
+ status: string;
9
+ created_at: string;
10
+ }
11
+ export interface PairingSession {
12
+ id: string;
13
+ pair_url: string;
14
+ qr_payload: string;
15
+ controller_token?: string;
16
+ edge_id: string;
17
+ status: "pending" | "claimed" | "expired" | string;
18
+ credential_id?: string;
19
+ expires_at: string;
20
+ requested_scopes?: string[];
21
+ }
22
+ export interface CreatePairingOptions {
23
+ edgeId: string;
24
+ ttlSeconds?: number;
25
+ requestedScopes?: string[];
26
+ }
27
+ /**
28
+ * Register this MCP instance as a plugin with the server.
29
+ * Server requires a registered plugin (edge_id) before pairing can begin.
30
+ * Idempotent — re-registering with the same stable_identity returns the existing plugin.
31
+ */
32
+ export declare function registerPlugin(endpoint: string, options: {
33
+ stableIdentity: string;
34
+ displayName?: string;
35
+ adapterKind?: string;
36
+ }): Promise<PluginRecord>;
37
+ export declare function createPairingSession(endpoint: string, options: CreatePairingOptions): Promise<PairingSession>;
38
+ export declare function getPairingSession(endpoint: string, sessionId: string): Promise<PairingSession>;
39
+ export declare function waitForPairingClaim(endpoint: string, sessionId: string, timeoutMs?: number): Promise<PairingSession>;
40
+ export declare function renderPairingQRCode(url: string): Promise<string>;
41
+ export declare function executePairing(endpoint: string, edgeId: string, deviceName?: string): Promise<{
42
+ session: PairingSession;
43
+ credential: DeviceCredential;
44
+ }>;
45
+ export declare function formatPairingStatus(cred: DeviceCredential | null): string;
@@ -0,0 +1,124 @@
1
+ import QRCode from "qrcode";
2
+ import { saveCredential, saveState } from "./config.js";
3
+ const DEFAULT_SCOPES = [
4
+ "observe",
5
+ "session.control",
6
+ "screen.read",
7
+ "screen.capture",
8
+ "ble.control",
9
+ ];
10
+ /**
11
+ * Register this MCP instance as a plugin with the server.
12
+ * Server requires a registered plugin (edge_id) before pairing can begin.
13
+ * Idempotent — re-registering with the same stable_identity returns the existing plugin.
14
+ */
15
+ export async function registerPlugin(endpoint, options) {
16
+ const response = await fetch(`${endpoint}/v1/plugins`, {
17
+ method: "POST",
18
+ headers: { "Content-Type": "application/json" },
19
+ body: JSON.stringify({
20
+ adapter_kind: options.adapterKind ?? "mcp",
21
+ display_name: options.displayName ?? "ZhiHand MCP Server",
22
+ stable_identity: options.stableIdentity,
23
+ }),
24
+ });
25
+ if (!response.ok) {
26
+ throw new Error(`Register plugin failed: ${response.status} ${await response.text()}`);
27
+ }
28
+ const payload = (await response.json());
29
+ return payload.plugin;
30
+ }
31
+ export async function createPairingSession(endpoint, options) {
32
+ const response = await fetch(`${endpoint}/v1/pairing/sessions`, {
33
+ method: "POST",
34
+ headers: { "Content-Type": "application/json" },
35
+ body: JSON.stringify({
36
+ edge_id: options.edgeId,
37
+ ttl_seconds: options.ttlSeconds ?? 600,
38
+ requested_scopes: options.requestedScopes ?? DEFAULT_SCOPES,
39
+ }),
40
+ });
41
+ if (!response.ok) {
42
+ throw new Error(`Create pairing session failed: ${response.status}`);
43
+ }
44
+ const payload = (await response.json());
45
+ return {
46
+ ...payload.session,
47
+ controller_token: payload.controller_token ?? payload.session.controller_token,
48
+ };
49
+ }
50
+ export async function getPairingSession(endpoint, sessionId) {
51
+ const response = await fetch(`${endpoint}/v1/pairing/sessions/${encodeURIComponent(sessionId)}`);
52
+ if (!response.ok) {
53
+ throw new Error(`Get pairing session failed: ${response.status}`);
54
+ }
55
+ const payload = (await response.json());
56
+ return payload.session;
57
+ }
58
+ export async function waitForPairingClaim(endpoint, sessionId, timeoutMs = 600_000) {
59
+ const deadline = Date.now() + timeoutMs;
60
+ while (Date.now() < deadline) {
61
+ const session = await getPairingSession(endpoint, sessionId);
62
+ if (session.status === "claimed" && session.credential_id) {
63
+ return session;
64
+ }
65
+ if (session.status === "expired") {
66
+ throw new Error("Pairing session expired.");
67
+ }
68
+ await new Promise((r) => setTimeout(r, 2000));
69
+ }
70
+ throw new Error("Pairing timeout.");
71
+ }
72
+ export async function renderPairingQRCode(url) {
73
+ return QRCode.toString(url, { type: "utf8", margin: 1 });
74
+ }
75
+ export async function executePairing(endpoint, edgeId, deviceName) {
76
+ // Step 0: Register plugin first — server requires a known edge_id before pairing.
77
+ // Uses edgeId as stable_identity so re-runs are idempotent.
78
+ const plugin = await registerPlugin(endpoint, {
79
+ stableIdentity: edgeId,
80
+ displayName: deviceName ? `ZhiHand MCP — ${deviceName}` : "ZhiHand MCP Server",
81
+ });
82
+ const registeredEdgeId = plugin.edge_id;
83
+ const session = await createPairingSession(endpoint, { edgeId: registeredEdgeId });
84
+ // Save pending state
85
+ saveState({
86
+ sessionId: session.id,
87
+ controllerToken: session.controller_token,
88
+ edgeId: session.edge_id,
89
+ pairUrl: session.pair_url,
90
+ status: "pending",
91
+ expiresAt: session.expires_at,
92
+ });
93
+ // Wait for phone to scan
94
+ const claimed = await waitForPairingClaim(endpoint, session.id);
95
+ const credential = {
96
+ credentialId: claimed.credential_id,
97
+ controllerToken: claimed.controller_token ?? session.controller_token,
98
+ endpoint,
99
+ deviceName: deviceName ?? `device_${Date.now()}`,
100
+ pairedAt: new Date().toISOString(),
101
+ };
102
+ const name = deviceName ?? credential.deviceName;
103
+ saveCredential(name, credential, true);
104
+ // Update state
105
+ saveState({
106
+ sessionId: session.id,
107
+ controllerToken: credential.controllerToken,
108
+ edgeId: session.edge_id,
109
+ credentialId: credential.credentialId,
110
+ pairUrl: session.pair_url,
111
+ status: "claimed",
112
+ });
113
+ return { session: claimed, credential };
114
+ }
115
+ export function formatPairingStatus(cred) {
116
+ if (!cred)
117
+ return "Not paired. Run 'zhihand pair' to connect a device.";
118
+ return [
119
+ `Paired to: ${cred.deviceName ?? "unknown device"}`,
120
+ `Endpoint: ${cred.endpoint}`,
121
+ `Credential: ${cred.credentialId}`,
122
+ `Paired at: ${cred.pairedAt ?? "unknown"}`,
123
+ ].join("\n");
124
+ }
@@ -0,0 +1,2 @@
1
+ import type { ZhiHandConfig } from "./config.ts";
2
+ export declare function fetchScreenshotBinary(config: ZhiHandConfig): Promise<Buffer>;
@@ -0,0 +1,21 @@
1
+ export async function fetchScreenshotBinary(config) {
2
+ const controller = new AbortController();
3
+ const timeout = setTimeout(() => controller.abort(), config.timeoutMs ?? 10_000);
4
+ try {
5
+ const response = await fetch(`${config.controlPlaneEndpoint}/v1/credentials/${encodeURIComponent(config.credentialId)}/screen`, {
6
+ method: "GET",
7
+ headers: {
8
+ "x-zhihand-controller-token": config.controllerToken,
9
+ "Accept": "image/jpeg",
10
+ },
11
+ signal: controller.signal,
12
+ });
13
+ if (!response.ok) {
14
+ throw new Error(`Screenshot fetch failed: ${response.status}`);
15
+ }
16
+ return Buffer.from(await response.arrayBuffer());
17
+ }
18
+ finally {
19
+ clearTimeout(timeout);
20
+ }
21
+ }
@@ -0,0 +1,35 @@
1
+ import type { ZhiHandConfig } from "./config.ts";
2
+ import type { QueuedCommandRecord, WaitForCommandAckResult } from "./command.ts";
3
+ export interface SSEEvent {
4
+ id: string;
5
+ topic: string;
6
+ kind: string;
7
+ credential_id: string;
8
+ command?: QueuedCommandRecord;
9
+ sequence: number;
10
+ }
11
+ export declare function handleSSEEvent(event: SSEEvent): void;
12
+ export declare function subscribeToCommandAck(commandId: string, callback: (cmd: QueuedCommandRecord) => void): () => void;
13
+ /**
14
+ * Connect to the SSE event stream for command ACKs.
15
+ * Maintains a persistent connection that dispatches events to registered callbacks.
16
+ * Reconnects automatically on connection loss.
17
+ */
18
+ export declare function connectSSE(config: ZhiHandConfig): void;
19
+ /**
20
+ * Disconnect the SSE event stream.
21
+ */
22
+ export declare function disconnectSSE(): void;
23
+ /**
24
+ * Whether the SSE stream is currently connected.
25
+ */
26
+ export declare function isSSEConnected(): boolean;
27
+ /**
28
+ * Wait for command ACK via SSE push.
29
+ * Falls back to polling if SSE is not active.
30
+ */
31
+ export declare function waitForCommandAck(config: ZhiHandConfig, options: {
32
+ commandId: string;
33
+ timeoutMs?: number;
34
+ signal?: AbortSignal;
35
+ }): Promise<WaitForCommandAckResult>;
@@ -0,0 +1,149 @@
1
+ import { getCommand } from "./command.js";
2
+ // Per-commandId callback registry for SSE-based ACK
3
+ const ackCallbacks = new Map();
4
+ // Active SSE connection state
5
+ let sseAbortController = null;
6
+ let sseConnected = false;
7
+ export function handleSSEEvent(event) {
8
+ if (event.kind === "command.acked" && event.command) {
9
+ const callback = ackCallbacks.get(event.command.id);
10
+ if (callback) {
11
+ callback(event.command);
12
+ ackCallbacks.delete(event.command.id);
13
+ }
14
+ }
15
+ }
16
+ export function subscribeToCommandAck(commandId, callback) {
17
+ ackCallbacks.set(commandId, callback);
18
+ return () => { ackCallbacks.delete(commandId); };
19
+ }
20
+ /**
21
+ * Connect to the SSE event stream for command ACKs.
22
+ * Maintains a persistent connection that dispatches events to registered callbacks.
23
+ * Reconnects automatically on connection loss.
24
+ */
25
+ export function connectSSE(config) {
26
+ if (sseAbortController)
27
+ return; // Already connected
28
+ sseAbortController = new AbortController();
29
+ const { signal } = sseAbortController;
30
+ const url = `${config.controlPlaneEndpoint}/v1/credentials/${encodeURIComponent(config.credentialId)}/events?topic=commands`;
31
+ (async () => {
32
+ while (!signal.aborted) {
33
+ try {
34
+ const response = await fetch(url, {
35
+ headers: {
36
+ "Accept": "text/event-stream",
37
+ "x-zhihand-controller-token": config.controllerToken,
38
+ },
39
+ signal,
40
+ });
41
+ if (!response.ok) {
42
+ throw new Error(`SSE connect failed: ${response.status}`);
43
+ }
44
+ sseConnected = true;
45
+ const reader = response.body?.getReader();
46
+ if (!reader)
47
+ throw new Error("No response body for SSE");
48
+ const decoder = new TextDecoder();
49
+ let buffer = "";
50
+ while (!signal.aborted) {
51
+ const { done, value } = await reader.read();
52
+ if (done)
53
+ break;
54
+ buffer += decoder.decode(value, { stream: true });
55
+ const lines = buffer.split("\n");
56
+ buffer = lines.pop() ?? "";
57
+ let eventData = "";
58
+ for (const line of lines) {
59
+ if (line.startsWith("data: ")) {
60
+ eventData += line.slice(6);
61
+ }
62
+ else if (line === "" && eventData) {
63
+ try {
64
+ const event = JSON.parse(eventData);
65
+ handleSSEEvent(event);
66
+ }
67
+ catch {
68
+ // Malformed event, skip
69
+ }
70
+ eventData = "";
71
+ }
72
+ }
73
+ }
74
+ }
75
+ catch (err) {
76
+ if (signal.aborted)
77
+ break;
78
+ sseConnected = false;
79
+ // Backoff before reconnect
80
+ await new Promise((r) => setTimeout(r, 3000));
81
+ }
82
+ }
83
+ sseConnected = false;
84
+ })();
85
+ }
86
+ /**
87
+ * Disconnect the SSE event stream.
88
+ */
89
+ export function disconnectSSE() {
90
+ sseAbortController?.abort();
91
+ sseAbortController = null;
92
+ sseConnected = false;
93
+ }
94
+ /**
95
+ * Whether the SSE stream is currently connected.
96
+ */
97
+ export function isSSEConnected() {
98
+ return sseConnected;
99
+ }
100
+ /**
101
+ * Wait for command ACK via SSE push.
102
+ * Falls back to polling if SSE is not active.
103
+ */
104
+ export async function waitForCommandAck(config, options) {
105
+ const timeoutMs = options.timeoutMs ?? 15_000;
106
+ // Ensure SSE is connected for real-time ACKs
107
+ connectSSE(config);
108
+ return new Promise((resolve, reject) => {
109
+ let resolved = false;
110
+ let pollInterval;
111
+ const timeout = setTimeout(() => {
112
+ cleanup();
113
+ resolve({ acked: false });
114
+ }, timeoutMs);
115
+ const unsubscribe = subscribeToCommandAck(options.commandId, (ackedCommand) => {
116
+ if (resolved)
117
+ return;
118
+ resolved = true;
119
+ cleanup();
120
+ resolve({ acked: true, command: ackedCommand });
121
+ });
122
+ // Also poll as fallback (SSE may not be connected yet or may be reconnecting)
123
+ pollInterval = setInterval(async () => {
124
+ if (resolved)
125
+ return;
126
+ try {
127
+ const cmd = await getCommand(config, options.commandId);
128
+ if (cmd.acked_at) {
129
+ resolved = true;
130
+ cleanup();
131
+ resolve({ acked: true, command: cmd });
132
+ }
133
+ }
134
+ catch {
135
+ // Polling failure is non-fatal; SSE or next poll may succeed
136
+ }
137
+ }, 500);
138
+ options.signal?.addEventListener("abort", () => {
139
+ cleanup();
140
+ reject(new Error("The operation was aborted"));
141
+ }, { once: true });
142
+ function cleanup() {
143
+ clearTimeout(timeout);
144
+ unsubscribe();
145
+ if (pollInterval)
146
+ clearInterval(pollInterval);
147
+ }
148
+ });
149
+ }
@@ -0,0 +1,3 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function createServer(deviceName?: string): McpServer;
3
+ export declare function startStdioServer(deviceName?: string): Promise<void>;
package/dist/index.js ADDED
@@ -0,0 +1,43 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { resolveConfig } from "./core/config.js";
4
+ import { controlSchema, screenshotSchema, pairSchema } from "./tools/schemas.js";
5
+ import { executeControl } from "./tools/control.js";
6
+ import { handleScreenshot } from "./tools/screenshot.js";
7
+ import { handlePair } from "./tools/pair.js";
8
+ const PACKAGE_VERSION = "0.12.1";
9
+ export function createServer(deviceName) {
10
+ const server = new McpServer({
11
+ name: "zhihand",
12
+ version: PACKAGE_VERSION,
13
+ });
14
+ // zhihand_control — main phone control tool
15
+ server.tool("zhihand_control", controlSchema, async (params) => {
16
+ const config = resolveConfig(deviceName);
17
+ return await executeControl(config, params);
18
+ });
19
+ // zhihand_screenshot — capture current screen without any action
20
+ server.tool("zhihand_screenshot", screenshotSchema, async () => {
21
+ const config = resolveConfig(deviceName);
22
+ return await handleScreenshot(config);
23
+ });
24
+ // zhihand_pair — device pairing
25
+ server.tool("zhihand_pair", pairSchema, async (params) => {
26
+ return await handlePair(params);
27
+ });
28
+ return server;
29
+ }
30
+ export async function startStdioServer(deviceName) {
31
+ const server = createServer(deviceName);
32
+ const transport = new StdioServerTransport();
33
+ await server.connect(transport);
34
+ }
35
+ // Direct execution: start stdio server
36
+ const isDirectRun = process.argv[1]?.endsWith("index.ts") || process.argv[1]?.endsWith("index.js");
37
+ if (isDirectRun) {
38
+ const deviceArg = process.argv.find((a) => a.startsWith("--device="))?.split("=")[1];
39
+ startStdioServer(deviceArg ?? process.env.ZHIHAND_DEVICE).catch((err) => {
40
+ process.stderr.write(`ZhiHand MCP Server failed: ${err.message}\n`);
41
+ process.exit(1);
42
+ });
43
+ }
@@ -0,0 +1,49 @@
1
+ type OpenClawLogger = {
2
+ info?: (message: string) => void;
3
+ warn?: (message: string) => void;
4
+ error?: (message: string) => void;
5
+ };
6
+ type OpenClawRuntime = {
7
+ state: {
8
+ resolveStateDir: () => string;
9
+ };
10
+ stt?: {
11
+ transcribeAudioFile: (input: {
12
+ path: string;
13
+ }) => Promise<{
14
+ text?: string;
15
+ } | string>;
16
+ };
17
+ };
18
+ type OpenClawToolRegistration = {
19
+ name: string;
20
+ label: string;
21
+ description: string;
22
+ parameters: Record<string, unknown>;
23
+ execute: (id: string, params: Record<string, unknown>) => Promise<Record<string, unknown>>;
24
+ };
25
+ type OpenClawPluginApi = {
26
+ logger: OpenClawLogger;
27
+ runtime: OpenClawRuntime;
28
+ pluginConfig?: Record<string, unknown>;
29
+ registerService: (service: {
30
+ id: string;
31
+ start: () => Promise<void>;
32
+ stop: () => Promise<void>;
33
+ }) => void;
34
+ registerCommand: (command: {
35
+ name: string;
36
+ description: string;
37
+ acceptsArgs?: boolean;
38
+ handler: (ctx: {
39
+ args?: string;
40
+ }) => Promise<{
41
+ text: string;
42
+ }>;
43
+ }) => void;
44
+ registerTool: (tool: OpenClawToolRegistration, options?: {
45
+ optional?: boolean;
46
+ }) => void;
47
+ };
48
+ export declare function registerOpenClawTools(api: OpenClawPluginApi, deviceName?: string): void;
49
+ export default registerOpenClawTools;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * OpenClaw Plugin adapter — thin wrapper that bridges OpenClaw Plugin API
3
+ * to MCP core logic. All business logic lives in core/ and tools/.
4
+ */
5
+ import { resolveConfig } from "./core/config.js";
6
+ import { executeControl } from "./tools/control.js";
7
+ import { handleScreenshot } from "./tools/screenshot.js";
8
+ import { handlePair } from "./tools/pair.js";
9
+ import { detectCLITools, formatDetectedTools } from "./cli/detect.js";
10
+ import { controlSchema, screenshotSchema, pairSchema } from "./tools/schemas.js";
11
+ function zodSchemaToJsonSchema(zodShape) {
12
+ // Simplified conversion — OpenClaw uses JSON Schema-like parameter objects.
13
+ // The actual Zod schemas are used for validation inside tool handlers.
14
+ const properties = {};
15
+ for (const [key, value] of Object.entries(zodShape)) {
16
+ const v = value;
17
+ properties[key] = {
18
+ type: "string",
19
+ description: v.description ?? key,
20
+ };
21
+ }
22
+ return { type: "object", properties };
23
+ }
24
+ export function registerOpenClawTools(api, deviceName) {
25
+ const log = (msg) => api.logger.info?.(msg);
26
+ // zhihand_control
27
+ api.registerTool({
28
+ name: "zhihand_control",
29
+ label: "ZhiHand Control",
30
+ description: "Control a paired phone: tap, swipe, type, scroll, screenshot, and more.",
31
+ parameters: zodSchemaToJsonSchema(controlSchema),
32
+ execute: async (_id, params) => {
33
+ const config = resolveConfig(deviceName);
34
+ const result = await executeControl(config, params);
35
+ return result;
36
+ },
37
+ });
38
+ // zhihand_screenshot
39
+ api.registerTool({
40
+ name: "zhihand_screenshot",
41
+ label: "ZhiHand Screenshot",
42
+ description: "Capture current phone screen without performing any action.",
43
+ parameters: zodSchemaToJsonSchema(screenshotSchema),
44
+ execute: async (_id, _params) => {
45
+ const config = resolveConfig(deviceName);
46
+ const result = await handleScreenshot(config);
47
+ return result;
48
+ },
49
+ });
50
+ // zhihand_pair
51
+ api.registerTool({
52
+ name: "zhihand_pair",
53
+ label: "ZhiHand Pair",
54
+ description: "Pair with a phone. Returns QR code and pairing URL.",
55
+ parameters: zodSchemaToJsonSchema(pairSchema),
56
+ execute: async (_id, params) => {
57
+ const result = await handlePair(params);
58
+ return result;
59
+ },
60
+ }, { optional: true });
61
+ // detect command
62
+ api.registerCommand({
63
+ name: "zhihand-detect",
64
+ description: "Detect available CLI tools (Claude Code, Codex, Gemini, OpenClaw)",
65
+ handler: async () => {
66
+ const tools = await detectCLITools();
67
+ return { text: formatDetectedTools(tools) };
68
+ },
69
+ });
70
+ log("[zhihand] OpenClaw tools registered via MCP core adapter");
71
+ }
72
+ export default registerOpenClawTools;
@@ -0,0 +1,18 @@
1
+ import type { ZhiHandConfig } from "../core/config.ts";
2
+ import type { ControlParams } from "../core/command.ts";
3
+ type TextContent = {
4
+ type: "text";
5
+ text: string;
6
+ };
7
+ type ImageContent = {
8
+ type: "image";
9
+ data: string;
10
+ mimeType: "image/jpeg";
11
+ };
12
+ type ToolContent = TextContent | ImageContent;
13
+ type ToolResult = {
14
+ content: ToolContent[];
15
+ };
16
+ export declare function executeControl(config: ZhiHandConfig, params: ControlParams): Promise<ToolResult>;
17
+ export declare function executeScreenshot(config: ZhiHandConfig): Promise<ToolResult>;
18
+ export {};