@zhihand/mcp 0.19.0 → 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
+ }
@@ -1,9 +1,9 @@
1
- import { spawn, execSync } from "node:child_process";
2
- import fs from "node:fs";
1
+ import { spawn } from "node:child_process";
3
2
  import fsp from "node:fs/promises";
4
3
  import path from "node:path";
5
4
  import os from "node:os";
6
5
  import { fileURLToPath } from "node:url";
6
+ import { resolveGemini, resolveClaude, resolveCodex } from "../core/resolve-path.js";
7
7
  const CLI_TIMEOUT = 120_000; // 120s
8
8
  const SIGKILL_DELAY = 2_000; // 2s after SIGTERM
9
9
  const MAX_OUTPUT_BYTES = 100 * 1024; // 100KB
@@ -13,93 +13,6 @@ const SESSION_STABILITY_DELAY = 2_000; // wait 2s after outcome before returning
13
13
  // Resolve pty-wrap.py relative to this file (works from both src/ and dist/)
14
14
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
15
  const PTY_WRAP_SCRIPT = path.resolve(__dirname, "../../scripts/pty-wrap.py");
16
- // ── Executable Path Resolution ───────────────────────────────
17
- /** Cache of resolved executable paths to avoid repeated lookups */
18
- const executableCache = new Map();
19
- /**
20
- * Resolve the full path of a CLI executable.
21
- * Searches PATH first via `which`, then falls back to platform-specific known locations.
22
- */
23
- function resolveExecutable(name, fallbackPaths) {
24
- const cached = executableCache.get(name);
25
- if (cached)
26
- return cached;
27
- // Try `which` first (works when the binary is in PATH)
28
- try {
29
- const resolved = execSync(`which ${name}`, { encoding: "utf8", timeout: 5000 }).trim();
30
- if (resolved) {
31
- executableCache.set(name, resolved);
32
- return resolved;
33
- }
34
- }
35
- catch {
36
- // Not in PATH, try fallback locations
37
- }
38
- // Try known platform-specific paths
39
- for (const candidate of fallbackPaths) {
40
- // Support glob-like patterns with * (e.g. version directories)
41
- if (candidate.includes("*")) {
42
- try {
43
- const dir = path.dirname(candidate);
44
- const pattern = path.basename(candidate);
45
- // Walk one level of glob for version directories
46
- const parentDir = path.dirname(dir);
47
- const globSegment = path.basename(dir);
48
- if (globSegment === "*") {
49
- const entries = fs.readdirSync(parentDir, { withFileTypes: true });
50
- // Sort descending to prefer latest version
51
- const dirs = entries
52
- .filter(e => e.isDirectory())
53
- .map(e => e.name)
54
- .sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));
55
- for (const d of dirs) {
56
- const full = path.join(parentDir, d, pattern);
57
- if (fs.existsSync(full)) {
58
- executableCache.set(name, full);
59
- return full;
60
- }
61
- }
62
- }
63
- }
64
- catch {
65
- // Glob resolution failed, skip
66
- }
67
- }
68
- else {
69
- if (fs.existsSync(candidate)) {
70
- executableCache.set(name, candidate);
71
- return candidate;
72
- }
73
- }
74
- }
75
- // Last resort: return bare name and let spawn fail with a clear error
76
- return name;
77
- }
78
- /** Resolve gemini executable path */
79
- function resolveGemini() {
80
- return resolveExecutable("gemini", [
81
- "/opt/homebrew/bin/gemini", // macOS ARM (Homebrew)
82
- "/usr/local/bin/gemini", // macOS Intel / Linux
83
- path.join(os.homedir(), ".local/bin/gemini"), // pip --user install
84
- path.join(os.homedir(), "bin/gemini"),
85
- ]);
86
- }
87
- /** Resolve claude executable path */
88
- function resolveClaude() {
89
- const platform = process.platform;
90
- const fallbacks = [];
91
- if (platform === "darwin") {
92
- // macOS: Claude Code installed via Claude desktop app
93
- fallbacks.push(path.join(os.homedir(), "Library/Application Support/Claude/claude-code/*/claude.app/Contents/MacOS/claude"), "/usr/local/bin/claude", "/opt/homebrew/bin/claude");
94
- }
95
- else if (platform === "linux") {
96
- fallbacks.push("/usr/local/bin/claude", path.join(os.homedir(), ".local/bin/claude"), "/snap/bin/claude");
97
- }
98
- else if (platform === "win32") {
99
- fallbacks.push(path.join(process.env.LOCALAPPDATA ?? "", "Programs/Claude/claude.exe"), path.join(process.env.APPDATA ?? "", "npm/claude.cmd"));
100
- }
101
- return resolveExecutable("claude", fallbacks);
102
- }
103
16
  // Gemini session directories
104
17
  const GEMINI_TMP_DIR = path.join(os.homedir(), ".gemini", "tmp");
105
18
  let activeChild = null;
@@ -506,7 +419,8 @@ function dispatchCodex(prompt, startTime, model) {
506
419
  args.push("-m", codexModel);
507
420
  }
508
421
  args.push(prompt);
509
- const child = spawn("codex", args, {
422
+ const codexPath = resolveCodex();
423
+ const child = spawn(codexPath, args, {
510
424
  env: process.env,
511
425
  stdio: ["ignore", "pipe", "pipe"],
512
426
  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.19.0";
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.19.0",
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",