@zhihand/mcp 0.12.0 → 0.12.2
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/README.md +288 -0
- package/bin/zhihand +6 -6
- package/bin/zhihand.openclaw +2 -2
- package/dist/cli/detect.d.ts +10 -0
- package/dist/cli/detect.js +75 -0
- package/dist/cli/openclaw.d.ts +6 -0
- package/dist/cli/openclaw.js +47 -0
- package/dist/cli/spawn.d.ts +2 -0
- package/dist/cli/spawn.js +31 -0
- package/dist/core/command.d.ts +41 -0
- package/dist/core/command.js +84 -0
- package/dist/core/config.d.ts +26 -0
- package/dist/core/config.js +67 -0
- package/dist/core/pair.d.ts +45 -0
- package/dist/core/pair.js +130 -0
- package/dist/core/screenshot.d.ts +2 -0
- package/dist/core/screenshot.js +21 -0
- package/dist/core/sse.d.ts +35 -0
- package/dist/core/sse.js +149 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +43 -0
- package/dist/openclaw.adapter.d.ts +49 -0
- package/dist/openclaw.adapter.js +72 -0
- package/dist/tools/control.d.ts +18 -0
- package/dist/tools/control.js +52 -0
- package/dist/tools/pair.d.ts +8 -0
- package/dist/tools/pair.js +49 -0
- package/dist/tools/schemas.d.ts +20 -0
- package/dist/tools/schemas.js +25 -0
- package/dist/tools/screenshot.d.ts +11 -0
- package/dist/tools/screenshot.js +4 -0
- package/package.json +13 -5
- package/src/cli/detect.ts +0 -90
- package/src/cli/openclaw.ts +0 -50
- package/src/cli/spawn.ts +0 -34
- package/src/core/command.ts +0 -144
- package/src/core/config.ts +0 -91
- package/src/core/pair.ts +0 -143
- package/src/core/screenshot.ts +0 -28
- package/src/core/sse.ts +0 -88
- package/src/index.ts +0 -53
- package/src/openclaw.adapter.ts +0 -116
- package/src/tools/control.ts +0 -66
- package/src/tools/pair.ts +0 -58
- package/src/tools/schemas.ts +0 -28
- package/src/tools/screenshot.ts +0 -8
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createControlCommand, enqueueCommand, formatAckSummary } from "../core/command.js";
|
|
2
|
+
import { fetchScreenshotBinary } from "../core/screenshot.js";
|
|
3
|
+
import { waitForCommandAck } from "../core/sse.js";
|
|
4
|
+
function sleep(ms) {
|
|
5
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
6
|
+
}
|
|
7
|
+
export async function executeControl(config, params) {
|
|
8
|
+
// wait: Plugin-local implementation, no server round-trip
|
|
9
|
+
if (params.action === "wait") {
|
|
10
|
+
await sleep(params.durationMs ?? 1000);
|
|
11
|
+
const screenshot = await fetchScreenshotBinary(config);
|
|
12
|
+
return {
|
|
13
|
+
content: [
|
|
14
|
+
{ type: "text", text: `Waited ${params.durationMs ?? 1000}ms` },
|
|
15
|
+
{ type: "image", data: screenshot.toString("base64"), mimeType: "image/jpeg" },
|
|
16
|
+
],
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
// screenshot: send receive_screenshot, App captures immediately (no 2s delay)
|
|
20
|
+
if (params.action === "screenshot") {
|
|
21
|
+
return await executeScreenshot(config);
|
|
22
|
+
}
|
|
23
|
+
// HID operations: enqueue → ACK → GET screenshot
|
|
24
|
+
const command = createControlCommand(params);
|
|
25
|
+
const queued = await enqueueCommand(config, command);
|
|
26
|
+
const ack = await waitForCommandAck(config, { commandId: queued.id, timeoutMs: 15_000 });
|
|
27
|
+
const content = [
|
|
28
|
+
{ type: "text", text: formatAckSummary(params.action, ack) },
|
|
29
|
+
];
|
|
30
|
+
if (ack.acked) {
|
|
31
|
+
try {
|
|
32
|
+
const screenshot = await fetchScreenshotBinary(config);
|
|
33
|
+
content.push({ type: "image", data: screenshot.toString("base64"), mimeType: "image/jpeg" });
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// Screenshot is best-effort after ACK
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return { content };
|
|
40
|
+
}
|
|
41
|
+
export async function executeScreenshot(config) {
|
|
42
|
+
const command = createControlCommand({ action: "screenshot" });
|
|
43
|
+
const queued = await enqueueCommand(config, command);
|
|
44
|
+
const ack = await waitForCommandAck(config, { commandId: queued.id, timeoutMs: 5_000 });
|
|
45
|
+
const screenshot = await fetchScreenshotBinary(config);
|
|
46
|
+
return {
|
|
47
|
+
content: [
|
|
48
|
+
{ type: "text", text: `Screenshot captured (acked: ${ack.acked})` },
|
|
49
|
+
{ type: "image", data: screenshot.toString("base64"), mimeType: "image/jpeg" },
|
|
50
|
+
],
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { loadDefaultCredential } from "../core/config.js";
|
|
2
|
+
import { createPairingSession, registerPlugin, renderPairingQRCode, formatPairingStatus, } from "../core/pair.js";
|
|
3
|
+
const DEFAULT_ENDPOINT = "https://api.zhihand.com";
|
|
4
|
+
const DEFAULT_EDGE_ID_PREFIX = "mcp-";
|
|
5
|
+
function generateEdgeId() {
|
|
6
|
+
return `${DEFAULT_EDGE_ID_PREFIX}${Date.now().toString(36)}`;
|
|
7
|
+
}
|
|
8
|
+
export async function handlePair(params, endpoint) {
|
|
9
|
+
const resolvedEndpoint = endpoint ?? DEFAULT_ENDPOINT;
|
|
10
|
+
// Check existing credential
|
|
11
|
+
if (!params.forceNew) {
|
|
12
|
+
const existing = loadDefaultCredential();
|
|
13
|
+
if (existing) {
|
|
14
|
+
return {
|
|
15
|
+
content: [
|
|
16
|
+
{ type: "text", text: formatPairingStatus(existing) },
|
|
17
|
+
],
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
// Register plugin first — server requires a known edge_id before pairing
|
|
22
|
+
const stableIdentity = generateEdgeId();
|
|
23
|
+
const plugin = await registerPlugin(resolvedEndpoint, {
|
|
24
|
+
stableIdentity,
|
|
25
|
+
});
|
|
26
|
+
// Create new pairing session with the registered edge_id
|
|
27
|
+
const session = await createPairingSession(resolvedEndpoint, {
|
|
28
|
+
edgeId: plugin.edge_id,
|
|
29
|
+
});
|
|
30
|
+
const qr = await renderPairingQRCode(session.pair_url);
|
|
31
|
+
return {
|
|
32
|
+
content: [
|
|
33
|
+
{
|
|
34
|
+
type: "text",
|
|
35
|
+
text: [
|
|
36
|
+
"Scan QR code or open URL on your phone to pair:",
|
|
37
|
+
"",
|
|
38
|
+
qr,
|
|
39
|
+
"",
|
|
40
|
+
`URL: ${session.pair_url}`,
|
|
41
|
+
`Expires at: ${session.expires_at}`,
|
|
42
|
+
"",
|
|
43
|
+
"Waiting for phone to scan...",
|
|
44
|
+
"(Call zhihand_pair again after scanning to check status)",
|
|
45
|
+
].join("\n"),
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const controlSchema: {
|
|
3
|
+
action: z.ZodEnum<["click", "doubleclick", "rightclick", "middleclick", "type", "swipe", "scroll", "keycombo", "clipboard", "wait", "screenshot"]>;
|
|
4
|
+
xRatio: z.ZodOptional<z.ZodNumber>;
|
|
5
|
+
yRatio: z.ZodOptional<z.ZodNumber>;
|
|
6
|
+
text: z.ZodOptional<z.ZodString>;
|
|
7
|
+
direction: z.ZodOptional<z.ZodEnum<["up", "down", "left", "right"]>>;
|
|
8
|
+
amount: z.ZodOptional<z.ZodDefault<z.ZodNumber>>;
|
|
9
|
+
keys: z.ZodOptional<z.ZodString>;
|
|
10
|
+
clipboardAction: z.ZodOptional<z.ZodEnum<["get", "set"]>>;
|
|
11
|
+
durationMs: z.ZodOptional<z.ZodDefault<z.ZodNumber>>;
|
|
12
|
+
startXRatio: z.ZodOptional<z.ZodNumber>;
|
|
13
|
+
startYRatio: z.ZodOptional<z.ZodNumber>;
|
|
14
|
+
endXRatio: z.ZodOptional<z.ZodNumber>;
|
|
15
|
+
endYRatio: z.ZodOptional<z.ZodNumber>;
|
|
16
|
+
};
|
|
17
|
+
export declare const screenshotSchema: {};
|
|
18
|
+
export declare const pairSchema: {
|
|
19
|
+
forceNew: z.ZodOptional<z.ZodDefault<z.ZodBoolean>>;
|
|
20
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const controlSchema = {
|
|
3
|
+
action: z.enum([
|
|
4
|
+
"click", "doubleclick", "rightclick", "middleclick",
|
|
5
|
+
"type", "swipe", "scroll", "keycombo",
|
|
6
|
+
"clipboard",
|
|
7
|
+
"wait", "screenshot",
|
|
8
|
+
]),
|
|
9
|
+
xRatio: z.number().min(0).max(1).optional().describe("Normalized horizontal position [0,1]"),
|
|
10
|
+
yRatio: z.number().min(0).max(1).optional().describe("Normalized vertical position [0,1]"),
|
|
11
|
+
text: z.string().optional().describe("Text for type or clipboard set"),
|
|
12
|
+
direction: z.enum(["up", "down", "left", "right"]).optional().describe("Scroll direction"),
|
|
13
|
+
amount: z.number().int().positive().default(3).optional().describe("Scroll steps (default 3)"),
|
|
14
|
+
keys: z.string().optional().describe("Key combo string, e.g. 'ctrl+c', 'alt+tab'"),
|
|
15
|
+
clipboardAction: z.enum(["get", "set"]).optional().describe("Clipboard action"),
|
|
16
|
+
durationMs: z.number().int().positive().max(10000).default(1000).optional().describe("Duration in ms for wait (default 1000, max 10000)"),
|
|
17
|
+
startXRatio: z.number().min(0).max(1).optional().describe("Swipe start X [0,1]"),
|
|
18
|
+
startYRatio: z.number().min(0).max(1).optional().describe("Swipe start Y [0,1]"),
|
|
19
|
+
endXRatio: z.number().min(0).max(1).optional().describe("Swipe end X [0,1]"),
|
|
20
|
+
endYRatio: z.number().min(0).max(1).optional().describe("Swipe end Y [0,1]"),
|
|
21
|
+
};
|
|
22
|
+
export const screenshotSchema = {};
|
|
23
|
+
export const pairSchema = {
|
|
24
|
+
forceNew: z.boolean().default(false).optional().describe("Force new pairing even if already paired"),
|
|
25
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ZhiHandConfig } from "../core/config.ts";
|
|
2
|
+
export declare function handleScreenshot(config: ZhiHandConfig): Promise<{
|
|
3
|
+
content: ({
|
|
4
|
+
type: "text";
|
|
5
|
+
text: string;
|
|
6
|
+
} | {
|
|
7
|
+
type: "image";
|
|
8
|
+
data: string;
|
|
9
|
+
mimeType: "image/jpeg";
|
|
10
|
+
})[];
|
|
11
|
+
}>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhihand/mcp",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "ZhiHand MCP Server — phone control tools for Claude Code, Codex, Gemini CLI, and OpenClaw",
|
|
@@ -16,19 +16,22 @@
|
|
|
16
16
|
"zhihand.openclaw": "./bin/zhihand.openclaw"
|
|
17
17
|
},
|
|
18
18
|
"exports": {
|
|
19
|
-
".": "./
|
|
20
|
-
"./openclaw": "./
|
|
19
|
+
".": "./dist/index.js",
|
|
20
|
+
"./openclaw": "./dist/openclaw.adapter.js"
|
|
21
21
|
},
|
|
22
22
|
"files": [
|
|
23
23
|
"README.md",
|
|
24
24
|
"bin/",
|
|
25
|
-
"
|
|
25
|
+
"dist/"
|
|
26
26
|
],
|
|
27
27
|
"publishConfig": {
|
|
28
28
|
"access": "public"
|
|
29
29
|
},
|
|
30
30
|
"scripts": {
|
|
31
|
-
"
|
|
31
|
+
"build": "tsc",
|
|
32
|
+
"prepublishOnly": "npm run build",
|
|
33
|
+
"start": "node dist/index.js",
|
|
34
|
+
"dev": "node --experimental-strip-types src/index.ts",
|
|
32
35
|
"test": "node --test --experimental-strip-types src/**/*.test.ts"
|
|
33
36
|
},
|
|
34
37
|
"dependencies": {
|
|
@@ -38,5 +41,10 @@
|
|
|
38
41
|
},
|
|
39
42
|
"engines": {
|
|
40
43
|
"node": ">=22"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@types/node": "^25.5.0",
|
|
47
|
+
"@types/qrcode": "^1.5.6",
|
|
48
|
+
"typescript": "^6.0.2"
|
|
41
49
|
}
|
|
42
50
|
}
|
package/src/cli/detect.ts
DELETED
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
import { execSync } from "node:child_process";
|
|
2
|
-
|
|
3
|
-
export interface CLITool {
|
|
4
|
-
name: "claudecode" | "codex" | "gemini" | "openclaw";
|
|
5
|
-
command: string;
|
|
6
|
-
version: string;
|
|
7
|
-
loggedIn: boolean;
|
|
8
|
-
priority: number;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
function tryExec(cmd: string): string | null {
|
|
12
|
-
try {
|
|
13
|
-
return execSync(cmd, { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
14
|
-
} catch {
|
|
15
|
-
return null;
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function isCommandAvailable(cmd: string): boolean {
|
|
20
|
-
return tryExec(`which ${cmd}`) !== null;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
async function detectClaudeCode(): Promise<CLITool | null> {
|
|
24
|
-
if (!isCommandAvailable("claude")) return null;
|
|
25
|
-
const version = tryExec("claude --version") ?? "unknown";
|
|
26
|
-
// Check login: claude has config in ~/.claude/
|
|
27
|
-
const loggedIn = tryExec("ls ~/.claude/settings.json") !== null;
|
|
28
|
-
return { name: "claudecode", command: "claude", version, loggedIn, priority: 1 };
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
async function detectCodex(): Promise<CLITool | null> {
|
|
32
|
-
if (!isCommandAvailable("codex")) return null;
|
|
33
|
-
const version = tryExec("codex --version") ?? "unknown";
|
|
34
|
-
// Check login: OPENAI_API_KEY env var or config
|
|
35
|
-
const loggedIn = !!process.env.OPENAI_API_KEY || tryExec("ls ~/.codex/") !== null;
|
|
36
|
-
return { name: "codex", command: "codex", version, loggedIn, priority: 2 };
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
async function detectGemini(): Promise<CLITool | null> {
|
|
40
|
-
if (!isCommandAvailable("gemini")) return null;
|
|
41
|
-
const version = tryExec("gemini --version") ?? "unknown";
|
|
42
|
-
// Check login: Google Cloud auth
|
|
43
|
-
const loggedIn = tryExec("gemini auth status") !== null;
|
|
44
|
-
return { name: "gemini", command: "gemini", version, loggedIn, priority: 3 };
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
async function detectOpenClaw(): Promise<CLITool | null> {
|
|
48
|
-
if (!isCommandAvailable("openclaw")) return null;
|
|
49
|
-
const version = tryExec("openclaw --version") ?? "unknown";
|
|
50
|
-
const loggedIn = tryExec("ls ~/.openclaw/openclaw.json") !== null;
|
|
51
|
-
return { name: "openclaw", command: "openclaw", version, loggedIn, priority: 4 };
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export async function detectCLITools(): Promise<CLITool[]> {
|
|
55
|
-
const results = await Promise.allSettled([
|
|
56
|
-
detectClaudeCode(),
|
|
57
|
-
detectCodex(),
|
|
58
|
-
detectGemini(),
|
|
59
|
-
detectOpenClaw(),
|
|
60
|
-
]);
|
|
61
|
-
|
|
62
|
-
return results
|
|
63
|
-
.filter((r): r is PromiseFulfilledResult<CLITool | null> => r.status === "fulfilled")
|
|
64
|
-
.map((r) => r.value)
|
|
65
|
-
.filter((t): t is CLITool => t !== null)
|
|
66
|
-
.sort((a, b) => a.priority - b.priority);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export async function detectBestCLI(): Promise<CLITool | null> {
|
|
70
|
-
const cliOverride = process.env.ZHIHAND_CLI;
|
|
71
|
-
const tools = await detectCLITools();
|
|
72
|
-
|
|
73
|
-
if (cliOverride) {
|
|
74
|
-
const match = tools.find((t) => t.name === cliOverride || t.command === cliOverride);
|
|
75
|
-
if (match) return match;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// Return best available tool (logged in + highest priority)
|
|
79
|
-
return tools.find((t) => t.loggedIn) ?? tools[0] ?? null;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export function formatDetectedTools(tools: CLITool[]): string {
|
|
83
|
-
if (tools.length === 0) return "No CLI tools detected.";
|
|
84
|
-
return [
|
|
85
|
-
"Detected CLI tools:",
|
|
86
|
-
...tools.map((t) =>
|
|
87
|
-
` ${t.loggedIn ? "✓" : "✗"} ${t.name} (${t.command} ${t.version})${t.loggedIn ? "" : " — not logged in"}`
|
|
88
|
-
),
|
|
89
|
-
].join("\n");
|
|
90
|
-
}
|
package/src/cli/openclaw.ts
DELETED
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
import { execSync } from "node:child_process";
|
|
2
|
-
|
|
3
|
-
function tryExec(cmd: string): string | null {
|
|
4
|
-
try {
|
|
5
|
-
return execSync(cmd, { encoding: "utf8", timeout: 30_000, stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
6
|
-
} catch {
|
|
7
|
-
return null;
|
|
8
|
-
}
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
function isCommandAvailable(cmd: string): boolean {
|
|
12
|
-
return tryExec(`which ${cmd}`) !== null;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export async function isZhiHandPluginInstalled(): Promise<boolean> {
|
|
16
|
-
const output = tryExec("openclaw plugins list");
|
|
17
|
-
if (!output) return false;
|
|
18
|
-
return output.includes("zhihand") || output.includes("@zhihand/mcp");
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export async function installZhiHandPlugin(
|
|
22
|
-
options: { timeoutMs?: number; autoConfirm?: boolean } = {}
|
|
23
|
-
): Promise<boolean> {
|
|
24
|
-
const timeout = options.timeoutMs ?? 30_000;
|
|
25
|
-
try {
|
|
26
|
-
execSync("openclaw plugins install @zhihand/mcp", {
|
|
27
|
-
encoding: "utf8",
|
|
28
|
-
timeout,
|
|
29
|
-
stdio: options.autoConfirm ? ["pipe", "pipe", "pipe"] : "inherit",
|
|
30
|
-
});
|
|
31
|
-
return true;
|
|
32
|
-
} catch {
|
|
33
|
-
return false;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export async function detectAndSetupOpenClaw(): Promise<void> {
|
|
38
|
-
if (!isCommandAvailable("openclaw")) return;
|
|
39
|
-
|
|
40
|
-
const pluginInstalled = await isZhiHandPluginInstalled();
|
|
41
|
-
if (pluginInstalled) return;
|
|
42
|
-
|
|
43
|
-
process.stderr.write("[zhihand] Detected OpenClaw without ZhiHand plugin. Installing...\n");
|
|
44
|
-
const success = await installZhiHandPlugin({ timeoutMs: 30_000, autoConfirm: true });
|
|
45
|
-
if (success) {
|
|
46
|
-
process.stderr.write("[zhihand] ZhiHand plugin installed to OpenClaw.\n");
|
|
47
|
-
} else {
|
|
48
|
-
process.stderr.write("[zhihand] Failed to install ZhiHand plugin to OpenClaw.\n");
|
|
49
|
-
}
|
|
50
|
-
}
|
package/src/cli/spawn.ts
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import { execSync } from "node:child_process";
|
|
2
|
-
import type { CLITool } from "./detect.ts";
|
|
3
|
-
|
|
4
|
-
function shellEscape(s: string): string {
|
|
5
|
-
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export async function spawnCLITask(tool: CLITool, prompt: string): Promise<string> {
|
|
9
|
-
const escaped = shellEscape(prompt);
|
|
10
|
-
switch (tool.name) {
|
|
11
|
-
case "claudecode":
|
|
12
|
-
return execSync(`${tool.command} -p ${escaped} --output-format json`, {
|
|
13
|
-
encoding: "utf8",
|
|
14
|
-
timeout: 300_000,
|
|
15
|
-
});
|
|
16
|
-
case "codex":
|
|
17
|
-
return execSync(`${tool.command} -q ${escaped} --json`, {
|
|
18
|
-
encoding: "utf8",
|
|
19
|
-
timeout: 300_000,
|
|
20
|
-
});
|
|
21
|
-
case "gemini":
|
|
22
|
-
return execSync(`${tool.command} -p ${escaped}`, {
|
|
23
|
-
encoding: "utf8",
|
|
24
|
-
timeout: 300_000,
|
|
25
|
-
});
|
|
26
|
-
case "openclaw":
|
|
27
|
-
return execSync(`${tool.command} run ${escaped}`, {
|
|
28
|
-
encoding: "utf8",
|
|
29
|
-
timeout: 300_000,
|
|
30
|
-
});
|
|
31
|
-
default:
|
|
32
|
-
throw new Error(`Unsupported CLI tool: ${tool.name}`);
|
|
33
|
-
}
|
|
34
|
-
}
|
package/src/core/command.ts
DELETED
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
import type { ZhiHandConfig } from "./config.ts";
|
|
2
|
-
|
|
3
|
-
export type ScrollDirection = "up" | "down" | "left" | "right";
|
|
4
|
-
export type ClipboardAction = "get" | "set";
|
|
5
|
-
|
|
6
|
-
export interface ControlParams {
|
|
7
|
-
action: string;
|
|
8
|
-
xRatio?: number;
|
|
9
|
-
yRatio?: number;
|
|
10
|
-
text?: string;
|
|
11
|
-
direction?: ScrollDirection;
|
|
12
|
-
amount?: number;
|
|
13
|
-
keys?: string;
|
|
14
|
-
clipboardAction?: ClipboardAction;
|
|
15
|
-
durationMs?: number;
|
|
16
|
-
startXRatio?: number;
|
|
17
|
-
startYRatio?: number;
|
|
18
|
-
endXRatio?: number;
|
|
19
|
-
endYRatio?: number;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export interface QueuedControlCommand {
|
|
23
|
-
type: string;
|
|
24
|
-
payload?: Record<string, unknown>;
|
|
25
|
-
messageId?: number;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export interface QueuedCommandRecord {
|
|
29
|
-
id: string;
|
|
30
|
-
credential_id: string;
|
|
31
|
-
status: string;
|
|
32
|
-
command: QueuedControlCommand;
|
|
33
|
-
created_at: string;
|
|
34
|
-
acked_at?: string;
|
|
35
|
-
ack_status?: string;
|
|
36
|
-
ack_result?: Record<string, unknown>;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export interface WaitForCommandAckResult {
|
|
40
|
-
acked: boolean;
|
|
41
|
-
command?: QueuedCommandRecord;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
let messageCounter = 0;
|
|
45
|
-
|
|
46
|
-
function nextMessageId(): number {
|
|
47
|
-
messageCounter = (messageCounter + 1) % 1000;
|
|
48
|
-
return (Date.now() * 1000) + messageCounter;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export function createControlCommand(params: ControlParams): QueuedControlCommand {
|
|
52
|
-
switch (params.action) {
|
|
53
|
-
case "click":
|
|
54
|
-
return { type: "receive_click", payload: { x: params.xRatio, y: params.yRatio } };
|
|
55
|
-
case "doubleclick":
|
|
56
|
-
return { type: "receive_doubleclick", payload: { x: params.xRatio, y: params.yRatio } };
|
|
57
|
-
case "rightclick":
|
|
58
|
-
return { type: "receive_rightclick", payload: { x: params.xRatio, y: params.yRatio } };
|
|
59
|
-
case "middleclick":
|
|
60
|
-
return { type: "receive_middleclick", payload: { x: params.xRatio, y: params.yRatio } };
|
|
61
|
-
case "type":
|
|
62
|
-
return { type: "receive_type", payload: { text: params.text } };
|
|
63
|
-
case "swipe":
|
|
64
|
-
return {
|
|
65
|
-
type: "receive_swipe",
|
|
66
|
-
payload: {
|
|
67
|
-
startX: params.startXRatio,
|
|
68
|
-
startY: params.startYRatio,
|
|
69
|
-
endX: params.endXRatio,
|
|
70
|
-
endY: params.endYRatio,
|
|
71
|
-
},
|
|
72
|
-
};
|
|
73
|
-
case "scroll":
|
|
74
|
-
return {
|
|
75
|
-
type: "receive_scroll",
|
|
76
|
-
payload: {
|
|
77
|
-
x: params.xRatio,
|
|
78
|
-
y: params.yRatio,
|
|
79
|
-
direction: params.direction,
|
|
80
|
-
amount: params.amount ?? 3,
|
|
81
|
-
},
|
|
82
|
-
};
|
|
83
|
-
case "keycombo":
|
|
84
|
-
return { type: "receive_keycombo", payload: { keys: params.keys } };
|
|
85
|
-
case "clipboard":
|
|
86
|
-
return {
|
|
87
|
-
type: "receive_clipboard",
|
|
88
|
-
payload: { action: params.clipboardAction, text: params.text },
|
|
89
|
-
};
|
|
90
|
-
case "screenshot":
|
|
91
|
-
return { type: "receive_screenshot", payload: {} };
|
|
92
|
-
default:
|
|
93
|
-
throw new Error(`Unsupported action: ${params.action}`);
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
export async function enqueueCommand(
|
|
98
|
-
config: ZhiHandConfig,
|
|
99
|
-
command: QueuedControlCommand
|
|
100
|
-
): Promise<QueuedCommandRecord> {
|
|
101
|
-
const response = await fetch(
|
|
102
|
-
`${config.controlPlaneEndpoint}/v1/credentials/${encodeURIComponent(config.credentialId)}/commands`,
|
|
103
|
-
{
|
|
104
|
-
method: "POST",
|
|
105
|
-
headers: {
|
|
106
|
-
"Content-Type": "application/json",
|
|
107
|
-
"x-zhihand-controller-token": config.controllerToken,
|
|
108
|
-
},
|
|
109
|
-
body: JSON.stringify({
|
|
110
|
-
command: { ...command, message_id: command.messageId ?? nextMessageId() },
|
|
111
|
-
}),
|
|
112
|
-
}
|
|
113
|
-
);
|
|
114
|
-
if (!response.ok) {
|
|
115
|
-
throw new Error(`Enqueue command failed: ${response.status}`);
|
|
116
|
-
}
|
|
117
|
-
const payload = (await response.json()) as { command: QueuedCommandRecord };
|
|
118
|
-
return payload.command;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
export async function getCommand(
|
|
122
|
-
config: ZhiHandConfig,
|
|
123
|
-
commandId: string
|
|
124
|
-
): Promise<QueuedCommandRecord> {
|
|
125
|
-
const response = await fetch(
|
|
126
|
-
`${config.controlPlaneEndpoint}/v1/credentials/${encodeURIComponent(config.credentialId)}/commands/${encodeURIComponent(commandId)}`,
|
|
127
|
-
{
|
|
128
|
-
headers: { "x-zhihand-controller-token": config.controllerToken },
|
|
129
|
-
}
|
|
130
|
-
);
|
|
131
|
-
if (!response.ok) {
|
|
132
|
-
throw new Error(`Get command failed: ${response.status}`);
|
|
133
|
-
}
|
|
134
|
-
const payload = (await response.json()) as { command: QueuedCommandRecord };
|
|
135
|
-
return payload.command;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
export function formatAckSummary(action: string, result: WaitForCommandAckResult): string {
|
|
139
|
-
if (!result.acked) {
|
|
140
|
-
return `Sent ${action}, waiting for ACK (timed out).`;
|
|
141
|
-
}
|
|
142
|
-
const ackStatus = result.command?.ack_status ?? "ok";
|
|
143
|
-
return `Sent ${action}. ACK: ${ackStatus}`;
|
|
144
|
-
}
|
package/src/core/config.ts
DELETED
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import os from "node:os";
|
|
4
|
-
|
|
5
|
-
export interface DeviceCredential {
|
|
6
|
-
credentialId: string;
|
|
7
|
-
controllerToken: string;
|
|
8
|
-
endpoint: string;
|
|
9
|
-
deviceName?: string;
|
|
10
|
-
pairedAt?: string;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export interface CredentialStore {
|
|
14
|
-
default: string;
|
|
15
|
-
devices: Record<string, DeviceCredential>;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export interface ZhiHandConfig {
|
|
19
|
-
controlPlaneEndpoint: string;
|
|
20
|
-
credentialId: string;
|
|
21
|
-
controllerToken: string;
|
|
22
|
-
edgeId?: string;
|
|
23
|
-
timeoutMs?: number;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const ZHIHAND_DIR = path.join(os.homedir(), ".zhihand");
|
|
27
|
-
const CREDENTIALS_PATH = path.join(ZHIHAND_DIR, "credentials.json");
|
|
28
|
-
const STATE_PATH = path.join(ZHIHAND_DIR, "state.json");
|
|
29
|
-
|
|
30
|
-
export function resolveZhiHandDir(): string {
|
|
31
|
-
return ZHIHAND_DIR;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function ensureZhiHandDir(): void {
|
|
35
|
-
fs.mkdirSync(ZHIHAND_DIR, { recursive: true });
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function loadCredentialStore(): CredentialStore | null {
|
|
39
|
-
if (!fs.existsSync(CREDENTIALS_PATH)) return null;
|
|
40
|
-
try {
|
|
41
|
-
return JSON.parse(fs.readFileSync(CREDENTIALS_PATH, "utf8")) as CredentialStore;
|
|
42
|
-
} catch {
|
|
43
|
-
return null;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export function loadDefaultCredential(): DeviceCredential | null {
|
|
48
|
-
const store = loadCredentialStore();
|
|
49
|
-
if (!store) return null;
|
|
50
|
-
return store.devices[store.default] ?? null;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export function saveCredential(name: string, cred: DeviceCredential, setDefault: boolean = true): void {
|
|
54
|
-
ensureZhiHandDir();
|
|
55
|
-
let store = loadCredentialStore() ?? { default: name, devices: {} };
|
|
56
|
-
store.devices[name] = cred;
|
|
57
|
-
if (setDefault) store.default = name;
|
|
58
|
-
fs.writeFileSync(CREDENTIALS_PATH, JSON.stringify(store, null, 2));
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export function resolveConfig(deviceName?: string): ZhiHandConfig {
|
|
62
|
-
const store = loadCredentialStore();
|
|
63
|
-
if (!store) {
|
|
64
|
-
throw new Error("No ZhiHand credentials found. Run 'zhihand pair' first.");
|
|
65
|
-
}
|
|
66
|
-
const name = deviceName ?? store.default;
|
|
67
|
-
const cred = store.devices[name];
|
|
68
|
-
if (!cred) {
|
|
69
|
-
throw new Error(`Device '${name}' not found. Available: ${Object.keys(store.devices).join(", ")}`);
|
|
70
|
-
}
|
|
71
|
-
return {
|
|
72
|
-
controlPlaneEndpoint: cred.endpoint,
|
|
73
|
-
credentialId: cred.credentialId,
|
|
74
|
-
controllerToken: cred.controllerToken,
|
|
75
|
-
timeoutMs: 10_000,
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export function loadState<T = unknown>(): T | null {
|
|
80
|
-
if (!fs.existsSync(STATE_PATH)) return null;
|
|
81
|
-
try {
|
|
82
|
-
return JSON.parse(fs.readFileSync(STATE_PATH, "utf8")) as T;
|
|
83
|
-
} catch {
|
|
84
|
-
return null;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export function saveState(state: unknown): void {
|
|
89
|
-
ensureZhiHandDir();
|
|
90
|
-
fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2));
|
|
91
|
-
}
|