@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.
- 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 +124 -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
package/README.md
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
# @zhihand/mcp
|
|
2
|
+
|
|
3
|
+
ZhiHand MCP Server — let AI agents see and control your phone.
|
|
4
|
+
|
|
5
|
+
Version: `0.12.1`
|
|
6
|
+
|
|
7
|
+
## What is this?
|
|
8
|
+
|
|
9
|
+
`@zhihand/mcp` is the core integration layer for ZhiHand. It provides an [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server that exposes phone control tools to any compatible AI agent, including:
|
|
10
|
+
|
|
11
|
+
- **Claude Code**
|
|
12
|
+
- **Codex CLI**
|
|
13
|
+
- **Gemini CLI**
|
|
14
|
+
- **OpenClaw**
|
|
15
|
+
|
|
16
|
+
One npm package, two entry points:
|
|
17
|
+
|
|
18
|
+
| Entry | Purpose |
|
|
19
|
+
|---|---|
|
|
20
|
+
| `zhihand serve` | MCP Server (stdio) — used by Claude Code, Codex, Gemini CLI |
|
|
21
|
+
| `zhihand.openclaw` | OpenClaw Plugin entry — thin wrapper calling the same core |
|
|
22
|
+
|
|
23
|
+
## Requirements
|
|
24
|
+
|
|
25
|
+
- **Node.js >= 22**
|
|
26
|
+
- A **ZhiHand mobile app** (Android or iOS) installed on your phone
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npm install -g @zhihand/mcp
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Or use directly with `npx`:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npx @zhihand/mcp serve
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Quick Start
|
|
41
|
+
|
|
42
|
+
### 1. Pair your phone
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
zhihand setup
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
This runs the full interactive setup:
|
|
49
|
+
|
|
50
|
+
1. Registers as a plugin with the ZhiHand server
|
|
51
|
+
2. Creates a pairing session and displays a QR code in the terminal
|
|
52
|
+
3. Waits for you to scan the QR code with the ZhiHand mobile app
|
|
53
|
+
4. Saves credentials to `~/.zhihand/credentials.json`
|
|
54
|
+
5. Detects installed CLI tools (Claude Code, Codex, Gemini CLI, OpenClaw)
|
|
55
|
+
6. Prints the MCP configuration snippet for your tools
|
|
56
|
+
|
|
57
|
+
### 2. Configure your AI tool
|
|
58
|
+
|
|
59
|
+
Add the ZhiHand MCP server to your tool's configuration:
|
|
60
|
+
|
|
61
|
+
**Claude Code** — Add to `.mcp.json` in your project root, or run:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
claude mcp add zhihand -- zhihand serve
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Or manually create/edit `.mcp.json`:
|
|
68
|
+
|
|
69
|
+
```json
|
|
70
|
+
{
|
|
71
|
+
"mcpServers": {
|
|
72
|
+
"zhihand": {
|
|
73
|
+
"command": "zhihand",
|
|
74
|
+
"args": ["serve"]
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
**Codex CLI** — Add to your MCP config:
|
|
81
|
+
|
|
82
|
+
```json
|
|
83
|
+
{
|
|
84
|
+
"mcpServers": {
|
|
85
|
+
"zhihand": {
|
|
86
|
+
"command": "zhihand",
|
|
87
|
+
"args": ["serve"]
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
**Gemini CLI** — Add to `~/.gemini/settings.json`:
|
|
94
|
+
|
|
95
|
+
```json
|
|
96
|
+
{
|
|
97
|
+
"mcpServers": {
|
|
98
|
+
"zhihand": {
|
|
99
|
+
"command": "zhihand",
|
|
100
|
+
"args": ["serve"]
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
**OpenClaw** — Install the plugin directly:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
openclaw plugins install @zhihand/mcp
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### 3. Start using it
|
|
113
|
+
|
|
114
|
+
Once configured, your AI agent can use ZhiHand tools directly. For example, in Claude Code:
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
> Take a screenshot of my phone
|
|
118
|
+
> Tap on the Settings icon
|
|
119
|
+
> Type "hello world" into the search box
|
|
120
|
+
> Scroll down to find the About section
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## CLI Commands
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
zhihand serve Start MCP Server (stdio mode, called by AI tools)
|
|
127
|
+
zhihand setup Interactive setup: pair + detect tools + print config
|
|
128
|
+
zhihand pair Pair with a phone (QR code in terminal)
|
|
129
|
+
zhihand status Show current pairing status and device info
|
|
130
|
+
zhihand detect List detected CLI tools and their login status
|
|
131
|
+
zhihand --help Show help
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Options
|
|
135
|
+
|
|
136
|
+
| Option | Description |
|
|
137
|
+
|---|---|
|
|
138
|
+
| `--device <name>` | Use a specific paired device (if you have multiple) |
|
|
139
|
+
| `-h, --help` | Show help |
|
|
140
|
+
|
|
141
|
+
### Environment Variables
|
|
142
|
+
|
|
143
|
+
| Variable | Description |
|
|
144
|
+
|---|---|
|
|
145
|
+
| `ZHIHAND_DEVICE` | Default device name (same as `--device`) |
|
|
146
|
+
| `ZHIHAND_CLI` | Override CLI tool selection for mobile-initiated tasks |
|
|
147
|
+
|
|
148
|
+
## MCP Tools
|
|
149
|
+
|
|
150
|
+
The server exposes three tools to AI agents:
|
|
151
|
+
|
|
152
|
+
### `zhihand_control`
|
|
153
|
+
|
|
154
|
+
The main phone control tool. Supports these actions:
|
|
155
|
+
|
|
156
|
+
| Action | Parameters | Description |
|
|
157
|
+
|---|---|---|
|
|
158
|
+
| `click` | `xRatio`, `yRatio` | Tap at normalized coordinates [0,1] |
|
|
159
|
+
| `doubleclick` | `xRatio`, `yRatio` | Double-tap |
|
|
160
|
+
| `rightclick` | `xRatio`, `yRatio` | Right-click (long press) |
|
|
161
|
+
| `middleclick` | `xRatio`, `yRatio` | Middle-click |
|
|
162
|
+
| `type` | `text` | Type text into the focused field |
|
|
163
|
+
| `swipe` | `startXRatio`, `startYRatio`, `endXRatio`, `endYRatio` | Swipe gesture |
|
|
164
|
+
| `scroll` | `xRatio`, `yRatio`, `direction`, `amount` | Scroll up/down/left/right |
|
|
165
|
+
| `keycombo` | `keys` | Key combination (e.g. `"ctrl+c"`, `"alt+tab"`) |
|
|
166
|
+
| `clipboard` | `clipboardAction` (`get`/`set`), `text` | Read or write clipboard |
|
|
167
|
+
| `wait` | `durationMs` | Wait (local sleep, no server round-trip) |
|
|
168
|
+
| `screenshot` | — | Capture screen immediately |
|
|
169
|
+
|
|
170
|
+
Coordinates use **normalized ratios** (0.0 to 1.0), where `(0, 0)` is the top-left corner and `(1, 1)` is the bottom-right. This works across any screen resolution.
|
|
171
|
+
|
|
172
|
+
Every action returns a text summary and a screenshot of the result.
|
|
173
|
+
|
|
174
|
+
### `zhihand_screenshot`
|
|
175
|
+
|
|
176
|
+
Capture the current phone screen without performing any action. Returns an image.
|
|
177
|
+
|
|
178
|
+
No parameters required.
|
|
179
|
+
|
|
180
|
+
### `zhihand_pair`
|
|
181
|
+
|
|
182
|
+
Pair with a phone device. Returns a QR code and pairing URL.
|
|
183
|
+
|
|
184
|
+
| Parameter | Type | Description |
|
|
185
|
+
|---|---|---|
|
|
186
|
+
| `forceNew` | `boolean` | Force new pairing even if already paired (default: `false`) |
|
|
187
|
+
|
|
188
|
+
## How It Works
|
|
189
|
+
|
|
190
|
+
```
|
|
191
|
+
AI Agent ←stdio→ zhihand serve (MCP Server)
|
|
192
|
+
│
|
|
193
|
+
├── POST /v1/plugins Register plugin
|
|
194
|
+
├── POST /v1/pairing/sessions Create pairing
|
|
195
|
+
├── POST /v1/credentials/{id}/commands Send command
|
|
196
|
+
├── GET /v1/credentials/{id}/commands/{cid} Poll ACK
|
|
197
|
+
├── SSE /v1/credentials/{id}/events?topic=commands Real-time ACK
|
|
198
|
+
└── GET /v1/credentials/{id}/screen Fetch screenshot (JPEG)
|
|
199
|
+
│
|
|
200
|
+
ZhiHand Server
|
|
201
|
+
│
|
|
202
|
+
ZhiHand Mobile App
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
1. AI agent calls a tool (e.g. `zhihand_control` with `action: "click"`)
|
|
206
|
+
2. MCP Server translates to a device command and enqueues it via the ZhiHand API
|
|
207
|
+
3. Mobile app picks up the command, executes it, and sends an ACK
|
|
208
|
+
4. MCP Server receives the ACK (via SSE or polling fallback)
|
|
209
|
+
5. MCP Server fetches a fresh screenshot and returns it to the AI agent
|
|
210
|
+
|
|
211
|
+
Screenshots are transferred as raw JPEG binary and only base64-encoded at the LLM API boundary, minimizing bandwidth.
|
|
212
|
+
|
|
213
|
+
## Credential Storage
|
|
214
|
+
|
|
215
|
+
Pairing credentials are stored at:
|
|
216
|
+
|
|
217
|
+
```
|
|
218
|
+
~/.zhihand/
|
|
219
|
+
├── credentials.json # Device credentials (credentialId, controllerToken, endpoint)
|
|
220
|
+
└── state.json # Current pairing session state
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
You can manage multiple devices. The `credentials.json` file stores a `default` device name and a `devices` map:
|
|
224
|
+
|
|
225
|
+
```json
|
|
226
|
+
{
|
|
227
|
+
"default": "mcp-myhost",
|
|
228
|
+
"devices": {
|
|
229
|
+
"mcp-myhost": {
|
|
230
|
+
"credentialId": "cred_abc123",
|
|
231
|
+
"controllerToken": "tok_...",
|
|
232
|
+
"endpoint": "https://api.zhihand.com",
|
|
233
|
+
"deviceName": "mcp-myhost",
|
|
234
|
+
"pairedAt": "2026-04-01T00:00:00.000Z"
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
## Architecture
|
|
241
|
+
|
|
242
|
+
```
|
|
243
|
+
packages/mcp/
|
|
244
|
+
├── bin/
|
|
245
|
+
│ ├── zhihand # Main CLI entry (serve/setup/pair/status/detect)
|
|
246
|
+
│ └── zhihand.openclaw # OpenClaw plugin entry
|
|
247
|
+
├── src/
|
|
248
|
+
│ ├── index.ts # MCP Server (stdio transport)
|
|
249
|
+
│ ├── openclaw.adapter.ts # OpenClaw Plugin adapter (thin wrapper)
|
|
250
|
+
│ ├── core/
|
|
251
|
+
│ │ ├── config.ts # Credential & config management (~/.zhihand/)
|
|
252
|
+
│ │ ├── command.ts # Command creation, enqueue, ACK formatting
|
|
253
|
+
│ │ ├── screenshot.ts # Binary screenshot fetch (JPEG)
|
|
254
|
+
│ │ ├── sse.ts # SSE client + hybrid ACK (SSE push + polling fallback)
|
|
255
|
+
│ │ └── pair.ts # Plugin registration + device pairing flow
|
|
256
|
+
│ ├── tools/
|
|
257
|
+
│ │ ├── schemas.ts # Zod parameter schemas
|
|
258
|
+
│ │ ├── control.ts # zhihand_control handler
|
|
259
|
+
│ │ ├── screenshot.ts # zhihand_screenshot handler
|
|
260
|
+
│ │ └── pair.ts # zhihand_pair handler
|
|
261
|
+
│ └── cli/
|
|
262
|
+
│ ├── detect.ts # CLI tool detection (Claude Code, Codex, Gemini, OpenClaw)
|
|
263
|
+
│ ├── spawn.ts # CLI process spawning (for mobile-initiated tasks)
|
|
264
|
+
│ └── openclaw.ts # OpenClaw auto-detect & plugin install
|
|
265
|
+
├── dist/ # Compiled JavaScript (shipped in npm package)
|
|
266
|
+
├── package.json
|
|
267
|
+
└── tsconfig.json
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
## Development
|
|
271
|
+
|
|
272
|
+
```bash
|
|
273
|
+
# Install dependencies
|
|
274
|
+
npm install
|
|
275
|
+
|
|
276
|
+
# Build (compiles TypeScript to dist/)
|
|
277
|
+
npm run build
|
|
278
|
+
|
|
279
|
+
# Run in development mode (uses --experimental-strip-types)
|
|
280
|
+
npm run dev
|
|
281
|
+
|
|
282
|
+
# Run tests
|
|
283
|
+
npm test
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
## License
|
|
287
|
+
|
|
288
|
+
MIT
|
package/bin/zhihand
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
1
|
+
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import { parseArgs } from "node:util";
|
|
5
|
-
import { startStdioServer } from "../
|
|
6
|
-
import { detectCLITools, formatDetectedTools } from "../
|
|
7
|
-
import { detectAndSetupOpenClaw } from "../
|
|
8
|
-
import { loadDefaultCredential } from "../
|
|
9
|
-
import { executePairing } from "../
|
|
5
|
+
import { startStdioServer } from "../dist/index.js";
|
|
6
|
+
import { detectCLITools, formatDetectedTools } from "../dist/cli/detect.js";
|
|
7
|
+
import { detectAndSetupOpenClaw } from "../dist/cli/openclaw.js";
|
|
8
|
+
import { loadDefaultCredential } from "../dist/core/config.js";
|
|
9
|
+
import { executePairing } from "../dist/core/pair.js";
|
|
10
10
|
|
|
11
11
|
const DEFAULT_ENDPOINT = "https://api.zhihand.com";
|
|
12
12
|
|
package/bin/zhihand.openclaw
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
1
|
+
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* OpenClaw Plugin entry point.
|
|
5
5
|
* This script is invoked by OpenClaw when the zhihand plugin is loaded.
|
|
6
6
|
* It bridges the OpenClaw Plugin API to MCP core logic via the adapter.
|
|
7
7
|
*/
|
|
8
|
-
import { registerOpenClawTools } from "../
|
|
8
|
+
import { registerOpenClawTools } from "../dist/openclaw.adapter.js";
|
|
9
9
|
|
|
10
10
|
// OpenClaw injects the plugin API as the default export's argument
|
|
11
11
|
export default function activate(api) {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface CLITool {
|
|
2
|
+
name: "claudecode" | "codex" | "gemini" | "openclaw";
|
|
3
|
+
command: string;
|
|
4
|
+
version: string;
|
|
5
|
+
loggedIn: boolean;
|
|
6
|
+
priority: number;
|
|
7
|
+
}
|
|
8
|
+
export declare function detectCLITools(): Promise<CLITool[]>;
|
|
9
|
+
export declare function detectBestCLI(): Promise<CLITool | null>;
|
|
10
|
+
export declare function formatDetectedTools(tools: CLITool[]): string;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
function tryExec(cmd) {
|
|
3
|
+
try {
|
|
4
|
+
return execSync(cmd, { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
5
|
+
}
|
|
6
|
+
catch {
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
function isCommandAvailable(cmd) {
|
|
11
|
+
return tryExec(`which ${cmd}`) !== null;
|
|
12
|
+
}
|
|
13
|
+
async function detectClaudeCode() {
|
|
14
|
+
if (!isCommandAvailable("claude"))
|
|
15
|
+
return null;
|
|
16
|
+
const version = tryExec("claude --version") ?? "unknown";
|
|
17
|
+
// Check login: claude has config in ~/.claude/
|
|
18
|
+
const loggedIn = tryExec("ls ~/.claude/settings.json") !== null;
|
|
19
|
+
return { name: "claudecode", command: "claude", version, loggedIn, priority: 1 };
|
|
20
|
+
}
|
|
21
|
+
async function detectCodex() {
|
|
22
|
+
if (!isCommandAvailable("codex"))
|
|
23
|
+
return null;
|
|
24
|
+
const version = tryExec("codex --version") ?? "unknown";
|
|
25
|
+
// Check login: OPENAI_API_KEY env var or config
|
|
26
|
+
const loggedIn = !!process.env.OPENAI_API_KEY || tryExec("ls ~/.codex/") !== null;
|
|
27
|
+
return { name: "codex", command: "codex", version, loggedIn, priority: 2 };
|
|
28
|
+
}
|
|
29
|
+
async function detectGemini() {
|
|
30
|
+
if (!isCommandAvailable("gemini"))
|
|
31
|
+
return null;
|
|
32
|
+
const version = tryExec("gemini --version") ?? "unknown";
|
|
33
|
+
// Check login: Google Cloud auth
|
|
34
|
+
const loggedIn = tryExec("gemini auth status") !== null;
|
|
35
|
+
return { name: "gemini", command: "gemini", version, loggedIn, priority: 3 };
|
|
36
|
+
}
|
|
37
|
+
async function detectOpenClaw() {
|
|
38
|
+
if (!isCommandAvailable("openclaw"))
|
|
39
|
+
return null;
|
|
40
|
+
const version = tryExec("openclaw --version") ?? "unknown";
|
|
41
|
+
const loggedIn = tryExec("ls ~/.openclaw/openclaw.json") !== null;
|
|
42
|
+
return { name: "openclaw", command: "openclaw", version, loggedIn, priority: 4 };
|
|
43
|
+
}
|
|
44
|
+
export async function detectCLITools() {
|
|
45
|
+
const results = await Promise.allSettled([
|
|
46
|
+
detectClaudeCode(),
|
|
47
|
+
detectCodex(),
|
|
48
|
+
detectGemini(),
|
|
49
|
+
detectOpenClaw(),
|
|
50
|
+
]);
|
|
51
|
+
return results
|
|
52
|
+
.filter((r) => r.status === "fulfilled")
|
|
53
|
+
.map((r) => r.value)
|
|
54
|
+
.filter((t) => t !== null)
|
|
55
|
+
.sort((a, b) => a.priority - b.priority);
|
|
56
|
+
}
|
|
57
|
+
export async function detectBestCLI() {
|
|
58
|
+
const cliOverride = process.env.ZHIHAND_CLI;
|
|
59
|
+
const tools = await detectCLITools();
|
|
60
|
+
if (cliOverride) {
|
|
61
|
+
const match = tools.find((t) => t.name === cliOverride || t.command === cliOverride);
|
|
62
|
+
if (match)
|
|
63
|
+
return match;
|
|
64
|
+
}
|
|
65
|
+
// Return best available tool (logged in + highest priority)
|
|
66
|
+
return tools.find((t) => t.loggedIn) ?? tools[0] ?? null;
|
|
67
|
+
}
|
|
68
|
+
export function formatDetectedTools(tools) {
|
|
69
|
+
if (tools.length === 0)
|
|
70
|
+
return "No CLI tools detected.";
|
|
71
|
+
return [
|
|
72
|
+
"Detected CLI tools:",
|
|
73
|
+
...tools.map((t) => ` ${t.loggedIn ? "✓" : "✗"} ${t.name} (${t.command} ${t.version})${t.loggedIn ? "" : " — not logged in"}`),
|
|
74
|
+
].join("\n");
|
|
75
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
function tryExec(cmd) {
|
|
3
|
+
try {
|
|
4
|
+
return execSync(cmd, { encoding: "utf8", timeout: 30_000, stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
5
|
+
}
|
|
6
|
+
catch {
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
function isCommandAvailable(cmd) {
|
|
11
|
+
return tryExec(`which ${cmd}`) !== null;
|
|
12
|
+
}
|
|
13
|
+
export async function isZhiHandPluginInstalled() {
|
|
14
|
+
const output = tryExec("openclaw plugins list");
|
|
15
|
+
if (!output)
|
|
16
|
+
return false;
|
|
17
|
+
return output.includes("zhihand") || output.includes("@zhihand/mcp");
|
|
18
|
+
}
|
|
19
|
+
export async function installZhiHandPlugin(options = {}) {
|
|
20
|
+
const timeout = options.timeoutMs ?? 30_000;
|
|
21
|
+
try {
|
|
22
|
+
execSync("openclaw plugins install @zhihand/mcp", {
|
|
23
|
+
encoding: "utf8",
|
|
24
|
+
timeout,
|
|
25
|
+
stdio: options.autoConfirm ? ["pipe", "pipe", "pipe"] : "inherit",
|
|
26
|
+
});
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export async function detectAndSetupOpenClaw() {
|
|
34
|
+
if (!isCommandAvailable("openclaw"))
|
|
35
|
+
return;
|
|
36
|
+
const pluginInstalled = await isZhiHandPluginInstalled();
|
|
37
|
+
if (pluginInstalled)
|
|
38
|
+
return;
|
|
39
|
+
process.stderr.write("[zhihand] Detected OpenClaw without ZhiHand plugin. Installing...\n");
|
|
40
|
+
const success = await installZhiHandPlugin({ timeoutMs: 30_000, autoConfirm: true });
|
|
41
|
+
if (success) {
|
|
42
|
+
process.stderr.write("[zhihand] ZhiHand plugin installed to OpenClaw.\n");
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
process.stderr.write("[zhihand] Failed to install ZhiHand plugin to OpenClaw.\n");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
function shellEscape(s) {
|
|
3
|
+
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
4
|
+
}
|
|
5
|
+
export async function spawnCLITask(tool, prompt) {
|
|
6
|
+
const escaped = shellEscape(prompt);
|
|
7
|
+
switch (tool.name) {
|
|
8
|
+
case "claudecode":
|
|
9
|
+
return execSync(`${tool.command} -p ${escaped} --output-format json`, {
|
|
10
|
+
encoding: "utf8",
|
|
11
|
+
timeout: 300_000,
|
|
12
|
+
});
|
|
13
|
+
case "codex":
|
|
14
|
+
return execSync(`${tool.command} -q ${escaped} --json`, {
|
|
15
|
+
encoding: "utf8",
|
|
16
|
+
timeout: 300_000,
|
|
17
|
+
});
|
|
18
|
+
case "gemini":
|
|
19
|
+
return execSync(`${tool.command} -p ${escaped}`, {
|
|
20
|
+
encoding: "utf8",
|
|
21
|
+
timeout: 300_000,
|
|
22
|
+
});
|
|
23
|
+
case "openclaw":
|
|
24
|
+
return execSync(`${tool.command} run ${escaped}`, {
|
|
25
|
+
encoding: "utf8",
|
|
26
|
+
timeout: 300_000,
|
|
27
|
+
});
|
|
28
|
+
default:
|
|
29
|
+
throw new Error(`Unsupported CLI tool: ${tool.name}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ZhiHandConfig } from "./config.ts";
|
|
2
|
+
export type ScrollDirection = "up" | "down" | "left" | "right";
|
|
3
|
+
export type ClipboardAction = "get" | "set";
|
|
4
|
+
export interface ControlParams {
|
|
5
|
+
action: string;
|
|
6
|
+
xRatio?: number;
|
|
7
|
+
yRatio?: number;
|
|
8
|
+
text?: string;
|
|
9
|
+
direction?: ScrollDirection;
|
|
10
|
+
amount?: number;
|
|
11
|
+
keys?: string;
|
|
12
|
+
clipboardAction?: ClipboardAction;
|
|
13
|
+
durationMs?: number;
|
|
14
|
+
startXRatio?: number;
|
|
15
|
+
startYRatio?: number;
|
|
16
|
+
endXRatio?: number;
|
|
17
|
+
endYRatio?: number;
|
|
18
|
+
}
|
|
19
|
+
export interface QueuedControlCommand {
|
|
20
|
+
type: string;
|
|
21
|
+
payload?: Record<string, unknown>;
|
|
22
|
+
messageId?: number;
|
|
23
|
+
}
|
|
24
|
+
export interface QueuedCommandRecord {
|
|
25
|
+
id: string;
|
|
26
|
+
credential_id: string;
|
|
27
|
+
status: string;
|
|
28
|
+
command: QueuedControlCommand;
|
|
29
|
+
created_at: string;
|
|
30
|
+
acked_at?: string;
|
|
31
|
+
ack_status?: string;
|
|
32
|
+
ack_result?: Record<string, unknown>;
|
|
33
|
+
}
|
|
34
|
+
export interface WaitForCommandAckResult {
|
|
35
|
+
acked: boolean;
|
|
36
|
+
command?: QueuedCommandRecord;
|
|
37
|
+
}
|
|
38
|
+
export declare function createControlCommand(params: ControlParams): QueuedControlCommand;
|
|
39
|
+
export declare function enqueueCommand(config: ZhiHandConfig, command: QueuedControlCommand): Promise<QueuedCommandRecord>;
|
|
40
|
+
export declare function getCommand(config: ZhiHandConfig, commandId: string): Promise<QueuedCommandRecord>;
|
|
41
|
+
export declare function formatAckSummary(action: string, result: WaitForCommandAckResult): string;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
let messageCounter = 0;
|
|
2
|
+
function nextMessageId() {
|
|
3
|
+
messageCounter = (messageCounter + 1) % 1000;
|
|
4
|
+
return (Date.now() * 1000) + messageCounter;
|
|
5
|
+
}
|
|
6
|
+
export function createControlCommand(params) {
|
|
7
|
+
switch (params.action) {
|
|
8
|
+
case "click":
|
|
9
|
+
return { type: "receive_click", payload: { x: params.xRatio, y: params.yRatio } };
|
|
10
|
+
case "doubleclick":
|
|
11
|
+
return { type: "receive_doubleclick", payload: { x: params.xRatio, y: params.yRatio } };
|
|
12
|
+
case "rightclick":
|
|
13
|
+
return { type: "receive_rightclick", payload: { x: params.xRatio, y: params.yRatio } };
|
|
14
|
+
case "middleclick":
|
|
15
|
+
return { type: "receive_middleclick", payload: { x: params.xRatio, y: params.yRatio } };
|
|
16
|
+
case "type":
|
|
17
|
+
return { type: "receive_type", payload: { text: params.text } };
|
|
18
|
+
case "swipe":
|
|
19
|
+
return {
|
|
20
|
+
type: "receive_swipe",
|
|
21
|
+
payload: {
|
|
22
|
+
startX: params.startXRatio,
|
|
23
|
+
startY: params.startYRatio,
|
|
24
|
+
endX: params.endXRatio,
|
|
25
|
+
endY: params.endYRatio,
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
case "scroll":
|
|
29
|
+
return {
|
|
30
|
+
type: "receive_scroll",
|
|
31
|
+
payload: {
|
|
32
|
+
x: params.xRatio,
|
|
33
|
+
y: params.yRatio,
|
|
34
|
+
direction: params.direction,
|
|
35
|
+
amount: params.amount ?? 3,
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
case "keycombo":
|
|
39
|
+
return { type: "receive_keycombo", payload: { keys: params.keys } };
|
|
40
|
+
case "clipboard":
|
|
41
|
+
return {
|
|
42
|
+
type: "receive_clipboard",
|
|
43
|
+
payload: { action: params.clipboardAction, text: params.text },
|
|
44
|
+
};
|
|
45
|
+
case "screenshot":
|
|
46
|
+
return { type: "receive_screenshot", payload: {} };
|
|
47
|
+
default:
|
|
48
|
+
throw new Error(`Unsupported action: ${params.action}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export async function enqueueCommand(config, command) {
|
|
52
|
+
const response = await fetch(`${config.controlPlaneEndpoint}/v1/credentials/${encodeURIComponent(config.credentialId)}/commands`, {
|
|
53
|
+
method: "POST",
|
|
54
|
+
headers: {
|
|
55
|
+
"Content-Type": "application/json",
|
|
56
|
+
"x-zhihand-controller-token": config.controllerToken,
|
|
57
|
+
},
|
|
58
|
+
body: JSON.stringify({
|
|
59
|
+
command: { ...command, message_id: command.messageId ?? nextMessageId() },
|
|
60
|
+
}),
|
|
61
|
+
});
|
|
62
|
+
if (!response.ok) {
|
|
63
|
+
throw new Error(`Enqueue command failed: ${response.status}`);
|
|
64
|
+
}
|
|
65
|
+
const payload = (await response.json());
|
|
66
|
+
return payload.command;
|
|
67
|
+
}
|
|
68
|
+
export async function getCommand(config, commandId) {
|
|
69
|
+
const response = await fetch(`${config.controlPlaneEndpoint}/v1/credentials/${encodeURIComponent(config.credentialId)}/commands/${encodeURIComponent(commandId)}`, {
|
|
70
|
+
headers: { "x-zhihand-controller-token": config.controllerToken },
|
|
71
|
+
});
|
|
72
|
+
if (!response.ok) {
|
|
73
|
+
throw new Error(`Get command failed: ${response.status}`);
|
|
74
|
+
}
|
|
75
|
+
const payload = (await response.json());
|
|
76
|
+
return payload.command;
|
|
77
|
+
}
|
|
78
|
+
export function formatAckSummary(action, result) {
|
|
79
|
+
if (!result.acked) {
|
|
80
|
+
return `Sent ${action}, waiting for ACK (timed out).`;
|
|
81
|
+
}
|
|
82
|
+
const ackStatus = result.command?.ack_status ?? "ok";
|
|
83
|
+
return `Sent ${action}. ACK: ${ackStatus}`;
|
|
84
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface DeviceCredential {
|
|
2
|
+
credentialId: string;
|
|
3
|
+
controllerToken: string;
|
|
4
|
+
endpoint: string;
|
|
5
|
+
deviceName?: string;
|
|
6
|
+
pairedAt?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface CredentialStore {
|
|
9
|
+
default: string;
|
|
10
|
+
devices: Record<string, DeviceCredential>;
|
|
11
|
+
}
|
|
12
|
+
export interface ZhiHandConfig {
|
|
13
|
+
controlPlaneEndpoint: string;
|
|
14
|
+
credentialId: string;
|
|
15
|
+
controllerToken: string;
|
|
16
|
+
edgeId?: string;
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
}
|
|
19
|
+
export declare function resolveZhiHandDir(): string;
|
|
20
|
+
export declare function ensureZhiHandDir(): void;
|
|
21
|
+
export declare function loadCredentialStore(): CredentialStore | null;
|
|
22
|
+
export declare function loadDefaultCredential(): DeviceCredential | null;
|
|
23
|
+
export declare function saveCredential(name: string, cred: DeviceCredential, setDefault?: boolean): void;
|
|
24
|
+
export declare function resolveConfig(deviceName?: string): ZhiHandConfig;
|
|
25
|
+
export declare function loadState<T = unknown>(): T | null;
|
|
26
|
+
export declare function saveState(state: unknown): void;
|