@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 +13 -1
- package/dist/cli/detect.d.ts +1 -0
- package/dist/cli/detect.js +18 -19
- package/dist/cli/mcp-config.js +12 -7
- package/dist/core/resolve-path.d.ts +12 -0
- package/dist/core/resolve-path.js +99 -0
- package/dist/daemon/dispatcher.js +4 -90
- package/dist/index.js +1 -1
- package/package.json +1 -1
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;
|
package/dist/cli/detect.d.ts
CHANGED
package/dist/cli/detect.js
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
20
|
+
const resolved = resolveCodex();
|
|
21
|
+
if (resolved === "codex")
|
|
23
22
|
return null;
|
|
24
|
-
const version = tryExec("
|
|
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
|
-
|
|
28
|
+
const resolved = resolveGemini();
|
|
29
|
+
if (resolved === "gemini")
|
|
31
30
|
return null;
|
|
32
|
-
const version = tryExec("
|
|
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
|
-
|
|
38
|
+
const resolved = resolveExecutable("openclaw", []);
|
|
39
|
+
if (resolved === "openclaw")
|
|
41
40
|
return null;
|
|
42
|
-
const version = tryExec("
|
|
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([
|
package/dist/cli/mcp-config.js
CHANGED
|
@@ -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: () =>
|
|
10
|
-
remove:
|
|
14
|
+
add: () => `${q(resolveClaude())} mcp add --transport http zhihand ${mcpUrl()}`,
|
|
15
|
+
remove: () => `${q(resolveClaude())} mcp remove zhihand`,
|
|
11
16
|
},
|
|
12
17
|
codex: {
|
|
13
|
-
add: () =>
|
|
14
|
-
remove:
|
|
18
|
+
add: () => `${q(resolveCodex())} mcp add zhihand --url ${mcpUrl()}`,
|
|
19
|
+
remove: () => `${q(resolveCodex())} mcp remove zhihand`,
|
|
15
20
|
},
|
|
16
21
|
gemini: {
|
|
17
|
-
add: () =>
|
|
18
|
-
remove:
|
|
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
|
|
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
|
|
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.
|
|
8
|
+
const PACKAGE_VERSION = "0.19.1";
|
|
9
9
|
export function createServer(deviceName) {
|
|
10
10
|
const server = new McpServer({
|
|
11
11
|
name: "zhihand",
|