@zhihand/mcp 0.19.0 → 0.20.0
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 +34 -12
- 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/config.d.ts +9 -0
- package/dist/core/config.js +12 -0
- package/dist/core/resolve-path.d.ts +12 -0
- package/dist/core/resolve-path.js +99 -0
- package/dist/daemon/dispatcher.js +41 -100
- package/dist/daemon/index.js +24 -7
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -1
- package/package.json +1 -1
package/bin/zhihand
CHANGED
|
@@ -6,7 +6,7 @@ import { startStdioServer } from "../dist/index.js";
|
|
|
6
6
|
import { startDaemon, stopDaemon, isAlreadyRunning } from "../dist/daemon/index.js";
|
|
7
7
|
import { detectCLITools, formatDetectedTools } from "../dist/cli/detect.js";
|
|
8
8
|
import { detectAndSetupOpenClaw } from "../dist/cli/openclaw.js";
|
|
9
|
-
import { loadDefaultCredential, loadBackendConfig, saveBackendConfig } from "../dist/core/config.js";
|
|
9
|
+
import { loadDefaultCredential, loadBackendConfig, saveBackendConfig, DEFAULT_MODELS } from "../dist/core/config.js";
|
|
10
10
|
import { executePairing } from "../dist/core/pair.js";
|
|
11
11
|
import { configureMCP, displayName } from "../dist/cli/mcp-config.js";
|
|
12
12
|
|
|
@@ -23,6 +23,7 @@ const { positionals, values } = parseArgs({
|
|
|
23
23
|
strict: false,
|
|
24
24
|
options: {
|
|
25
25
|
device: { type: "string" },
|
|
26
|
+
model: { type: "string", short: "m" },
|
|
26
27
|
help: { type: "boolean", short: "h", default: false },
|
|
27
28
|
detach: { type: "boolean", short: "d", default: false },
|
|
28
29
|
port: { type: "string" },
|
|
@@ -41,9 +42,10 @@ Usage:
|
|
|
41
42
|
zhihand stop Stop daemon
|
|
42
43
|
zhihand status Show status (pairing, backend, brain)
|
|
43
44
|
|
|
44
|
-
zhihand gemini Switch backend to Gemini CLI
|
|
45
|
-
zhihand claude Switch backend to Claude Code
|
|
46
|
-
zhihand codex Switch backend to Codex CLI
|
|
45
|
+
zhihand gemini Switch backend to Gemini CLI (default model: flash)
|
|
46
|
+
zhihand claude Switch backend to Claude Code (default model: sonnet)
|
|
47
|
+
zhihand codex Switch backend to Codex CLI (default model: gpt-5.4-mini)
|
|
48
|
+
zhihand gemini --model pro Switch backend with custom model
|
|
47
49
|
|
|
48
50
|
zhihand setup Interactive setup: pair + configure + start
|
|
49
51
|
zhihand pair Pair with a phone device
|
|
@@ -53,6 +55,7 @@ Usage:
|
|
|
53
55
|
|
|
54
56
|
Options:
|
|
55
57
|
--device <name> Use a specific paired device
|
|
58
|
+
--model, -m <name> Set model alias (e.g. flash, pro, sonnet, opus, gpt-5.4-mini)
|
|
56
59
|
--port <port> Override daemon port (default: 18686)
|
|
57
60
|
-d, --detach Run daemon in background
|
|
58
61
|
-h, --help Show this help
|
|
@@ -82,12 +85,15 @@ if (Object.prototype.hasOwnProperty.call(CLI_TOOL_MAP, command)) {
|
|
|
82
85
|
const config = loadBackendConfig();
|
|
83
86
|
const previous = config.activeBackend;
|
|
84
87
|
|
|
85
|
-
|
|
86
|
-
|
|
88
|
+
const userModel = values.model ?? null;
|
|
89
|
+
const effectiveModel = userModel ?? DEFAULT_MODELS[backendName];
|
|
90
|
+
|
|
91
|
+
if (previous === backendName && !userModel) {
|
|
92
|
+
console.log(`Already using ${displayName(backendName)} as backend (model: ${effectiveModel}).`);
|
|
87
93
|
process.exit(0);
|
|
88
94
|
}
|
|
89
95
|
|
|
90
|
-
console.log(`Switching backend to ${displayName(backendName)}...`);
|
|
96
|
+
console.log(`Switching backend to ${displayName(backendName)} (model: ${effectiveModel})...`);
|
|
91
97
|
|
|
92
98
|
// Configure MCP (HTTP transport)
|
|
93
99
|
const { configured, removed } = configureMCP(backendName, previous);
|
|
@@ -100,7 +106,7 @@ if (Object.prototype.hasOwnProperty.call(CLI_TOOL_MAP, command)) {
|
|
|
100
106
|
const res = await fetch(`http://127.0.0.1:${port}/internal/backend`, {
|
|
101
107
|
method: "POST",
|
|
102
108
|
headers: { "Content-Type": "application/json" },
|
|
103
|
-
body: JSON.stringify({ backend: backendName }),
|
|
109
|
+
body: JSON.stringify({ backend: backendName, model: userModel }),
|
|
104
110
|
signal: AbortSignal.timeout(5000),
|
|
105
111
|
});
|
|
106
112
|
if (res.ok) {
|
|
@@ -108,11 +114,11 @@ if (Object.prototype.hasOwnProperty.call(CLI_TOOL_MAP, command)) {
|
|
|
108
114
|
}
|
|
109
115
|
} catch {
|
|
110
116
|
// Daemon not responding, just save config
|
|
111
|
-
saveBackendConfig({ activeBackend: backendName });
|
|
117
|
+
saveBackendConfig({ activeBackend: backendName, model: userModel });
|
|
112
118
|
console.log(`\nBackend config saved. Daemon not responding — restart with 'zhihand start'.`);
|
|
113
119
|
}
|
|
114
120
|
} else {
|
|
115
|
-
saveBackendConfig({ activeBackend: backendName });
|
|
121
|
+
saveBackendConfig({ activeBackend: backendName, model: userModel });
|
|
116
122
|
console.log(`\nBackend switched to ${displayName(backendName)}.`);
|
|
117
123
|
console.log(`Start the daemon to receive prompts: zhihand start`);
|
|
118
124
|
}
|
|
@@ -131,17 +137,29 @@ switch (command) {
|
|
|
131
137
|
case "relay": {
|
|
132
138
|
if (values.detach) {
|
|
133
139
|
const { spawn: spawnChild } = await import("node:child_process");
|
|
140
|
+
const fsSync = await import("node:fs");
|
|
141
|
+
const pathMod = await import("node:path");
|
|
142
|
+
const osMod = await import("node:os");
|
|
143
|
+
|
|
134
144
|
const args = [process.argv[1], "start"];
|
|
135
145
|
if (values.port) args.push("--port", values.port);
|
|
136
146
|
if (values.device) args.push("--device", values.device);
|
|
137
147
|
|
|
148
|
+
// Write daemon logs to ~/.zhihand/daemon.log
|
|
149
|
+
const zhihandDir = pathMod.default.join(osMod.default.homedir(), ".zhihand");
|
|
150
|
+
fsSync.default.mkdirSync(zhihandDir, { recursive: true });
|
|
151
|
+
const logPath = pathMod.default.join(zhihandDir, "daemon.log");
|
|
152
|
+
const logFd = fsSync.default.openSync(logPath, "a");
|
|
153
|
+
|
|
138
154
|
const child = spawnChild(process.execPath, args, {
|
|
139
155
|
detached: true,
|
|
140
|
-
stdio: "ignore",
|
|
156
|
+
stdio: ["ignore", logFd, logFd],
|
|
141
157
|
env: { ...process.env },
|
|
142
158
|
});
|
|
143
159
|
child.unref();
|
|
160
|
+
fsSync.default.closeSync(logFd);
|
|
144
161
|
console.log(`Daemon starting in background (PID ${child.pid}).`);
|
|
162
|
+
console.log(`Logs: ${logPath}`);
|
|
145
163
|
process.exit(0);
|
|
146
164
|
}
|
|
147
165
|
const port = values.port ? parseInt(values.port, 10) : undefined;
|
|
@@ -187,7 +205,11 @@ switch (command) {
|
|
|
187
205
|
} else {
|
|
188
206
|
console.log("No paired device. Run: zhihand setup");
|
|
189
207
|
}
|
|
190
|
-
|
|
208
|
+
const backendLabel = backend.activeBackend ? displayName(backend.activeBackend) : "(none)";
|
|
209
|
+
const modelLabel = backend.activeBackend
|
|
210
|
+
? (backend.model ?? DEFAULT_MODELS[backend.activeBackend])
|
|
211
|
+
: "-";
|
|
212
|
+
console.log(`Active backend: ${backendLabel} (model: ${modelLabel})`);
|
|
191
213
|
console.log(`Daemon: ${daemonPid ? `running (PID ${daemonPid})` : "not running"}`);
|
|
192
214
|
|
|
193
215
|
// If daemon running, get live status
|
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
|
package/dist/core/config.d.ts
CHANGED
|
@@ -19,7 +19,16 @@ export interface ZhiHandConfig {
|
|
|
19
19
|
export type BackendName = "claudecode" | "codex" | "gemini" | "openclaw";
|
|
20
20
|
export interface BackendConfig {
|
|
21
21
|
activeBackend: BackendName | null;
|
|
22
|
+
model?: string | null;
|
|
22
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* Default model aliases per backend.
|
|
26
|
+
* These are generic aliases that the respective CLIs resolve to the latest version:
|
|
27
|
+
* - Gemini CLI: "flash" → latest flash model (e.g. gemini-2.5-flash)
|
|
28
|
+
* - Claude Code: "sonnet" → latest sonnet (e.g. claude-sonnet-4-20250514)
|
|
29
|
+
* - Codex CLI: requires full model name, no alias support
|
|
30
|
+
*/
|
|
31
|
+
export declare const DEFAULT_MODELS: Record<Exclude<BackendName, "openclaw">, string>;
|
|
23
32
|
export declare function resolveZhiHandDir(): string;
|
|
24
33
|
export declare function ensureZhiHandDir(): void;
|
|
25
34
|
export declare function loadCredentialStore(): CredentialStore | null;
|
package/dist/core/config.js
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import os from "node:os";
|
|
4
|
+
/**
|
|
5
|
+
* Default model aliases per backend.
|
|
6
|
+
* These are generic aliases that the respective CLIs resolve to the latest version:
|
|
7
|
+
* - Gemini CLI: "flash" → latest flash model (e.g. gemini-2.5-flash)
|
|
8
|
+
* - Claude Code: "sonnet" → latest sonnet (e.g. claude-sonnet-4-20250514)
|
|
9
|
+
* - Codex CLI: requires full model name, no alias support
|
|
10
|
+
*/
|
|
11
|
+
export const DEFAULT_MODELS = {
|
|
12
|
+
gemini: "flash", // Gemini CLI resolves to latest flash
|
|
13
|
+
claudecode: "sonnet", // Claude Code resolves to latest sonnet
|
|
14
|
+
codex: "gpt-5.4-mini", // Codex default: latest GPT mini model
|
|
15
|
+
};
|
|
4
16
|
const ZHIHAND_DIR = path.join(os.homedir(), ".zhihand");
|
|
5
17
|
const CREDENTIALS_PATH = path.join(ZHIHAND_DIR, "credentials.json");
|
|
6
18
|
const STATE_PATH = path.join(ZHIHAND_DIR, "state.json");
|
|
@@ -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,10 @@
|
|
|
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 { DEFAULT_MODELS } from "../core/config.js";
|
|
7
|
+
import { resolveGemini, resolveClaude, resolveCodex } from "../core/resolve-path.js";
|
|
7
8
|
const CLI_TIMEOUT = 120_000; // 120s
|
|
8
9
|
const SIGKILL_DELAY = 2_000; // 2s after SIGTERM
|
|
9
10
|
const MAX_OUTPUT_BYTES = 100 * 1024; // 100KB
|
|
@@ -13,93 +14,6 @@ const SESSION_STABILITY_DELAY = 2_000; // wait 2s after outcome before returning
|
|
|
13
14
|
// Resolve pty-wrap.py relative to this file (works from both src/ and dist/)
|
|
14
15
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
15
16
|
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
17
|
// Gemini session directories
|
|
104
18
|
const GEMINI_TMP_DIR = path.join(os.homedir(), ".gemini", "tmp");
|
|
105
19
|
let activeChild = null;
|
|
@@ -454,14 +368,17 @@ ${userPrompt}`;
|
|
|
454
368
|
export function dispatchToCLI(backend, prompt, log, model) {
|
|
455
369
|
const startTime = Date.now();
|
|
456
370
|
const wrappedPrompt = wrapPrompt(prompt);
|
|
371
|
+
// Resolve model: explicit > env > default
|
|
372
|
+
const resolvedModel = resolveModel(backend, model);
|
|
373
|
+
log(`[dispatch] Backend: ${backend}, Model: ${resolvedModel}`);
|
|
457
374
|
if (backend === "gemini") {
|
|
458
|
-
return dispatchGemini(wrappedPrompt, startTime, log,
|
|
375
|
+
return dispatchGemini(wrappedPrompt, startTime, log, resolvedModel);
|
|
459
376
|
}
|
|
460
377
|
if (backend === "codex") {
|
|
461
|
-
return dispatchCodex(wrappedPrompt, startTime,
|
|
378
|
+
return dispatchCodex(wrappedPrompt, startTime, resolvedModel);
|
|
462
379
|
}
|
|
463
380
|
if (backend === "claudecode") {
|
|
464
|
-
return dispatchClaude(wrappedPrompt, startTime,
|
|
381
|
+
return dispatchClaude(wrappedPrompt, startTime, resolvedModel);
|
|
465
382
|
}
|
|
466
383
|
return Promise.resolve({
|
|
467
384
|
text: `Unsupported backend: ${backend}`,
|
|
@@ -469,12 +386,38 @@ export function dispatchToCLI(backend, prompt, log, model) {
|
|
|
469
386
|
durationMs: 0,
|
|
470
387
|
});
|
|
471
388
|
}
|
|
389
|
+
/**
|
|
390
|
+
* Resolve the model to use for a backend.
|
|
391
|
+
* Priority: explicit parameter > ZHIHAND_MODEL env > backend-specific env > default alias.
|
|
392
|
+
*
|
|
393
|
+
* Each backend CLI handles alias→full-name resolution natively:
|
|
394
|
+
* - Gemini CLI: "flash" → gemini-2.5-flash, "pro" → gemini-2.5-pro
|
|
395
|
+
* - Claude Code: "sonnet" → claude-sonnet-4-*, "opus" → claude-opus-4-*, "haiku" → claude-haiku-4-*
|
|
396
|
+
* - Codex CLI: no alias support — pass full model name directly (e.g. "o4-mini", "codex-mini")
|
|
397
|
+
*/
|
|
398
|
+
function resolveModel(backend, explicit) {
|
|
399
|
+
if (explicit)
|
|
400
|
+
return explicit;
|
|
401
|
+
// Global env override
|
|
402
|
+
const globalEnv = process.env.ZHIHAND_MODEL;
|
|
403
|
+
if (globalEnv)
|
|
404
|
+
return globalEnv;
|
|
405
|
+
// Per-backend env override
|
|
406
|
+
const envMap = {
|
|
407
|
+
gemini: process.env.ZHIHAND_GEMINI_MODEL,
|
|
408
|
+
claudecode: process.env.ZHIHAND_CLAUDE_MODEL,
|
|
409
|
+
codex: process.env.ZHIHAND_CODEX_MODEL,
|
|
410
|
+
};
|
|
411
|
+
const perBackend = envMap[backend];
|
|
412
|
+
if (perBackend)
|
|
413
|
+
return perBackend;
|
|
414
|
+
return DEFAULT_MODELS[backend];
|
|
415
|
+
}
|
|
472
416
|
// ── Gemini Dispatch (PTY + Session File Monitoring) ────────
|
|
473
417
|
function dispatchGemini(prompt, startTime, log, model) {
|
|
474
|
-
const geminiModel = model ?? process.env.CLAUDE_GEMINI_MODEL ?? "gemini-3.1-pro-preview";
|
|
475
418
|
const cliArgs = [
|
|
476
419
|
"--approval-mode", "yolo",
|
|
477
|
-
"--model",
|
|
420
|
+
"--model", model,
|
|
478
421
|
"-i", prompt,
|
|
479
422
|
];
|
|
480
423
|
const env = {
|
|
@@ -501,12 +444,10 @@ function dispatchCodex(prompt, startTime, model) {
|
|
|
501
444
|
// --dangerously-bypass-approvals-and-sandbox is required so MCP tool calls
|
|
502
445
|
// are not auto-cancelled in non-interactive mode (--full-auto cancels them)
|
|
503
446
|
const args = ["exec", "--dangerously-bypass-approvals-and-sandbox", "--skip-git-repo-check", "--json"];
|
|
504
|
-
|
|
505
|
-
if (codexModel) {
|
|
506
|
-
args.push("-m", codexModel);
|
|
507
|
-
}
|
|
447
|
+
args.push("-m", model);
|
|
508
448
|
args.push(prompt);
|
|
509
|
-
const
|
|
449
|
+
const codexPath = resolveCodex();
|
|
450
|
+
const child = spawn(codexPath, args, {
|
|
510
451
|
env: process.env,
|
|
511
452
|
stdio: ["ignore", "pipe", "pipe"],
|
|
512
453
|
detached: false,
|
|
@@ -517,7 +458,7 @@ function dispatchCodex(prompt, startTime, model) {
|
|
|
517
458
|
// ── Claude Dispatch ────────────────────────────────────────
|
|
518
459
|
function dispatchClaude(prompt, startTime, model) {
|
|
519
460
|
const claudePath = resolveClaude();
|
|
520
|
-
const child = spawn(claudePath, ["-p", prompt, "--output-format", "json"], {
|
|
461
|
+
const child = spawn(claudePath, ["-p", prompt, "--model", model, "--output-format", "json"], {
|
|
521
462
|
env: process.env,
|
|
522
463
|
stdio: ["ignore", "pipe", "pipe"],
|
|
523
464
|
detached: false,
|
package/dist/daemon/index.js
CHANGED
|
@@ -5,7 +5,8 @@ import path from "node:path";
|
|
|
5
5
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
6
6
|
// Transport type used only for cleanup interface
|
|
7
7
|
import { createServer as createMcpServer } from "../index.js";
|
|
8
|
-
import { resolveConfig, loadBackendConfig, saveBackendConfig, resolveZhiHandDir, ensureZhiHandDir, } from "../core/config.js";
|
|
8
|
+
import { resolveConfig, loadBackendConfig, saveBackendConfig, resolveZhiHandDir, ensureZhiHandDir, DEFAULT_MODELS, } from "../core/config.js";
|
|
9
|
+
import { PACKAGE_VERSION } from "../index.js";
|
|
9
10
|
import { startHeartbeatLoop, stopHeartbeatLoop, sendBrainOffline } from "./heartbeat.js";
|
|
10
11
|
import { PromptListener } from "./prompt-listener.js";
|
|
11
12
|
import { dispatchToCLI, postReply, killActiveChild } from "./dispatcher.js";
|
|
@@ -13,6 +14,7 @@ const DEFAULT_PORT = 18686;
|
|
|
13
14
|
const PID_FILE = "daemon.pid";
|
|
14
15
|
// ── State ──────────────────────────────────────────────────
|
|
15
16
|
let activeBackend = null;
|
|
17
|
+
let activeModel = null; // user-selected model alias, null = use default
|
|
16
18
|
let isProcessing = false;
|
|
17
19
|
const promptQueue = [];
|
|
18
20
|
function log(msg) {
|
|
@@ -28,7 +30,7 @@ async function processPrompt(config, prompt) {
|
|
|
28
30
|
}
|
|
29
31
|
const preview = prompt.text.length > 40 ? prompt.text.slice(0, 40) + "..." : prompt.text;
|
|
30
32
|
log(`[relay] Prompt: "${preview}" → dispatching to ${activeBackend}...`);
|
|
31
|
-
const result = await dispatchToCLI(activeBackend, prompt.text, log);
|
|
33
|
+
const result = await dispatchToCLI(activeBackend, prompt.text, log, activeModel ?? undefined);
|
|
32
34
|
const ok = await postReply(config, prompt.id, result.text);
|
|
33
35
|
const dur = (result.durationMs / 1000).toFixed(1);
|
|
34
36
|
if (ok) {
|
|
@@ -69,7 +71,7 @@ function handleInternalAPI(req, res) {
|
|
|
69
71
|
});
|
|
70
72
|
req.on("end", () => {
|
|
71
73
|
try {
|
|
72
|
-
const { backend } = JSON.parse(body);
|
|
74
|
+
const { backend, model } = JSON.parse(body);
|
|
73
75
|
const allowed = ["claudecode", "codex", "gemini"];
|
|
74
76
|
if (!allowed.includes(backend)) {
|
|
75
77
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
@@ -77,10 +79,12 @@ function handleInternalAPI(req, res) {
|
|
|
77
79
|
return;
|
|
78
80
|
}
|
|
79
81
|
activeBackend = backend;
|
|
80
|
-
|
|
81
|
-
|
|
82
|
+
activeModel = model ?? null;
|
|
83
|
+
saveBackendConfig({ activeBackend, model: activeModel });
|
|
84
|
+
const effectiveModel = activeModel ?? DEFAULT_MODELS[activeBackend];
|
|
85
|
+
log(`[config] Backend switched to ${activeBackend}, model: ${effectiveModel}`);
|
|
82
86
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
83
|
-
res.end(JSON.stringify({ ok: true, backend: activeBackend }));
|
|
87
|
+
res.end(JSON.stringify({ ok: true, backend: activeBackend, model: effectiveModel }));
|
|
84
88
|
}
|
|
85
89
|
catch {
|
|
86
90
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
@@ -90,9 +94,12 @@ function handleInternalAPI(req, res) {
|
|
|
90
94
|
return true;
|
|
91
95
|
}
|
|
92
96
|
if (url === "/internal/status" && req.method === "GET") {
|
|
97
|
+
const effectiveModel = activeBackend ? (activeModel ?? DEFAULT_MODELS[activeBackend]) : null;
|
|
93
98
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
94
99
|
res.end(JSON.stringify({
|
|
100
|
+
version: PACKAGE_VERSION,
|
|
95
101
|
backend: activeBackend,
|
|
102
|
+
model: effectiveModel,
|
|
96
103
|
processing: isProcessing,
|
|
97
104
|
queueLength: promptQueue.length,
|
|
98
105
|
pid: process.pid,
|
|
@@ -155,9 +162,19 @@ export async function startDaemon(options) {
|
|
|
155
162
|
log("Run 'zhihand setup' to pair a device first.");
|
|
156
163
|
process.exit(1);
|
|
157
164
|
}
|
|
158
|
-
// Load backend
|
|
165
|
+
// Load backend + model
|
|
159
166
|
const backendConfig = loadBackendConfig();
|
|
160
167
|
activeBackend = backendConfig.activeBackend ?? null;
|
|
168
|
+
activeModel = backendConfig.model ?? null;
|
|
169
|
+
// Log startup info
|
|
170
|
+
log(`ZhiHand v${PACKAGE_VERSION} starting...`);
|
|
171
|
+
if (activeBackend) {
|
|
172
|
+
const effectiveModel = activeModel ?? DEFAULT_MODELS[activeBackend];
|
|
173
|
+
log(`[config] Backend: ${activeBackend}, Model: ${effectiveModel}`);
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
log(`[config] No backend configured. Use: zhihand gemini / zhihand claude / zhihand codex`);
|
|
177
|
+
}
|
|
161
178
|
// MCP sessions: each client gets its own McpServer + Transport pair
|
|
162
179
|
// because McpServer.connect() can only be called once per instance
|
|
163
180
|
const MAX_MCP_SESSIONS = 20;
|
package/dist/index.d.ts
CHANGED
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.
|
|
8
|
+
export const PACKAGE_VERSION = "0.20.0";
|
|
9
9
|
export function createServer(deviceName) {
|
|
10
10
|
const server = new McpServer({
|
|
11
11
|
name: "zhihand",
|