@zhihand/mcp 0.18.2 → 0.19.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/bin/zhihand CHANGED
@@ -131,17 +131,29 @@ switch (command) {
131
131
  case "relay": {
132
132
  if (values.detach) {
133
133
  const { spawn: spawnChild } = await import("node:child_process");
134
+ const fsSync = await import("node:fs");
135
+ const pathMod = await import("node:path");
136
+ const osMod = await import("node:os");
137
+
134
138
  const args = [process.argv[1], "start"];
135
139
  if (values.port) args.push("--port", values.port);
136
140
  if (values.device) args.push("--device", values.device);
137
141
 
142
+ // Write daemon logs to ~/.zhihand/daemon.log
143
+ const zhihandDir = pathMod.default.join(osMod.default.homedir(), ".zhihand");
144
+ fsSync.default.mkdirSync(zhihandDir, { recursive: true });
145
+ const logPath = pathMod.default.join(zhihandDir, "daemon.log");
146
+ const logFd = fsSync.default.openSync(logPath, "a");
147
+
138
148
  const child = spawnChild(process.execPath, args, {
139
149
  detached: true,
140
- stdio: "ignore",
150
+ stdio: ["ignore", logFd, logFd],
141
151
  env: { ...process.env },
142
152
  });
143
153
  child.unref();
154
+ fsSync.default.closeSync(logFd);
144
155
  console.log(`Daemon starting in background (PID ${child.pid}).`);
156
+ console.log(`Logs: ${logPath}`);
145
157
  process.exit(0);
146
158
  }
147
159
  const port = values.port ? parseInt(values.port, 10) : undefined;
@@ -1,6 +1,7 @@
1
1
  export interface CLITool {
2
2
  name: "claudecode" | "codex" | "gemini" | "openclaw";
3
3
  command: string;
4
+ resolvedPath: string;
4
5
  version: string;
5
6
  loggedIn: boolean;
6
7
  priority: number;
@@ -1,4 +1,5 @@
1
1
  import { execSync } from "node:child_process";
2
+ import { resolveExecutable, resolveGemini, resolveClaude, resolveCodex } from "../core/resolve-path.js";
2
3
  function tryExec(cmd) {
3
4
  try {
4
5
  return execSync(cmd, { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }).trim();
@@ -7,41 +8,39 @@ function tryExec(cmd) {
7
8
  return null;
8
9
  }
9
10
  }
10
- function isCommandAvailable(cmd) {
11
- return tryExec(`which ${cmd}`) !== null;
12
- }
13
11
  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/
12
+ const resolved = resolveClaude();
13
+ if (resolved === "claude")
14
+ return null; // bare name = not found
15
+ const version = tryExec(`"${resolved}" --version`) ?? "unknown";
18
16
  const loggedIn = tryExec("ls ~/.claude/settings.json") !== null;
19
- return { name: "claudecode", command: "claude", version, loggedIn, priority: 1 };
17
+ return { name: "claudecode", command: "claude", resolvedPath: resolved, version, loggedIn, priority: 1 };
20
18
  }
21
19
  async function detectCodex() {
22
- if (!isCommandAvailable("codex"))
20
+ const resolved = resolveCodex();
21
+ if (resolved === "codex")
23
22
  return null;
24
- const version = tryExec("codex --version") ?? "unknown";
25
- // Check login: OPENAI_API_KEY env var or config
23
+ const version = tryExec(`"${resolved}" --version`) ?? "unknown";
26
24
  const loggedIn = !!process.env.OPENAI_API_KEY || tryExec("ls ~/.codex/") !== null;
27
- return { name: "codex", command: "codex", version, loggedIn, priority: 2 };
25
+ return { name: "codex", command: "codex", resolvedPath: resolved, version, loggedIn, priority: 2 };
28
26
  }
29
27
  async function detectGemini() {
30
- if (!isCommandAvailable("gemini"))
28
+ const resolved = resolveGemini();
29
+ if (resolved === "gemini")
31
30
  return null;
32
- const version = tryExec("gemini --version") ?? "unknown";
33
- // Check login: oauth_creds.json or GOOGLE_API_KEY env var
31
+ const version = tryExec(`"${resolved}" --version`) ?? "unknown";
34
32
  const loggedIn = !!process.env.GOOGLE_API_KEY
35
33
  || !!process.env.GEMINI_API_KEY
36
34
  || tryExec("ls ~/.gemini/oauth_creds.json") !== null;
37
- return { name: "gemini", command: "gemini", version, loggedIn, priority: 3 };
35
+ return { name: "gemini", command: "gemini", resolvedPath: resolved, version, loggedIn, priority: 3 };
38
36
  }
39
37
  async function detectOpenClaw() {
40
- if (!isCommandAvailable("openclaw"))
38
+ const resolved = resolveExecutable("openclaw", []);
39
+ if (resolved === "openclaw")
41
40
  return null;
42
- const version = tryExec("openclaw --version") ?? "unknown";
41
+ const version = tryExec(`"${resolved}" --version`) ?? "unknown";
43
42
  const loggedIn = tryExec("ls ~/.openclaw/openclaw.json") !== null;
44
- return { name: "openclaw", command: "openclaw", version, loggedIn, priority: 4 };
43
+ return { name: "openclaw", command: "openclaw", resolvedPath: resolved, version, loggedIn, priority: 4 };
45
44
  }
46
45
  export async function detectCLITools() {
47
46
  const results = await Promise.allSettled([
@@ -1,21 +1,26 @@
1
1
  import { execSync } from "node:child_process";
2
+ import { resolveClaude, resolveCodex, resolveGemini } from "../core/resolve-path.js";
2
3
  const DEFAULT_PORT = 18686;
3
4
  function mcpUrl() {
4
5
  const port = parseInt(process.env.ZHIHAND_PORT ?? "", 10) || DEFAULT_PORT;
5
6
  return `http://localhost:${port}/mcp`;
6
7
  }
8
+ /** Quote a path for shell execution (handles spaces in paths) */
9
+ function q(p) {
10
+ return `"${p}"`;
11
+ }
7
12
  const MCP_COMMANDS = {
8
13
  claudecode: {
9
- add: () => `claude mcp add --transport http zhihand ${mcpUrl()}`,
10
- remove: "claude mcp remove zhihand",
14
+ add: () => `${q(resolveClaude())} mcp add --transport http zhihand ${mcpUrl()}`,
15
+ remove: () => `${q(resolveClaude())} mcp remove zhihand`,
11
16
  },
12
17
  codex: {
13
- add: () => `codex mcp add zhihand --url ${mcpUrl()}`,
14
- remove: "codex mcp remove zhihand",
18
+ add: () => `${q(resolveCodex())} mcp add zhihand --url ${mcpUrl()}`,
19
+ remove: () => `${q(resolveCodex())} mcp remove zhihand`,
15
20
  },
16
21
  gemini: {
17
- add: () => `gemini mcp add --transport http --scope user zhihand ${mcpUrl()}`,
18
- remove: "gemini mcp remove --scope user zhihand",
22
+ add: () => `${q(resolveGemini())} mcp add --transport http --scope user zhihand ${mcpUrl()}`,
23
+ remove: () => `${q(resolveGemini())} mcp remove --scope user zhihand`,
19
24
  },
20
25
  };
21
26
  const DISPLAY_NAMES = {
@@ -44,7 +49,7 @@ export function configureMCP(backend, previousBackend) {
44
49
  const cmds = MCP_COMMANDS[previousBackend];
45
50
  if (cmds) {
46
51
  console.log(` Removing MCP config from ${DISPLAY_NAMES[previousBackend]}...`);
47
- removed = tryRun(cmds.remove);
52
+ removed = tryRun(cmds.remove());
48
53
  }
49
54
  }
50
55
  // Add to new backend
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Resolve an executable by name: first try `which`, then check fallback paths.
3
+ * Supports a single `*` glob segment in fallback paths (for version directories).
4
+ * Returns the full path, or the bare name as last resort.
5
+ */
6
+ export declare function resolveExecutable(name: string, fallbackPaths: string[]): string;
7
+ /** Platform-specific fallback paths for gemini */
8
+ export declare function resolveGemini(): string;
9
+ /** Platform-specific fallback paths for claude */
10
+ export declare function resolveClaude(): string;
11
+ /** Platform-specific fallback paths for codex */
12
+ export declare function resolveCodex(): string;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Platform-aware executable path resolution.
3
+ * Shared by both the CLI detection layer and the daemon dispatcher.
4
+ */
5
+ import { execSync } from "node:child_process";
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import os from "node:os";
9
+ /** Cache of resolved executable paths to avoid repeated lookups */
10
+ const cache = new Map();
11
+ /**
12
+ * Resolve an executable by name: first try `which`, then check fallback paths.
13
+ * Supports a single `*` glob segment in fallback paths (for version directories).
14
+ * Returns the full path, or the bare name as last resort.
15
+ */
16
+ export function resolveExecutable(name, fallbackPaths) {
17
+ const cached = cache.get(name);
18
+ if (cached)
19
+ return cached;
20
+ // Try `which` first (works when the binary is in PATH)
21
+ try {
22
+ const resolved = execSync(`which ${name}`, { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }).trim();
23
+ if (resolved) {
24
+ cache.set(name, resolved);
25
+ return resolved;
26
+ }
27
+ }
28
+ catch {
29
+ // Not in PATH, try fallback locations
30
+ }
31
+ for (const candidate of fallbackPaths) {
32
+ if (candidate.includes("*")) {
33
+ // Expand one level of wildcard
34
+ try {
35
+ const parts = candidate.split("*");
36
+ if (parts.length === 2) {
37
+ const parentDir = parts[0].replace(/\/$/, "");
38
+ const suffix = parts[1];
39
+ if (fs.existsSync(parentDir)) {
40
+ const entries = fs.readdirSync(parentDir, { withFileTypes: true });
41
+ // Sort descending to prefer latest version
42
+ const dirs = entries
43
+ .filter(e => e.isDirectory())
44
+ .map(e => e.name)
45
+ .sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));
46
+ for (const d of dirs) {
47
+ const full = parentDir + "/" + d + suffix;
48
+ if (fs.existsSync(full)) {
49
+ cache.set(name, full);
50
+ return full;
51
+ }
52
+ }
53
+ }
54
+ }
55
+ }
56
+ catch { /* skip */ }
57
+ }
58
+ else {
59
+ if (fs.existsSync(candidate)) {
60
+ cache.set(name, candidate);
61
+ return candidate;
62
+ }
63
+ }
64
+ }
65
+ // Last resort: return bare name and let spawn fail with a clear error
66
+ return name;
67
+ }
68
+ /** Platform-specific fallback paths for gemini */
69
+ export function resolveGemini() {
70
+ return resolveExecutable("gemini", [
71
+ "/opt/homebrew/bin/gemini",
72
+ "/usr/local/bin/gemini",
73
+ path.join(os.homedir(), ".local/bin/gemini"),
74
+ path.join(os.homedir(), "bin/gemini"),
75
+ ]);
76
+ }
77
+ /** Platform-specific fallback paths for claude */
78
+ export function resolveClaude() {
79
+ const home = os.homedir();
80
+ const fallbacks = [];
81
+ if (process.platform === "darwin") {
82
+ fallbacks.push(path.join(home, "Library/Application Support/Claude/claude-code/*/claude.app/Contents/MacOS/claude"), "/usr/local/bin/claude", "/opt/homebrew/bin/claude");
83
+ }
84
+ else if (process.platform === "linux") {
85
+ fallbacks.push("/usr/local/bin/claude", path.join(home, ".local/bin/claude"), "/snap/bin/claude");
86
+ }
87
+ else if (process.platform === "win32") {
88
+ fallbacks.push(path.join(process.env.LOCALAPPDATA ?? "", "Programs/Claude/claude.exe"), path.join(process.env.APPDATA ?? "", "npm/claude.cmd"));
89
+ }
90
+ return resolveExecutable("claude", fallbacks);
91
+ }
92
+ /** Platform-specific fallback paths for codex */
93
+ export function resolveCodex() {
94
+ return resolveExecutable("codex", [
95
+ "/opt/homebrew/bin/codex",
96
+ "/usr/local/bin/codex",
97
+ path.join(os.homedir(), ".local/bin/codex"),
98
+ ]);
99
+ }
@@ -3,6 +3,7 @@ import fsp from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import os from "node:os";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { resolveGemini, resolveClaude, resolveCodex } from "../core/resolve-path.js";
6
7
  const CLI_TIMEOUT = 120_000; // 120s
7
8
  const SIGKILL_DELAY = 2_000; // 2s after SIGTERM
8
9
  const MAX_OUTPUT_BYTES = 100 * 1024; // 100KB
@@ -396,7 +397,8 @@ function dispatchGemini(prompt, startTime, log, model) {
396
397
  COLORTERM: "truecolor",
397
398
  };
398
399
  // Wrap with PTY so gemini sees isatty()==true
399
- const child = spawn("python3", [PTY_WRAP_SCRIPT, "gemini", ...cliArgs], {
400
+ const geminiPath = resolveGemini();
401
+ const child = spawn("python3", [PTY_WRAP_SCRIPT, geminiPath, ...cliArgs], {
400
402
  env,
401
403
  stdio: ["ignore", "pipe", "pipe"],
402
404
  detached: false,
@@ -417,7 +419,8 @@ function dispatchCodex(prompt, startTime, model) {
417
419
  args.push("-m", codexModel);
418
420
  }
419
421
  args.push(prompt);
420
- const child = spawn("codex", args, {
422
+ const codexPath = resolveCodex();
423
+ const child = spawn(codexPath, args, {
421
424
  env: process.env,
422
425
  stdio: ["ignore", "pipe", "pipe"],
423
426
  detached: false,
@@ -427,7 +430,8 @@ function dispatchCodex(prompt, startTime, model) {
427
430
  }
428
431
  // ── Claude Dispatch ────────────────────────────────────────
429
432
  function dispatchClaude(prompt, startTime, model) {
430
- const child = spawn("claude", ["-p", prompt, "--output-format", "json"], {
433
+ const claudePath = resolveClaude();
434
+ const child = spawn(claudePath, ["-p", prompt, "--output-format", "json"], {
431
435
  env: process.env,
432
436
  stdio: ["ignore", "pipe", "pipe"],
433
437
  detached: false,
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import { controlSchema, screenshotSchema, pairSchema } from "./tools/schemas.js"
5
5
  import { executeControl } from "./tools/control.js";
6
6
  import { handleScreenshot } from "./tools/screenshot.js";
7
7
  import { handlePair } from "./tools/pair.js";
8
- const PACKAGE_VERSION = "0.18.2";
8
+ const PACKAGE_VERSION = "0.19.1";
9
9
  export function createServer(deviceName) {
10
10
  const server = new McpServer({
11
11
  name: "zhihand",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhihand/mcp",
3
- "version": "0.18.2",
3
+ "version": "0.19.1",
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",